= emptyList()
+ var sectionTitle: String = ""
+ var emptyTitle: String = ""
+ // Nullable — omitted from the snapshot when the caller does not send a tts object.
+ // Note: Tauri parseArgs uses Gson for deserialization; a nullable nested @InvokeArg
+ // field is set to null when the key is absent from the JSON payload, which is the
+ // expected behavior. If deserialization issues arise at runtime, fall back to two
+ // flat optional fields (ttsActive: Boolean? / ttsPlaying: Boolean?).
+ var tts: UpdateReadingWidgetTtsArgs? = null
+}
+
data class ProductData(
val id: String,
val title: String,
@@ -1055,6 +1083,40 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}
+ @Command
+ fun update_reading_widget(invoke: Invoke) {
+ val args = invoke.parseArgs(UpdateReadingWidgetRequestArgs::class.java)
+ pluginScope.launch {
+ withContext(Dispatchers.IO) {
+ val books = org.json.JSONArray()
+ for (book in args.books) {
+ ReadingWidgetStore.writeThumbnail(activity, book.hash, book.coverPath, book.percent)
+ books.put(
+ org.json.JSONObject()
+ .put("hash", book.hash)
+ .put("title", book.title)
+ .put("author", book.author)
+ .put("percent", book.percent)
+ )
+ }
+ val snapshot = org.json.JSONObject()
+ .put("books", books)
+ .put("sectionTitle", args.sectionTitle)
+ .put("emptyTitle", args.emptyTitle)
+ args.tts?.let { tts ->
+ snapshot.put(
+ "tts",
+ org.json.JSONObject()
+ .put("active", tts.active)
+ .put("playing", tts.playing)
+ )
+ }
+ ReadingWidgetStore.writeSnapshot(activity, snapshot.toString())
+ }
+ if (isActive) invoke.resolve()
+ }
+ }
+
// ── Sync passphrase keychain ──────────────────────────────────────
// Backed by EncryptedSharedPreferences, which derives an AES-GCM
// master key from AndroidKeystore and stores the value-of-keys map
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetProviders.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetProviders.kt
new file mode 100644
index 00000000..165d4822
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetProviders.kt
@@ -0,0 +1,117 @@
+package com.readest.native_bridge
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
+import android.content.Intent
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.widget.RemoteViews
+import androidx.media.session.MediaButtonReceiver
+import android.support.v4.media.session.PlaybackStateCompat
+import org.json.JSONObject
+import java.io.File
+
+private fun bookPendingIntent(context: Context, hash: String, requestCode: Int): PendingIntent {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("readest://book/$hash"))
+ .setPackage(context.packageName)
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ return PendingIntent.getActivity(context, requestCode, intent, flags)
+}
+
+private fun bookAt(snapshot: JSONObject, index: Int): JSONObject? {
+ val books = snapshot.optJSONArray("books") ?: return null
+ return if (index < books.length()) books.optJSONObject(index) else null
+}
+
+private fun setCover(context: Context, views: RemoteViews, viewId: Int, hash: String) {
+ val file = File(ReadingWidgetStore.coversDir(context), "$hash.png")
+ val bitmap = if (file.exists()) BitmapFactory.decodeFile(file.absolutePath) else null
+ if (bitmap != null) views.setImageViewBitmap(viewId, bitmap)
+ else views.setImageViewResource(viewId, android.R.color.transparent)
+}
+
+class ReadingWidgetProvider : AppWidgetProvider() {
+ private val coverIds = intArrayOf(R.id.cover0, R.id.cover1, R.id.cover2)
+
+ override fun onUpdate(context: Context, mgr: AppWidgetManager, ids: IntArray) {
+ for (id in ids) updateWidget(context, mgr, id)
+ }
+
+ override fun onAppWidgetOptionsChanged(
+ context: Context, mgr: AppWidgetManager, id: Int, newOptions: android.os.Bundle
+ ) {
+ updateWidget(context, mgr, id)
+ }
+
+ private fun updateWidget(context: Context, mgr: AppWidgetManager, id: Int) {
+ val snapshot = ReadingWidgetStore.readSnapshot(context)
+ val opts = mgr.getAppWidgetOptions(id)
+ val minW = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 110)
+ val minH = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 110)
+ // Android grid: a span of n cells reports about (70*n - 30) dp, so
+ // n = (dp + 30) / 70. One book per column, capped at 3 (1 col -> 1,
+ // 2 -> 2, 3 and 4 -> 3). No header is shown, for a minimal UI.
+ val cols = ((minW + 30) / 70).coerceAtLeast(1).coerceAtMost(3)
+ val rows = ((minH + 30) / 70).coerceAtLeast(1)
+
+ val views = RemoteViews(context.packageName, R.layout.widget_reading)
+
+ val count = snapshot.optJSONArray("books")?.length() ?: 0
+ if (count == 0) {
+ views.setViewVisibility(R.id.empty, android.view.View.VISIBLE)
+ views.setViewVisibility(R.id.row, android.view.View.GONE)
+ views.setTextViewText(R.id.empty, snapshot.optString("emptyTitle"))
+ } else {
+ views.setViewVisibility(R.id.empty, android.view.View.GONE)
+ views.setViewVisibility(R.id.row, android.view.View.VISIBLE)
+ for (i in coverIds.indices) {
+ val book = if (i < cols) bookAt(snapshot, i) else null
+ if (book == null) {
+ views.setViewVisibility(coverIds[i], android.view.View.GONE)
+ continue
+ }
+ val hash = book.optString("hash")
+ setCover(context, views, coverIds[i], hash)
+ views.setOnClickPendingIntent(coverIds[i], bookPendingIntent(context, hash, id * 10 + i))
+ views.setViewVisibility(coverIds[i], android.view.View.VISIBLE)
+ }
+ }
+ // Only show the TTS controls when the widget has 2+ rows (no room in a
+ // single-row size). Add a little top padding in that case to balance the
+ // bottom control bar.
+ val tts = snapshot.optJSONObject("tts")
+ if (tts != null && tts.optBoolean("active") && rows >= 2) {
+ views.setViewVisibility(R.id.tts_bar, android.view.View.VISIBLE)
+ views.setViewVisibility(R.id.top_spacer, android.view.View.VISIBLE)
+ val playing = tts.optBoolean("playing")
+ views.setImageViewResource(
+ R.id.btn_play_pause,
+ if (playing) R.drawable.ic_widget_pause else R.drawable.ic_widget_play
+ )
+ views.setOnClickPendingIntent(
+ R.id.btn_prev,
+ MediaButtonReceiver.buildMediaButtonPendingIntent(
+ context, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
+ )
+ )
+ views.setOnClickPendingIntent(
+ R.id.btn_play_pause,
+ MediaButtonReceiver.buildMediaButtonPendingIntent(
+ context, PlaybackStateCompat.ACTION_PLAY_PAUSE
+ )
+ )
+ views.setOnClickPendingIntent(
+ R.id.btn_next,
+ MediaButtonReceiver.buildMediaButtonPendingIntent(
+ context, PlaybackStateCompat.ACTION_SKIP_TO_NEXT
+ )
+ )
+ } else {
+ views.setViewVisibility(R.id.tts_bar, android.view.View.GONE)
+ views.setViewVisibility(R.id.top_spacer, android.view.View.GONE)
+ }
+ mgr.updateAppWidget(id, views)
+ }
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt
new file mode 100644
index 00000000..5291d7e0
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt
@@ -0,0 +1,147 @@
+package com.readest.native_bridge
+
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.BitmapShader
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Shader
+import android.graphics.Typeface
+import org.json.JSONObject
+import java.io.File
+import kotlin.math.max
+
+object ReadingWidgetStore {
+ const val PREFS = "reading_widget"
+ const val KEY_SNAPSHOT = "snapshot"
+ private const val THUMB_WIDTH = 240
+ private const val THUMB_HEIGHT = 360
+ private const val CORNER_RADIUS = 18f
+
+ fun coversDir(context: Context): File =
+ File(context.filesDir, "widget/covers").apply { mkdirs() }
+
+ fun writeThumbnail(context: Context, hash: String, sourcePath: String, percent: Int) {
+ val dst = File(coversDir(context), "$hash.png")
+ val src = File(sourcePath)
+ if (!src.exists()) { dst.delete(); return }
+ // Note: skip-if-unchanged removed because the composite depends on the live percent.
+
+ // Bounds pre-pass for memory safety before full decode.
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(sourcePath, bounds)
+ val longEdge = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
+ var sample = 1
+ while (longEdge / sample > THUMB_HEIGHT * 2) sample *= 2
+ val opts = BitmapFactory.Options().apply { inSampleSize = sample }
+ val bitmap = BitmapFactory.decodeFile(sourcePath, opts) ?: return
+
+ // Center-crop to 2:3 portrait aspect (width:height = 2:3).
+ val srcW = bitmap.width
+ val srcH = bitmap.height
+ val cropW: Int
+ val cropH: Int
+ if (srcW * 3 > srcH * 2) {
+ // Source is wider than 2:3 — crop the sides.
+ cropH = srcH
+ cropW = srcH * 2 / 3
+ } else {
+ // Source is taller than 2:3 — crop top/bottom.
+ cropW = srcW
+ cropH = srcW * 3 / 2
+ }
+ val cropX = (srcW - cropW) / 2
+ val cropY = (srcH - cropH) / 2
+ val cropped = Bitmap.createBitmap(bitmap, cropX, cropY, cropW, cropH)
+ bitmap.recycle()
+
+ // Scale the cropped bitmap to the target size.
+ val scaled = Bitmap.createScaledBitmap(cropped, THUMB_WIDTH, THUMB_HEIGHT, true)
+ if (scaled !== cropped) cropped.recycle()
+
+ // Apply rounded corners by drawing through a BitmapShader onto a transparent canvas.
+ val rounded = Bitmap.createBitmap(THUMB_WIDTH, THUMB_HEIGHT, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(rounded)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ paint.shader = BitmapShader(scaled, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
+ canvas.drawRoundRect(
+ RectF(0f, 0f, THUMB_WIDTH.toFloat(), THUMB_HEIGHT.toFloat()),
+ CORNER_RADIUS, CORNER_RADIUS,
+ paint
+ )
+ scaled.recycle()
+
+ // Bake progress bar and % badge into the cover bitmap.
+ val w = rounded.width.toFloat()
+ val h = rounded.height.toFloat()
+ val pad = w * 0.05f
+ val pct = percent.coerceIn(0, 100)
+
+ // progress bar along the bottom
+ val barH = w * 0.035f
+ val barTop = h - pad - barH
+ val barLeft = pad
+ val barRight = w - pad
+ val radius = barH / 2f
+ val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0x66000000 }
+ canvas.drawRoundRect(RectF(barLeft, barTop, barRight, barTop + barH), radius, radius, trackPaint)
+ val fillRight = barLeft + (barRight - barLeft) * (pct / 100f)
+ if (fillRight > barLeft + radius) {
+ val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFF6A4BFF.toInt() }
+ canvas.drawRoundRect(RectF(barLeft, barTop, fillRight, barTop + barH), radius, radius, fillPaint)
+ }
+
+ // % badge pill, top-right
+ val text = "$pct%"
+ val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = 0xFFFFFFFF.toInt()
+ textSize = w * 0.085f
+ typeface = Typeface.DEFAULT_BOLD
+ }
+ val fm = textPaint.fontMetrics
+ val tw = textPaint.measureText(text)
+ val padX = w * 0.03f
+ val padY = w * 0.02f
+ val pillW = tw + padX * 2f
+ val pillH = (fm.descent - fm.ascent) + padY * 2f
+ val pillRight = w - pad
+ val pillTop = pad
+ val pillLeft = pillRight - pillW
+ val pillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xB3000000.toInt() }
+ val pillR = pillH / 2f
+ canvas.drawRoundRect(RectF(pillLeft, pillTop, pillRight, pillTop + pillH), pillR, pillR, pillPaint)
+ canvas.drawText(text, pillLeft + padX, pillTop + padY - fm.ascent, textPaint)
+
+ // Write as PNG so the alpha channel for rounded corners is preserved.
+ dst.outputStream().use { rounded.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ rounded.recycle()
+ }
+
+ fun writeSnapshot(context: Context, json: String) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit().putString(KEY_SNAPSHOT, json).apply()
+ notifyWidgets(context)
+ }
+
+ fun readSnapshot(context: Context): JSONObject {
+ val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getString(KEY_SNAPSHOT, null) ?: return JSONObject()
+ return runCatching { JSONObject(raw) }.getOrDefault(JSONObject())
+ }
+
+ private fun notifyWidgets(context: Context) {
+ val mgr = AppWidgetManager.getInstance(context)
+ val cls = ReadingWidgetProvider::class.java
+ val ids = mgr.getAppWidgetIds(ComponentName(context, cls))
+ if (ids.isNotEmpty()) {
+ val intent = android.content.Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
+ intent.component = ComponentName(context, cls)
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
+ context.sendBroadcast(intent)
+ }
+ }
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_pause.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_pause.xml
new file mode 100644
index 00000000..5109c592
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_pause.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_play.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_play.xml
new file mode 100644
index 00000000..22d19581
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_play.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_next.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_next.xml
new file mode 100644
index 00000000..21207ea7
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_next.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_previous.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_previous.xml
new file mode 100644
index 00000000..e1e56b4b
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_previous.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/widget_card_bg.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/widget_card_bg.xml
new file mode 100644
index 00000000..5f65fe29
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/widget_card_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/layout/widget_reading.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/layout/widget_reading.xml
new file mode 100644
index 00000000..03c28362
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/layout/widget_reading.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/xml/widget_reading_info.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/xml/widget_reading_info.xml
new file mode 100644
index 00000000..907211f2
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/xml/widget_reading_info.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs
index f5d16e6b..87f5434b 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs
@@ -38,6 +38,7 @@ const COMMANDS: &[&str] = &[
"get_secure_item",
"clear_secure_item",
"refresh_eink_screen",
+ "update_reading_widget",
];
fn main() {
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
index 36e4ef6f..06b8d0be 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
@@ -1502,6 +1502,26 @@ class NativeBridgePlugin: Plugin {
@objc public func refresh_eink_screen(_ invoke: Invoke) {
invoke.resolve(["success": false])
}
+
+ @objc public func update_reading_widget(_ invoke: Invoke) {
+ guard let args = try? invoke.parseArgs(UpdateReadingWidgetRequestArgs.self) else {
+ return invoke.reject("Failed to parse arguments")
+ }
+ DispatchQueue.global(qos: .utility).async {
+ for book in args.books {
+ ReadingWidgetWriter.writeThumbnail(hash: book.hash, sourcePath: book.coverPath)
+ }
+ let snapshot = ReadingWidgetWriter.Snapshot(
+ books: args.books.map {
+ .init(hash: $0.hash, title: $0.title, author: $0.author, percent: $0.percent)
+ },
+ sectionTitle: args.sectionTitle,
+ emptyTitle: args.emptyTitle
+ )
+ ReadingWidgetWriter.write(snapshot: snapshot)
+ invoke.resolve()
+ }
+ }
}
/// Persistent store for security-scoped folder bookmarks.
@@ -1731,6 +1751,19 @@ class ShowLookupPopoverArgs: Decodable {
let word: String
}
+struct UpdateReadingWidgetBookArgs: Decodable {
+ let hash: String
+ let title: String
+ let author: String
+ let percent: Int
+ let coverPath: String
+}
+struct UpdateReadingWidgetRequestArgs: Decodable {
+ let books: [UpdateReadingWidgetBookArgs]
+ let sectionTitle: String
+ let emptyTitle: String
+}
+
@_cdecl("init_plugin_native_bridge")
func initPlugin() -> Plugin {
return NativeBridgePlugin()
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift
new file mode 100644
index 00000000..d4dd3194
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift
@@ -0,0 +1,61 @@
+import Foundation
+import UIKit
+import WidgetKit
+
+enum ReadingWidgetWriter {
+ static let suiteName = "group.com.bilingify.readest"
+ static let snapshotKey = "readingWidgetSnapshot"
+ static let thumbMaxPixels: CGFloat = 240
+
+ struct SnapshotBook: Codable {
+ let hash: String
+ let title: String
+ let author: String
+ let percent: Int
+ }
+ struct Snapshot: Codable {
+ let books: [SnapshotBook]
+ let sectionTitle: String
+ let emptyTitle: String
+ }
+
+ static var containerURL: URL? {
+ FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: suiteName)
+ }
+
+ static func coversDir() -> URL? {
+ guard let base = containerURL?.appendingPathComponent("widget/covers", isDirectory: true)
+ else { return nil }
+ try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ return base
+ }
+
+ /// Downsample a cover file to <= thumbMaxPixels on the long edge and write
+ /// it as JPEG into the App Group container. Returns silently on failure.
+ static func writeThumbnail(hash: String, sourcePath: String) {
+ guard let dir = coversDir() else { return }
+ let dst = dir.appendingPathComponent("\(hash).jpg")
+ guard let image = UIImage(contentsOfFile: sourcePath) else {
+ try? FileManager.default.removeItem(at: dst)
+ return
+ }
+ let longEdge = max(image.size.width, image.size.height)
+ let scale = longEdge > thumbMaxPixels ? thumbMaxPixels / longEdge : 1
+ let target = CGSize(width: image.size.width * scale, height: image.size.height * scale)
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = 1.0
+ let renderer = UIGraphicsImageRenderer(size: target, format: format)
+ let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: target)) }
+ if let data = resized.jpegData(compressionQuality: 0.8) {
+ try? data.write(to: dst)
+ }
+ }
+
+ static func write(snapshot: Snapshot) {
+ guard let data = try? JSONEncoder().encode(snapshot) else { return }
+ UserDefaults(suiteName: suiteName)?.set(data, forKey: snapshotKey)
+ DispatchQueue.main.async {
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+ }
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/update_reading_widget.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/update_reading_widget.toml
new file mode 100644
index 00000000..87588863
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/update_reading_widget.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-update-reading-widget"
+description = "Enables the update_reading_widget command without any pre-configured scope."
+commands.allow = ["update_reading_widget"]
+
+[[permission]]
+identifier = "deny-update-reading-widget"
+description = "Denies the update_reading_widget command without any pre-configured scope."
+commands.deny = ["update_reading_widget"]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md
index 1c1c3eec..b042e7d1 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md
@@ -46,6 +46,7 @@ Default permissions for the plugin
- `allow-get-secure-item`
- `allow-clear-secure-item`
- `allow-refresh-eink-screen`
+- `allow-update-reading-widget`
## Permission Table
@@ -1203,6 +1204,32 @@ Denies the show_lookup_popover command without any pre-configured scope.
|
+`native-bridge:allow-update-reading-widget`
+
+ |
+
+
+Enables the update_reading_widget command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`native-bridge:deny-update-reading-widget`
+
+ |
+
+
+Denies the update_reading_widget command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`native-bridge:allow-use-background-audio`
|
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
index 1692f67d..6d1d6b4f 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
@@ -43,4 +43,5 @@ permissions = [
"allow-get-secure-item",
"allow-clear-secure-item",
"allow-refresh-eink-screen",
+ "allow-update-reading-widget",
]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
index 4a4460d1..7a7b0678 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
@@ -822,6 +822,18 @@
"const": "deny-show-lookup-popover",
"markdownDescription": "Denies the show_lookup_popover command without any pre-configured scope."
},
+ {
+ "description": "Enables the update_reading_widget command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-update-reading-widget",
+ "markdownDescription": "Enables the update_reading_widget command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the update_reading_widget command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-update-reading-widget",
+ "markdownDescription": "Denies the update_reading_widget command without any pre-configured scope."
+ },
{
"description": "Enables the use_background_audio command without any pre-configured scope.",
"type": "string",
@@ -835,10 +847,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
- "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`",
+ "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`",
"type": "string",
"const": "default",
- "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`"
+ "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`"
}
]
}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
index ca5f5023..dcda99d4 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
@@ -280,3 +280,11 @@ pub(crate) async fn refresh_eink_screen(
) -> Result {
app.native_bridge().refresh_eink_screen()
}
+
+#[command]
+pub(crate) async fn update_reading_widget(
+ app: AppHandle,
+ payload: UpdateReadingWidgetRequest,
+) -> Result<()> {
+ app.native_bridge().update_reading_widget(payload)
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
index 42e3f398..ff7449ee 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
@@ -351,6 +351,14 @@ impl NativeBridge {
pub fn refresh_eink_screen(&self) -> crate::Result {
Err(crate::Error::UnsupportedPlatformError)
}
+
+ pub fn update_reading_widget(
+ &self,
+ _payload: UpdateReadingWidgetRequest,
+ ) -> crate::Result<()> {
+ // Home-screen widgets are mobile-only; desktop is a no-op.
+ Ok(())
+ }
}
const KEYRING_SERVICE: &str = "Readest Safe Storage";
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
index 37e568e1..90c250d1 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
@@ -89,6 +89,7 @@ pub fn init() -> TauriPlugin {
commands::get_secure_item,
commands::clear_secure_item,
commands::refresh_eink_screen,
+ commands::update_reading_widget,
])
.setup(|app, api| {
#[cfg(mobile)]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
index 25c51e97..4ba5d6a9 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
@@ -353,3 +353,11 @@ impl NativeBridge {
.map_err(Into::into)
}
}
+
+impl NativeBridge {
+ pub fn update_reading_widget(&self, payload: UpdateReadingWidgetRequest) -> crate::Result<()> {
+ self.0
+ .run_mobile_plugin("update_reading_widget", payload)
+ .map_err(Into::into)
+ }
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
index 34362b16..2bab7153 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
@@ -405,3 +405,30 @@ pub struct RefreshEinkScreenResponse {
pub success: bool,
pub error: Option,
}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReadingWidgetBook {
+ pub hash: String,
+ pub title: String,
+ pub author: String,
+ pub percent: u8,
+ pub cover_path: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReadingWidgetTts {
+ pub active: bool,
+ pub playing: bool,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateReadingWidgetRequest {
+ pub books: Vec,
+ pub section_title: String,
+ pub empty_title: String,
+ #[serde(default)]
+ pub tts: Option,
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/reading_widget.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/reading_widget.rs
new file mode 100644
index 00000000..1a14a5f0
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/reading_widget.rs
@@ -0,0 +1,41 @@
+use tauri_plugin_native_bridge::UpdateReadingWidgetRequest;
+
+#[test]
+fn deserializes_camel_case_payload() {
+ let json = r#"{
+ "books": [{"hash":"h1","title":"T","author":"A","percent":72,"coverPath":"/x/h1/cover.png"}],
+ "sectionTitle": "Continue reading",
+ "emptyTitle": "Your books will appear here"
+ }"#;
+ let req: UpdateReadingWidgetRequest = serde_json::from_str(json).unwrap();
+ assert_eq!(req.books.len(), 1);
+ assert_eq!(req.books[0].percent, 72);
+ assert_eq!(req.books[0].cover_path, "/x/h1/cover.png");
+ assert_eq!(req.section_title, "Continue reading");
+ assert!(req.tts.is_none());
+}
+
+#[test]
+fn deserializes_tts_field_when_present() {
+ let json = r#"{
+ "books": [],
+ "sectionTitle": "S",
+ "emptyTitle": "E",
+ "tts": {"active": true, "playing": false}
+ }"#;
+ let req: UpdateReadingWidgetRequest = serde_json::from_str(json).unwrap();
+ let tts = req.tts.expect("tts should be Some");
+ assert_eq!(tts.active, true);
+ assert_eq!(tts.playing, false);
+}
+
+#[test]
+fn tts_is_none_when_absent() {
+ let json = r#"{
+ "books": [],
+ "sectionTitle": "S",
+ "emptyTitle": "E"
+ }"#;
+ let req: UpdateReadingWidgetRequest = serde_json::from_str(json).unwrap();
+ assert!(req.tts.is_none());
+}
diff --git a/apps/readest-app/src/__tests__/services/readingWidget.test.ts b/apps/readest-app/src/__tests__/services/readingWidget.test.ts
new file mode 100644
index 00000000..b6ae73ed
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/readingWidget.test.ts
@@ -0,0 +1,133 @@
+import { describe, it, expect, vi } from 'vitest';
+import {
+ selectReadingWidgetBooks,
+ computeReadingPercent,
+ buildReadingWidgetPayload,
+} from '@/services/widget/readingWidget';
+import type { Book } from '@/types/book';
+
+vi.mock('@/utils/bridge', () => ({ updateReadingWidget: vi.fn().mockResolvedValue(undefined) }));
+vi.mock('@/store/libraryStore', () => ({
+ useLibraryStore: {
+ getState: () => ({
+ library: [
+ {
+ hash: 'a',
+ title: 'Ta',
+ author: 'Aa',
+ format: 'EPUB',
+ updatedAt: 2,
+ progress: [1, 2],
+ readingStatus: 'reading',
+ },
+ {
+ hash: 'b',
+ title: 'Tb',
+ author: 'Ab',
+ format: 'EPUB',
+ updatedAt: 5,
+ readingStatus: 'finished',
+ },
+ ],
+ }),
+ },
+}));
+
+const mk = (over: Partial): Book =>
+ ({ hash: 'h', title: 'T', author: 'A', format: 'EPUB', updatedAt: 0, ...over }) as Book;
+
+describe('computeReadingPercent', () => {
+ it('rounds current/total to a 0-100 integer', () => {
+ expect(computeReadingPercent(mk({ progress: [72, 100] }))).toBe(72);
+ expect(computeReadingPercent(mk({ progress: [1, 3] }))).toBe(33);
+ });
+ it('is 0 when progress missing or total is 0', () => {
+ expect(computeReadingPercent(mk({}))).toBe(0);
+ expect(computeReadingPercent(mk({ progress: [1, 0] }))).toBe(0);
+ });
+ it('clamps to 100', () => {
+ expect(computeReadingPercent(mk({ progress: [120, 100] }))).toBe(100);
+ });
+});
+
+describe('selectReadingWidgetBooks', () => {
+ it('keeps non-deleted, non-finished, non-abandoned and sorts by updatedAt desc', () => {
+ const books = [
+ mk({ hash: 'a', updatedAt: 10, readingStatus: 'reading' }),
+ mk({ hash: 'b', updatedAt: 30, readingStatus: 'unread' }),
+ mk({ hash: 'c', updatedAt: 20, readingStatus: 'finished' }),
+ mk({ hash: 'd', updatedAt: 40, readingStatus: 'abandoned' }),
+ mk({ hash: 'e', updatedAt: 50, deletedAt: 123 }),
+ ];
+ expect(selectReadingWidgetBooks(books).map((b) => b.hash)).toEqual(['b', 'a']);
+ });
+ it('caps at the limit', () => {
+ const books = [1, 2, 3, 4].map((n) => mk({ hash: String(n), updatedAt: n }));
+ expect(selectReadingWidgetBooks(books, 3)).toHaveLength(3);
+ });
+});
+
+import { refreshReadingWidget } from '@/services/widget/readingWidget';
+
+const appServiceForBuild = {
+ isMobileApp: true,
+ resolveFilePath: vi.fn().mockResolvedValue('/data/Books'),
+} as unknown as import('@/types/system').AppService;
+
+const labelsForBuild = { sectionTitle: 'Continue reading', emptyTitle: 'Empty' };
+const booksForBuild: Book[] = [mk({ hash: 'x', updatedAt: 1, progress: [1, 4] })];
+
+describe('buildReadingWidgetPayload', () => {
+ it('includes tts field when provided', async () => {
+ const payload = await buildReadingWidgetPayload(
+ booksForBuild,
+ appServiceForBuild,
+ labelsForBuild,
+ {
+ active: true,
+ playing: false,
+ },
+ );
+ expect(payload.tts).toEqual({ active: true, playing: false });
+ });
+
+ it('omits tts key when not provided', async () => {
+ const payload = await buildReadingWidgetPayload(
+ booksForBuild,
+ appServiceForBuild,
+ labelsForBuild,
+ );
+ expect('tts' in payload).toBe(false);
+ });
+});
+
+describe('refreshReadingWidget', () => {
+ const appService = {
+ isMobileApp: true,
+ resolveFilePath: vi.fn().mockResolvedValue('/data/Books'),
+ } as unknown as import('@/types/system').AppService;
+
+ it('skips when not a mobile app', async () => {
+ const { updateReadingWidget } = await import('@/utils/bridge');
+ await refreshReadingWidget({ ...appService, isMobileApp: false } as never, {
+ sectionTitle: 'Continue reading',
+ emptyTitle: 'Empty',
+ });
+ expect(updateReadingWidget).not.toHaveBeenCalled();
+ });
+
+ it('selects in-progress books and resolves cover paths', async () => {
+ const { updateReadingWidget } = await import('@/utils/bridge');
+ await refreshReadingWidget(appService, {
+ sectionTitle: 'Continue reading',
+ emptyTitle: 'Empty',
+ });
+ expect(updateReadingWidget).toHaveBeenCalledWith({
+ books: [
+ { hash: 'a', title: 'Ta', author: 'Aa', percent: 50, coverPath: '/data/Books/a/cover.png' },
+ ],
+ sectionTitle: 'Continue reading',
+ emptyTitle: 'Empty',
+ });
+ });
+});
diff --git a/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts b/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts
new file mode 100644
index 00000000..bce40a21
--- /dev/null
+++ b/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect } from 'vitest';
+import { parseBookDeepLink } from '@/utils/deeplink';
+
+describe('parseBookDeepLink', () => {
+ it('parses the custom-scheme book-open form', () => {
+ expect(parseBookDeepLink('readest://book/abc123')).toEqual({ bookHash: 'abc123' });
+ });
+ it('parses the web form', () => {
+ expect(parseBookDeepLink('https://web.readest.com/o/book/abc123')).toEqual({
+ bookHash: 'abc123',
+ });
+ });
+ it('does NOT match the annotation form', () => {
+ expect(parseBookDeepLink('readest://book/abc123/annotation/n1')).toBeNull();
+ });
+ it('ignores unrelated urls', () => {
+ expect(parseBookDeepLink('readest://share/tok')).toBeNull();
+ expect(parseBookDeepLink('not a url')).toBeNull();
+ });
+});
diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx
index 46bbd9fa..2f13ec45 100644
--- a/apps/readest-app/src/app/library/page.tsx
+++ b/apps/readest-app/src/app/library/page.tsx
@@ -47,6 +47,8 @@ import { getLibraryViewSettings } from '@/helpers/settings';
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
+import { useOpenBookLink } from '@/hooks/useOpenBookLink';
+import { useReadingWidget } from '@/hooks/useReadingWidget';
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
import { useClipUrlIngress } from '@/hooks/useClipUrlIngress';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
@@ -266,6 +268,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useAppUrlIngress();
useOpenWithBooks();
useOpenAnnotationLink();
+ useOpenBookLink();
+ useReadingWidget();
useOpenShareLink();
useClipUrlIngress();
useTransferQueue(libraryLoaded);
diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
index e6cf550b..f7f1128f 100644
--- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
+++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
@@ -203,6 +203,20 @@ const FoliateViewer: React.FC<{
// Always stash the latest detail; if another rAF is already pending
// it'll pick this up and the intermediate states are skipped.
pendingRelocateRef.current = event as CustomEvent;
+ // requestAnimationFrame is paused while the WebView is backgrounded, so the
+ // rAF-coalesced commit below would never run during background TTS - which
+ // freezes book.progress (and readerProgressStore, and the home-screen
+ // widget that reads them). Commit synchronously when hidden so progress
+ // stays current. The page-follow relocate still fires; only the commit was
+ // being deferred.
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
+ if (relocateRafRef.current != null) {
+ cancelAnimationFrame(relocateRafRef.current);
+ relocateRafRef.current = null;
+ }
+ commitRelocate();
+ return;
+ }
if (relocateRafRef.current != null) return;
relocateRafRef.current = requestAnimationFrame(commitRelocate);
};
diff --git a/apps/readest-app/src/app/reader/hooks/useBooksManager.ts b/apps/readest-app/src/app/reader/hooks/useBooksManager.ts
index eca8646d..7211831b 100644
--- a/apps/readest-app/src/app/reader/hooks/useBooksManager.ts
+++ b/apps/readest-app/src/app/reader/hooks/useBooksManager.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
@@ -6,6 +6,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { uniqueId } from '@/utils/misc';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { navigateToReader } from '@/utils/nav';
+import { eventDispatcher } from '@/utils/event';
const useBooksManager = () => {
const router = useRouter();
@@ -41,6 +42,37 @@ const useBooksManager = () => {
setShouldUpdateSearchParams(true);
};
+ // Open a book in-place when the widget taps a book while a reader is already
+ // mounted. REPLACE the open book(s) with the tapped one (single ids=)
+ // rather than appending: appending produced ids=a+b which, with the OS
+ // re-delivering the launch deep link, looped. The store update renders the
+ // new book immediately; closing the previous key follows the same path as
+ // dismissBook.
+ const openBookInReader = (bookHash: string) => {
+ const existing = bookKeys.find((key) => key.startsWith(bookHash));
+ if (existing) {
+ setSideBarBookKey(existing);
+ return;
+ }
+ const newKey = `${bookHash}-${uniqueId()}`;
+ initViewState(envConfig, bookHash, newKey, true);
+ setBookKeys([newKey]);
+ setSideBarBookKey(newKey);
+ setShouldUpdateSearchParams(true);
+ };
+
+ // Stable ref so the listener calls the latest closure without re-subscribing.
+ const openBookRef = useRef(openBookInReader);
+ openBookRef.current = openBookInReader;
+ useEffect(() => {
+ const handle = (event: CustomEvent) => {
+ const { bookHash } = event.detail as { bookHash: string };
+ openBookRef.current(bookHash);
+ };
+ eventDispatcher.on('open-book-in-reader', handle);
+ return () => eventDispatcher.off('open-book-in-reader', handle);
+ }, []);
+
// Close a book and sync with bookKeys and URL
const dismissBook = (bookKey: string) => {
const updatedKeys = bookKeys.filter((key) => key !== bookKey);
diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx
index c385b84b..a66c82da 100644
--- a/apps/readest-app/src/app/reader/page.tsx
+++ b/apps/readest-app/src/app/reader/page.tsx
@@ -6,6 +6,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
+import { useOpenBookLink } from '@/hooks/useOpenBookLink';
+import { useReadingWidget } from '@/hooks/useReadingWidget';
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
import { useClipUrlIngress } from '@/hooks/useClipUrlIngress';
import { useSettingsStore } from '@/store/settingsStore';
@@ -22,6 +24,8 @@ export default function Page() {
useAppUrlIngress();
useOpenWithBooks();
useOpenAnnotationLink();
+ useOpenBookLink();
+ useReadingWidget();
useOpenShareLink();
useClipUrlIngress();
diff --git a/apps/readest-app/src/hooks/useOpenBookLink.ts b/apps/readest-app/src/hooks/useOpenBookLink.ts
new file mode 100644
index 00000000..ffe60a69
--- /dev/null
+++ b/apps/readest-app/src/hooks/useOpenBookLink.ts
@@ -0,0 +1,106 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useRouter } from 'next/navigation';
+import { getCurrent } from '@tauri-apps/plugin-deep-link';
+import { useEnv } from '@/context/EnvContext';
+import { useLibraryStore } from '@/store/libraryStore';
+import { isTauriAppPlatform } from '@/services/environment';
+import { navigateToReader } from '@/utils/nav';
+import { eventDispatcher } from '@/utils/event';
+import { parseBookDeepLink } from '@/utils/deeplink';
+import { useTranslation } from './useTranslation';
+
+// Module-scoped: survives hook remounts (library <-> reader). getCurrent()
+// keeps returning the launch URL for the session, so without this guard every
+// remount would re-read the cold-start URL.
+let coldStartConsumed = false;
+
+/**
+ * Receive `readest://book/{hash}` deep links (home-screen widget taps) and open
+ * the book in the reader. Subscribes to the shared 'app-incoming-url' event for
+ * live taps and reads getCurrent() once for cold start, deferring until the
+ * library has hydrated.
+ */
+export function useOpenBookLink() {
+ const _ = useTranslation();
+ const router = useRouter();
+ const { appService } = useEnv();
+ const getBookByHash = useLibraryStore((s) => s.getBookByHash);
+ const libraryLoaded = useLibraryStore((s) => s.libraryLoaded);
+ const pending = useRef(null);
+
+ const resolveAndNavigate = useCallback(
+ (bookHash: string) => {
+ const book = getBookByHash(bookHash);
+ if (!book) {
+ eventDispatcher.dispatch('toast', {
+ type: 'warning',
+ message: _('Book not in your library'),
+ timeout: 2500,
+ });
+ return;
+ }
+ // If a reader is already mounted, switch in place via useBooksManager: it
+ // focuses the book if it is already open (checked against the live
+ // bookKeys) or replaces the open book(s) with it otherwise. A plain
+ // navigateToReader does not re-init an already-mounted reader.
+ if (window.location.pathname.startsWith('/reader')) {
+ eventDispatcher.dispatch('open-book-in-reader', { bookHash });
+ return;
+ }
+
+ // No reader mounted (library / cold start) - navigate fresh.
+ navigateToReader(router, [bookHash]);
+ },
+ [_, getBookByHash, router],
+ );
+
+ useEffect(() => {
+ if (!isTauriAppPlatform() || !appService) return;
+
+ const handle = (url: string, coldStart = false) => {
+ const parsed = parseBookDeepLink(url);
+ if (!parsed) return;
+ // Dedupe ONLY the cold-start path. The OS persists the launch deep link
+ // and re-delivers it via getCurrent() on every reader reload, which would
+ // re-open the book in a loop. Live taps (app-incoming-url) are genuine
+ // user actions and must always be processed. sessionStorage survives
+ // reloads (module state does not).
+ if (coldStart) {
+ try {
+ if (sessionStorage.getItem('consumedColdStartBookUrl') === url) return;
+ sessionStorage.setItem('consumedColdStartBookUrl', url);
+ } catch {
+ // sessionStorage unavailable - proceed.
+ }
+ }
+ if (!useLibraryStore.getState().libraryLoaded) {
+ pending.current = parsed.bookHash;
+ return;
+ }
+ resolveAndNavigate(parsed.bookHash);
+ };
+
+ if (!coldStartConsumed) {
+ coldStartConsumed = true;
+ getCurrent()
+ .then((urls) => urls?.forEach((u) => handle(u, true)))
+ .catch(() => {});
+ }
+
+ const onIncoming = (event: CustomEvent) => {
+ const { urls } = event.detail as { urls: string[] };
+ urls.forEach((u) => handle(u));
+ };
+ eventDispatcher.on('app-incoming-url', onIncoming);
+ return () => {
+ eventDispatcher.off('app-incoming-url', onIncoming);
+ };
+ }, [appService, resolveAndNavigate]);
+
+ useEffect(() => {
+ if (!libraryLoaded || !pending.current) return;
+ const bookHash = pending.current;
+ pending.current = null;
+ resolveAndNavigate(bookHash);
+ }, [libraryLoaded, resolveAndNavigate]);
+}
diff --git a/apps/readest-app/src/hooks/useReadingWidget.ts b/apps/readest-app/src/hooks/useReadingWidget.ts
new file mode 100644
index 00000000..6ad96476
--- /dev/null
+++ b/apps/readest-app/src/hooks/useReadingWidget.ts
@@ -0,0 +1,98 @@
+import { useEffect, useRef } from 'react';
+import { useEnv } from '@/context/EnvContext';
+import { useLibraryStore } from '@/store/libraryStore';
+import { refreshReadingWidget } from '@/services/widget/readingWidget';
+import { debounce } from '@/utils/debounce';
+import { eventDispatcher } from '@/utils/event';
+import { useTranslation } from './useTranslation';
+
+/**
+ * Publish the home-screen reading-widget snapshot. The widget is only visible
+ * while the app is backgrounded, so we publish (1) once the library is loaded,
+ * (2) whenever the app goes to the background, (3) immediately on a TTS
+ * playback-state change (so controls appear/disappear), and (4) throttled on
+ * TTS position advances so the progress percent stays live while speaking.
+ * Mounted on both the library and reader pages.
+ */
+export function useReadingWidget() {
+ const _ = useTranslation();
+ const { appService } = useEnv();
+ const libraryLoaded = useLibraryStore((s) => s.libraryLoaded);
+ const ttsRef = useRef<{ active: boolean; playing: boolean }>({
+ active: false,
+ playing: false,
+ });
+
+ useEffect(() => {
+ if (!appService?.isMobileApp) return;
+ const labels = {
+ // The widget intentionally shows no section header (minimal UI), so the
+ // section title is left empty.
+ sectionTitle: '',
+ emptyTitle: _('Your books will appear here'),
+ };
+
+ const publishNow = () => {
+ const tts = ttsRef.current;
+ void refreshReadingWidget(
+ appService,
+ labels,
+ tts.active ? { active: true, playing: tts.playing } : undefined,
+ );
+ };
+
+ const publish = debounce(publishNow, 500);
+ // Leading interval throttle for TTS position. `tts-position` fires
+ // continuously while speaking, so a trailing debounce would perpetually
+ // reset its timer and never fire; and a pending setTimeout is unreliable
+ // once the app is backgrounded (the OS throttles background timers).
+ // Publish immediately, then at most once per interval, synchronously inside
+ // the event handler.
+ const TTS_POSITION_PUBLISH_INTERVAL = 5000;
+ let lastPositionPublishAt = 0;
+
+ if (libraryLoaded) publish();
+
+ const onVisibility = () => {
+ if (document.visibilityState === 'hidden') {
+ // Flush now: the WebView may be suspended before a debounced timer
+ // fires, and backgrounding is exactly when the widget needs the latest
+ // reading progress.
+ publish();
+ publish.flush();
+ }
+ };
+
+ const onPlaybackState = (event: CustomEvent) => {
+ const detail = event.detail as { bookKey: string; state: 'playing' | 'paused' | 'stopped' };
+ ttsRef.current = {
+ active: detail.state !== 'stopped',
+ playing: detail.state === 'playing',
+ };
+ // Publish immediately so controls appear/disappear and the play/pause
+ // icon flips without waiting for the next debounce cycle.
+ publishNow();
+ };
+
+ const onPosition = () => {
+ // Re-read the app-level book progress (the same value as the reader's
+ // progress bar) and re-publish. Leading throttle: publish immediately,
+ // then at most once per interval, synchronously so it still fires while
+ // the app is backgrounded.
+ const now = Date.now();
+ if (now - lastPositionPublishAt < TTS_POSITION_PUBLISH_INTERVAL) return;
+ lastPositionPublishAt = now;
+ publishNow();
+ };
+
+ document.addEventListener('visibilitychange', onVisibility);
+ eventDispatcher.on('tts-playback-state', onPlaybackState);
+ eventDispatcher.on('tts-position', onPosition);
+ return () => {
+ document.removeEventListener('visibilitychange', onVisibility);
+ eventDispatcher.off('tts-playback-state', onPlaybackState);
+ eventDispatcher.off('tts-position', onPosition);
+ publish.cancel();
+ };
+ }, [appService, libraryLoaded, _]);
+}
diff --git a/apps/readest-app/src/services/widget/readingWidget.ts b/apps/readest-app/src/services/widget/readingWidget.ts
new file mode 100644
index 00000000..343c8f33
--- /dev/null
+++ b/apps/readest-app/src/services/widget/readingWidget.ts
@@ -0,0 +1,84 @@
+import type { Book } from '@/types/book';
+import type { AppService } from '@/types/system';
+import { useLibraryStore } from '@/store/libraryStore';
+import { getCoverFilename } from '@/utils/book';
+import { updateReadingWidget } from '@/utils/bridge';
+import type { ReadingWidgetTts } from '@/utils/bridge';
+
+export interface ReadingWidgetBook {
+ hash: string;
+ title: string;
+ author: string;
+ percent: number;
+ coverPath: string;
+}
+
+export interface ReadingWidgetPayload {
+ books: ReadingWidgetBook[];
+ sectionTitle: string;
+ emptyTitle: string;
+ tts?: ReadingWidgetTts;
+}
+
+const EXCLUDED_STATUSES = new Set(['finished', 'abandoned']);
+
+export const computeReadingPercent = (book: Book): number => {
+ const progress = book.progress;
+ if (!progress) return 0;
+ const [current, total] = progress;
+ if (!total || total <= 0) return 0;
+ return Math.min(100, Math.max(0, Math.round((current / total) * 100)));
+};
+
+export const selectReadingWidgetBooks = (library: Book[], limit = 3): Book[] =>
+ library
+ .filter((b) => !b.deletedAt && !EXCLUDED_STATUSES.has(b.readingStatus ?? 'unread'))
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
+ .slice(0, limit);
+
+export interface ReadingWidgetLabels {
+ sectionTitle: string;
+ emptyTitle: string;
+}
+
+export const buildReadingWidgetPayload = async (
+ books: Book[],
+ appService: AppService,
+ labels: ReadingWidgetLabels,
+ tts?: ReadingWidgetTts,
+): Promise => {
+ // resolveFilePath('', 'Books') returns the absolute Books dir (no trailing
+ // slash) by delegating to fs.getPrefix internally. Both platforms use `/`,
+ // so plain string concatenation is correct and keeps the builder
+ // unit-testable without the Tauri path plugin.
+ const booksDir = (await appService.resolveFilePath('', 'Books')).replace(/\/+$/, '');
+ const widgetBooks: ReadingWidgetBook[] = books.map((book) => ({
+ hash: book.hash,
+ title: book.title ?? '',
+ author: book.author ?? '',
+ percent: computeReadingPercent(book),
+ coverPath: `${booksDir}/${getCoverFilename(book)}`,
+ }));
+ return {
+ books: widgetBooks,
+ sectionTitle: labels.sectionTitle,
+ emptyTitle: labels.emptyTitle,
+ ...(tts ? { tts } : {}),
+ };
+};
+
+export const refreshReadingWidget = async (
+ appService: AppService,
+ labels: ReadingWidgetLabels,
+ tts?: ReadingWidgetTts,
+): Promise => {
+ if (!appService.isMobileApp) return;
+ const library = useLibraryStore.getState().library;
+ const selected = selectReadingWidgetBooks(library);
+ const payload = await buildReadingWidgetPayload(selected, appService, labels, tts);
+ try {
+ await updateReadingWidget(payload);
+ } catch (err) {
+ console.warn('Failed to update reading widget', err);
+ }
+};
diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts
index 90c3840b..c77e72fc 100644
--- a/apps/readest-app/src/utils/bridge.ts
+++ b/apps/readest-app/src/utils/bridge.ts
@@ -341,6 +341,32 @@ export async function clearSecureItem(request: GetSecureItemRequest): Promise('plugin:native-bridge|clear_secure_item', { payload: request });
}
+// ── Reading widget ────────────────────────────────────────────────────────
+
+export interface ReadingWidgetBookPayload {
+ hash: string;
+ title: string;
+ author: string;
+ percent: number;
+ coverPath: string;
+}
+
+export interface ReadingWidgetTts {
+ active: boolean;
+ playing: boolean;
+}
+
+export interface UpdateReadingWidgetRequest {
+ books: ReadingWidgetBookPayload[];
+ sectionTitle: string;
+ emptyTitle: string;
+ tts?: ReadingWidgetTts;
+}
+
+export async function updateReadingWidget(request: UpdateReadingWidgetRequest): Promise {
+ await invoke('plugin:native-bridge|update_reading_widget', { payload: request });
+}
+
// ── Nightly updater (main-app commands, no native-bridge prefix) ─────────
// `verify_update_signature` gates the custom install flows (portable /
// AppImage / Android); `install_nightly_update` drives the Tauri updater for
diff --git a/apps/readest-app/src/utils/deeplink.ts b/apps/readest-app/src/utils/deeplink.ts
index aa64bdaf..df24bfaa 100644
--- a/apps/readest-app/src/utils/deeplink.ts
+++ b/apps/readest-app/src/utils/deeplink.ts
@@ -90,3 +90,38 @@ export const parseAnnotationDeepLink = (url: string): AnnotationDeepLink | null
return null;
};
+
+/**
+ * Parse an incoming readest:// or https://web.readest.com book-open URL.
+ * Matches only the bare form `book/{hash}` (the widget tap target); the
+ * 4-segment annotation form `book/{hash}/annotation/{id}` is handled by
+ * parseAnnotationDeepLink and must NOT match here.
+ */
+export const parseBookDeepLink = (url: string): { bookHash: string } | null => {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return null;
+ }
+
+ const isCustomScheme = parsed.protocol === 'readest:';
+ const isWebHost =
+ (parsed.protocol === 'https:' || parsed.protocol === 'http:') &&
+ parsed.host === 'web.readest.com';
+ if (!isCustomScheme && !isWebHost) return null;
+
+ const segments: string[] = isCustomScheme
+ ? [parsed.host, ...parsed.pathname.split('/')].filter(Boolean)
+ : parsed.pathname.split('/').filter(Boolean);
+
+ if (isWebHost) {
+ if (segments[0] !== 'o') return null;
+ segments.shift();
+ }
+
+ if (segments.length === 2 && segments[0] === 'book' && segments[1]) {
+ return { bookHash: segments[1] };
+ }
+ return null;
+};