feat(widget): add mobile home-screen reading widgets (#1602) (#4842)

Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.

- One responsive widget: Android resizable 1x1 to 4x3 (one book per
  column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
  rounded, with a percent badge and a progress bar (baked into the bitmap
  on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
  TTS is active, wired to the existing media session. Reading progress
  stays live during background TTS via a fraction computed from the baked
  offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
  Group and Android SharedPreferences through a new update_reading_widget
  native-bridge command; refresh is debounced and driven by library and
  progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
  place when one is already open.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-29 00:03:16 +08:00
committed by GitHub
parent 7972de1909
commit 7da41a65ad
46 changed files with 1499 additions and 4 deletions
Generated
+1
View File
@@ -7895,6 +7895,7 @@ dependencies = [
"keyring-core",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
@@ -69,5 +69,6 @@
"Imported {{count}} annotations_one": "Imported {{count}} annotation",
"Imported {{count}} annotations_other": "Imported {{count}} annotations",
"Recently read": "Recently read",
"Show recently read": "Show recently read"
"Show recently read": "Show recently read",
"Your books will appear here": "Your books will appear here"
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key><string>Readest</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundlePackageType</key><string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.bilingify.readest</string>
</array>
</dict>
</plist>
@@ -0,0 +1,34 @@
import WidgetKit
import SwiftUI
struct ReadingWidget: Widget {
let kind = "ReadingWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: ReadingProvider()) { entry in
ReadingWidgetEntryView(entry: entry)
}
.configurationDisplayName("Readest")
.description("Continue reading your recent books.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
struct ReadingWidgetEntryView: View {
@Environment(\.widgetFamily) var family
let entry: ReadingEntry
var body: some View {
content.widgetCardBackground()
}
@ViewBuilder private var content: some View {
switch family {
case .systemSmall: SmallReadingView(snapshot: entry.snapshot)
default: RowReadingView(snapshot: entry.snapshot)
}
}
}
@main
struct ReadestWidgetBundle: WidgetBundle {
var body: some Widget { ReadingWidget() }
}
@@ -0,0 +1,21 @@
import WidgetKit
import SwiftUI
struct ReadingEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot
}
struct ReadingProvider: TimelineProvider {
func placeholder(in context: Context) -> ReadingEntry {
ReadingEntry(date: Date(), snapshot: WidgetSnapshotStore.load())
}
func getSnapshot(in context: Context, completion: @escaping (ReadingEntry) -> Void) {
completion(ReadingEntry(date: Date(), snapshot: WidgetSnapshotStore.load()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<ReadingEntry>) -> Void) {
// One static entry; the app calls WidgetCenter.reloadAllTimelines() on change.
let entry = ReadingEntry(date: Date(), snapshot: WidgetSnapshotStore.load())
completion(Timeline(entries: [entry], policy: .never))
}
}
@@ -0,0 +1,122 @@
import WidgetKit
import SwiftUI
extension View {
/// Adaptive widget card surface: paper-white in light mode, near-black in
/// dark mode. Uses containerBackground on iOS 17+ (required there) and a
/// plain background on the iOS 15/16 deployment floor.
@ViewBuilder
func widgetCardBackground() -> some View {
if #available(iOS 17.0, *) {
containerBackground(Color(.systemBackground), for: .widget)
} else {
background(Color(.systemBackground))
}
}
}
private func bookURL(_ hash: String) -> URL { URL(string: "readest://book/\(hash)")! }
// MARK: - Progress Bar (overlaid along the bottom of the cover)
private struct ProgressBar: View {
let percent: Int
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.35))
Capsule().fill(Color.accentColor)
.frame(width: geo.size.width * CGFloat(min(100, max(0, percent))) / 100)
}
}
.frame(height: 3)
}
}
// MARK: - Cover Cell (shared by all widget families)
// Center-cropped cover image fills its cell; a % badge sits top-right and a
// thin progress bar is overlaid along the bottom edge. Missing-cover fallback
// shows a tinted tile with the title centered.
private struct CoverCell: View {
let book: WidgetSnapshotBook
var body: some View {
ZStack(alignment: .topTrailing) {
// Cover + progress bar (clipped together to the rounded rect)
ZStack(alignment: .bottom) {
if let image = WidgetSnapshotStore.coverImage(for: book.hash) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Color(.secondarySystemBackground)
Text(book.title)
.font(.caption2)
.lineLimit(3)
.multilineTextAlignment(.center)
.padding(6)
}
ProgressBar(percent: book.percent)
.padding(.horizontal, 6)
.padding(.bottom, 5)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
// % badge outside the clipShape so it is never trimmed
Text("\(book.percent)%")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 5)
.padding(.vertical, 3)
.background(Color.black.opacity(0.6))
.clipShape(Capsule())
.padding(5)
}
}
}
// MARK: - Small Widget (single cover fills the entire widget)
struct SmallReadingView: View {
let snapshot: WidgetSnapshot
var body: some View {
if let book = snapshot.books.first {
CoverCell(book: book)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.widgetURL(bookURL(book.hash))
} else {
EmptyReadingView(title: snapshot.emptyTitle)
}
}
}
// MARK: - Medium/Large Widget (row of up to 3 covers, no header for a clean UI)
struct RowReadingView: View {
let snapshot: WidgetSnapshot
var body: some View {
if snapshot.books.isEmpty {
EmptyReadingView(title: snapshot.emptyTitle)
} else {
HStack(alignment: .top, spacing: 10) {
ForEach(snapshot.books.prefix(3)) { book in
Link(destination: bookURL(book.hash)) {
CoverCell(book: book)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity)
}
}
.padding(12)
}
}
}
// MARK: - Empty State
struct EmptyReadingView: View {
let title: String
var body: some View {
VStack(spacing: 8) {
Image(systemName: "books.vertical").font(.title2).foregroundColor(.secondary)
Text(title).font(.system(size: 12)).foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(12).frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -0,0 +1,40 @@
import Foundation
import UIKit
struct WidgetSnapshotBook: Codable, Identifiable {
let hash: String
let title: String
let author: String
let percent: Int
var id: String { hash }
}
struct WidgetSnapshot: Codable {
let books: [WidgetSnapshotBook]
let sectionTitle: String
let emptyTitle: String
}
enum WidgetSnapshotStore {
static let suiteName = "group.com.bilingify.readest"
static let snapshotKey = "readingWidgetSnapshot"
static func load() -> WidgetSnapshot {
guard
let data = UserDefaults(suiteName: suiteName)?.data(forKey: snapshotKey),
let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
else {
return WidgetSnapshot(books: [], sectionTitle: "Continue reading", emptyTitle: "")
}
return snapshot
}
static func coverImage(for hash: String) -> UIImage? {
guard
let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: suiteName)
else { return nil }
let url = container.appendingPathComponent("widget/covers/\(hash).jpg")
return UIImage(contentsOfFile: url.path)
}
}
@@ -97,6 +97,9 @@ targets:
- target: ShareExtension
embed: true
codeSign: true
- target: ReadestWidget
embed: true
codeSign: true
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
@@ -104,6 +107,7 @@ targets:
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
- sdk: WidgetKit.framework
preBuildScripts:
- script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
@@ -167,3 +171,35 @@ targets:
ENABLE_BITCODE: false
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: false
SKIP_INSTALL: true
# WidgetKit extension that surfaces the user's current reading progress
# on the iOS home screen / Today view. Reads from the App Group suite
# written by the main app's useReadingWidget hook (Task 7).
ReadestWidget:
type: app-extension
platform: iOS
sources:
- path: ReadestWidget
info:
path: ReadestWidget/Info.plist
properties:
CFBundleDisplayName: Readest
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
settings:
base:
PRODUCT_NAME: ReadestWidget
PRODUCT_BUNDLE_IDENTIFIER: com.bilingify.readest.ReadestWidget
DEVELOPMENT_TEAM: J5W48D69VR
CODE_SIGN_ENTITLEMENTS: ReadestWidget/ReadestWidget.entitlements
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
SWIFT_VERSION: "5.0"
IPHONEOS_DEPLOYMENT_TARGET: 15.0
ARCHS: [arm64]
VALID_ARCHS: arm64
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
ENABLE_BITCODE: false
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: false
SKIP_INSTALL: true
dependencies:
- sdk: WidgetKit.framework
@@ -17,6 +17,9 @@ serde = "1.0"
thiserror = "2"
schemars = "0.8"
[dev-dependencies]
serde_json = "1"
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
schemars = "0.8"
@@ -54,6 +54,7 @@ dependencies {
// doesn't support modern API targets cleanly; alpha is widely used
// in production and the API is stable.
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.media:media:1.7.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
@@ -21,5 +21,15 @@
<receiver
android:name="com.readest.native_bridge.LookupChoiceReceiver"
android:exported="false" />
<receiver
android:name="com.readest.native_bridge.ReadingWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_reading_info" />
</receiver>
</application>
</manifest>
@@ -114,6 +114,34 @@ class PurchaseProductRequestArgs {
val productId: String? = null
}
@InvokeArg
class UpdateReadingWidgetBookArgs {
var hash: String = ""
var title: String = ""
var author: String = ""
var percent: Int = 0
var coverPath: String = ""
}
@InvokeArg
class UpdateReadingWidgetTtsArgs {
var active: Boolean = false
var playing: Boolean = false
}
@InvokeArg
class UpdateReadingWidgetRequestArgs {
var books: List<UpdateReadingWidgetBookArgs> = 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
@@ -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)
}
}
@@ -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)
}
}
}
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path android:fillColor="#FFFFFFFF" android:pathData="M6,19h4V5H6v14zm8,-14v14h4V5h-4z" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path android:fillColor="#FFFFFFFF" android:pathData="M8,5v14l11,-7z" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path android:fillColor="#FFFFFFFF" android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path android:fillColor="#FFFFFFFF" android:pathData="M6,6h2v12H6zm3.5,6l8.5,6V6z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="?android:attr/colorBackground" />
<corners android:radius="20dp" />
</shape>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:background="@drawable/widget_card_bg"
android:padding="6dp">
<!-- Empty FrameLayout used as a spacer. RemoteViews does not allow the base
<View> type, so a whitelisted container is used instead. -->
<FrameLayout android:id="@+id/top_spacer"
android:layout_width="match_parent" android:layout_height="8dp"
android:visibility="gone" />
<LinearLayout android:id="@+id/row"
android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"
android:orientation="horizontal" android:baselineAligned="false">
<ImageView android:id="@+id/cover0"
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:scaleType="fitCenter"
android:adjustViewBounds="true" android:contentDescription="@null" />
<ImageView android:id="@+id/cover1"
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:scaleType="fitCenter"
android:adjustViewBounds="true" android:contentDescription="@null" />
<ImageView android:id="@+id/cover2"
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:layout_marginHorizontal="4dp" android:scaleType="fitCenter"
android:adjustViewBounds="true" android:contentDescription="@null" />
</LinearLayout>
<LinearLayout android:id="@+id/tts_bar"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="horizontal" android:gravity="center"
android:layout_marginTop="6dp" android:visibility="gone">
<ImageView android:id="@+id/btn_prev"
android:layout_width="40dp" android:layout_height="40dp" android:padding="8dp"
android:src="@drawable/ic_widget_skip_previous" android:contentDescription="@null" />
<ImageView android:id="@+id/btn_play_pause"
android:layout_width="48dp" android:layout_height="48dp" android:padding="8dp"
android:layout_marginHorizontal="20dp"
android:src="@drawable/ic_widget_play" android:contentDescription="@null" />
<ImageView android:id="@+id/btn_next"
android:layout_width="40dp" android:layout_height="40dp" android:padding="8dp"
android:src="@drawable/ic_widget_skip_next" android:contentDescription="@null" />
</LinearLayout>
<TextView android:id="@+id/empty"
android:layout_width="match_parent" android:layout_height="match_parent"
android:gravity="center" android:visibility="gone" android:textSize="12sp"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp" android:minHeight="110dp"
android:minResizeWidth="40dp" android:minResizeHeight="40dp"
android:maxResizeWidth="250dp" android:maxResizeHeight="180dp"
android:targetCellWidth="4" android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0" android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_reading" />
@@ -38,6 +38,7 @@ const COMMANDS: &[&str] = &[
"get_secure_item",
"clear_secure_item",
"refresh_eink_screen",
"update_reading_widget",
];
fn main() {
@@ -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()
@@ -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()
}
}
}
@@ -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"]
@@ -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.
<tr>
<td>
`native-bridge:allow-update-reading-widget`
</td>
<td>
Enables the update_reading_widget command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-update-reading-widget`
</td>
<td>
Denies the update_reading_widget command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-use-background-audio`
</td>
@@ -43,4 +43,5 @@ permissions = [
"allow-get-secure-item",
"allow-clear-secure-item",
"allow-refresh-eink-screen",
"allow-update-reading-widget",
]
@@ -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`"
}
]
}
@@ -280,3 +280,11 @@ pub(crate) async fn refresh_eink_screen<R: Runtime>(
) -> Result<RefreshEinkScreenResponse> {
app.native_bridge().refresh_eink_screen()
}
#[command]
pub(crate) async fn update_reading_widget<R: Runtime>(
app: AppHandle<R>,
payload: UpdateReadingWidgetRequest,
) -> Result<()> {
app.native_bridge().update_reading_widget(payload)
}
@@ -351,6 +351,14 @@ impl<R: Runtime> NativeBridge<R> {
pub fn refresh_eink_screen(&self) -> crate::Result<RefreshEinkScreenResponse> {
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";
@@ -89,6 +89,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_secure_item,
commands::clear_secure_item,
commands::refresh_eink_screen,
commands::update_reading_widget,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -353,3 +353,11 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn update_reading_widget(&self, payload: UpdateReadingWidgetRequest) -> crate::Result<()> {
self.0
.run_mobile_plugin("update_reading_widget", payload)
.map_err(Into::into)
}
}
@@ -405,3 +405,30 @@ pub struct RefreshEinkScreenResponse {
pub success: bool,
pub error: Option<String>,
}
#[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<ReadingWidgetBook>,
pub section_title: String,
pub empty_title: String,
#[serde(default)]
pub tts: Option<ReadingWidgetTts>,
}
@@ -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());
}
@@ -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>): 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',
});
});
});
@@ -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();
});
});
@@ -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);
@@ -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);
};
@@ -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=<hash>)
// 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);
+4
View File
@@ -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();
@@ -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<string | null>(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]);
}
@@ -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, _]);
}
@@ -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<ReadingWidgetPayload> => {
// 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<void> => {
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);
}
};
+26
View File
@@ -341,6 +341,32 @@ export async function clearSecureItem(request: GetSecureItemRequest): Promise<Se
return invoke<SecureItemResponse>('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<void> {
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
+35
View File
@@ -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;
};