diff --git a/Cargo.lock b/Cargo.lock
index 49153bd1..7639b174 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -71,6 +71,7 @@ dependencies = [
"tokio",
"tokio-util",
"walkdir",
+ "winreg 0.52.0",
"zip 2.4.2",
]
@@ -1964,7 +1965,7 @@ dependencies = [
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"vswhom",
- "winreg",
+ "winreg 0.55.0",
]
[[package]]
@@ -10099,6 +10100,15 @@ dependencies = [
"windows-targets 0.42.2",
]
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -10420,6 +10430,16 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winreg"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "winreg"
version = "0.55.0"
diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml
index 58de1607..5c4e573b 100644
--- a/apps/readest-app/src-tauri/Cargo.toml
+++ b/apps/readest-app/src-tauri/Cargo.toml
@@ -117,5 +117,10 @@ tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
discord-rich-presence = "1.0.0"
+[target.'cfg(windows)'.dependencies]
+# Resolve the user's default browser from the registry for the cold-browser
+# OAuth-redirect fallback (see src/spawn_fresh_browser.rs).
+winreg = "0.52"
+
[target.'cfg(target_os = "android")'.dependencies]
libc = "0.2"
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
index 7370c2c2..b2e0824d 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
@@ -1133,6 +1133,71 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
invoke.resolve(ret)
}
+ // ── Keyed secure key-value store ──────────────────────────────────
+ // Same EncryptedSharedPreferences backing as the sync passphrase, but
+ // a generic keyed store (one prefs file, the caller's `key` as the
+ // entry key) so secrets like the Google Drive token set persist the
+ // same way without each needing its own command.
+
+ private val secureItemsPrefsName = "readest_secure_items_v1"
+
+ private fun openSecureItemsPrefs(): android.content.SharedPreferences {
+ val masterKey = androidx.security.crypto.MasterKey.Builder(activity)
+ .setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ return androidx.security.crypto.EncryptedSharedPreferences.create(
+ activity,
+ secureItemsPrefsName,
+ masterKey,
+ androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ }
+
+ @Command
+ fun set_secure_item(invoke: Invoke) {
+ val args = invoke.parseArgs(SecureItemSetArgs::class.java)
+ val ret = JSObject()
+ try {
+ openSecureItemsPrefs().edit().putString(args.key, args.value).apply()
+ ret.put("success", true)
+ } catch (e: Exception) {
+ Log.e("NativeBridgePlugin", "set_secure_item failed", e)
+ ret.put("success", false)
+ ret.put("error", e.message ?: "unknown")
+ }
+ invoke.resolve(ret)
+ }
+
+ @Command
+ fun get_secure_item(invoke: Invoke) {
+ val args = invoke.parseArgs(SecureItemGetArgs::class.java)
+ val ret = JSObject()
+ try {
+ val value = openSecureItemsPrefs().getString(args.key, null)
+ if (value != null) ret.put("value", value)
+ } catch (e: Exception) {
+ Log.e("NativeBridgePlugin", "get_secure_item failed", e)
+ ret.put("error", e.message ?: "unknown")
+ }
+ invoke.resolve(ret)
+ }
+
+ @Command
+ fun clear_secure_item(invoke: Invoke) {
+ val args = invoke.parseArgs(SecureItemGetArgs::class.java)
+ val ret = JSObject()
+ try {
+ openSecureItemsPrefs().edit().remove(args.key).apply()
+ ret.put("success", true)
+ } catch (e: Exception) {
+ Log.e("NativeBridgePlugin", "clear_secure_item failed", e)
+ ret.put("success", false)
+ ret.put("error", e.message ?: "unknown")
+ }
+ invoke.resolve(ret)
+ }
+
/**
* Hand a selected word off to whatever dictionary / lookup app the
* user has installed, via the standard `ACTION_PROCESS_TEXT`
@@ -1359,3 +1424,14 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
class SyncPassphraseSetArgs {
lateinit var passphrase: String
}
+
+@app.tauri.annotation.InvokeArg
+class SecureItemSetArgs {
+ lateinit var key: String
+ lateinit var value: String
+}
+
+@app.tauri.annotation.InvokeArg
+class SecureItemGetArgs {
+ lateinit var key: String
+}
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 eaabfb9a..b820ac74 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
@@ -34,6 +34,9 @@ const COMMANDS: &[&str] = &[
"checkPermissions",
"requestPermissions",
"clip_url",
+ "set_secure_item",
+ "get_secure_item",
+ "clear_secure_item",
];
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 16acb732..ffb2bed3 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
@@ -1234,6 +1234,79 @@ class NativeBridgePlugin: Plugin {
}
}
+ // ── Keyed secure key-value store ──────────────────────────────────
+ // Same Keychain backing as the sync passphrase, but a generic keyed
+ // store: one service, the caller's `key` as the account, so secrets
+ // like the Google Drive token set persist the same way.
+
+ private static let secureItemsService = "com.bilingify.readest.secure-items"
+
+ private func secureItemBaseQuery(_ key: String) -> [String: Any] {
+ return [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: NativeBridgePlugin.secureItemsService,
+ kSecAttrAccount as String: key
+ ]
+ }
+
+ @objc public func set_secure_item(_ invoke: Invoke) {
+ do {
+ let args = try invoke.parseArgs(SecureItemSetArgs.self)
+ guard let data = args.value.data(using: .utf8) else {
+ invoke.resolve(["success": false, "error": "encoding"])
+ return
+ }
+ var query = secureItemBaseQuery(args.key)
+ query[kSecValueData as String] = data
+ // Replace any existing entry. Delete-then-add keeps the
+ // accessibility class consistent across SDK versions.
+ SecItemDelete(query as CFDictionary)
+ let status = SecItemAdd(query as CFDictionary, nil)
+ if status == errSecSuccess {
+ invoke.resolve(["success": true])
+ } else {
+ invoke.resolve(["success": false, "error": "OSStatus \(status)"])
+ }
+ } catch {
+ invoke.resolve(["success": false, "error": "\(error)"])
+ }
+ }
+
+ @objc public func get_secure_item(_ invoke: Invoke) {
+ do {
+ let args = try invoke.parseArgs(SecureItemGetArgs.self)
+ var query = secureItemBaseQuery(args.key)
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ if status == errSecSuccess, let data = item as? Data, let s = String(data: data, encoding: .utf8) {
+ invoke.resolve(["value": s])
+ } else if status == errSecItemNotFound {
+ // No entry: empty response. The TS layer treats this as "not stored".
+ invoke.resolve([:])
+ } else {
+ invoke.resolve(["error": "OSStatus \(status)"])
+ }
+ } catch {
+ invoke.resolve(["error": "\(error)"])
+ }
+ }
+
+ @objc public func clear_secure_item(_ invoke: Invoke) {
+ do {
+ let args = try invoke.parseArgs(SecureItemGetArgs.self)
+ let status = SecItemDelete(secureItemBaseQuery(args.key) as CFDictionary)
+ if status == errSecSuccess || status == errSecItemNotFound {
+ invoke.resolve(["success": true])
+ } else {
+ invoke.resolve(["success": false, "error": "OSStatus \(status)"])
+ }
+ } catch {
+ invoke.resolve(["success": false, "error": "\(error)"])
+ }
+ }
+
@objc public func show_lookup_popover(_ invoke: Invoke) {
// Bridge for the system-dictionary "Look Up" surface on iOS.
// We use `UIReferenceLibraryViewController`, which is the same
@@ -1633,6 +1706,15 @@ class SyncPassphraseSetArgs: Decodable {
let passphrase: String
}
+class SecureItemSetArgs: Decodable {
+ let key: String
+ let value: String
+}
+
+class SecureItemGetArgs: Decodable {
+ let key: String
+}
+
class ShowLookupPopoverArgs: Decodable {
let word: String
}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/clear_secure_item.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/clear_secure_item.toml
new file mode 100644
index 00000000..36b7f4c1
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/clear_secure_item.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-clear-secure-item"
+description = "Enables the clear_secure_item command without any pre-configured scope."
+commands.allow = ["clear_secure_item"]
+
+[[permission]]
+identifier = "deny-clear-secure-item"
+description = "Denies the clear_secure_item command without any pre-configured scope."
+commands.deny = ["clear_secure_item"]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/get_secure_item.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/get_secure_item.toml
new file mode 100644
index 00000000..54ecb152
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/get_secure_item.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-get-secure-item"
+description = "Enables the get_secure_item command without any pre-configured scope."
+commands.allow = ["get_secure_item"]
+
+[[permission]]
+identifier = "deny-get-secure-item"
+description = "Denies the get_secure_item command without any pre-configured scope."
+commands.deny = ["get_secure_item"]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/set_secure_item.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/set_secure_item.toml
new file mode 100644
index 00000000..e3348d0a
--- /dev/null
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/set_secure_item.toml
@@ -0,0 +1,13 @@
+# Automatically generated - DO NOT EDIT!
+
+"$schema" = "../../schemas/schema.json"
+
+[[permission]]
+identifier = "allow-set-secure-item"
+description = "Enables the set_secure_item command without any pre-configured scope."
+commands.allow = ["set_secure_item"]
+
+[[permission]]
+identifier = "deny-set-secure-item"
+description = "Denies the set_secure_item command without any pre-configured scope."
+commands.deny = ["set_secure_item"]
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 f0b9546a..1e238690 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
@@ -42,6 +42,9 @@ Default permissions for the plugin
- `allow-get-sync-passphrase`
- `allow-clear-sync-passphrase`
- `allow-is-sync-keychain-available`
+- `allow-set-secure-item`
+- `allow-get-secure-item`
+- `allow-clear-secure-item`
## Permission Table
@@ -211,6 +214,32 @@ Denies the clear_lookup_dictionary command without any pre-configured scope.
|
+`native-bridge:allow-clear-secure-item`
+
+ |
+
+
+Enables the clear_secure_item command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`native-bridge:deny-clear-secure-item`
+
+ |
+
+
+Denies the clear_secure_item command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`native-bridge:allow-clear-sync-passphrase`
|
@@ -393,6 +422,32 @@ Denies the get_screen_brightness command without any pre-configured scope.
|
+`native-bridge:allow-get-secure-item`
+
+ |
+
+
+Enables the get_secure_item command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`native-bridge:deny-get-secure-item`
+
+ |
+
+
+Denies the get_secure_item command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`native-bridge:allow-get-status-bar-height`
|
@@ -1017,6 +1072,32 @@ Denies the set_screen_brightness command without any pre-configured scope.
|
+`native-bridge:allow-set-secure-item`
+
+ |
+
+
+Enables the set_secure_item command without any pre-configured scope.
+
+ |
+
+
+
+|
+
+`native-bridge:deny-set-secure-item`
+
+ |
+
+
+Denies the set_secure_item command without any pre-configured scope.
+
+ |
+
+
+
+|
+
`native-bridge:allow-set-sync-passphrase`
|
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 7bcb6ff3..df139dad 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
@@ -39,4 +39,7 @@ permissions = [
"allow-get-sync-passphrase",
"allow-clear-sync-passphrase",
"allow-is-sync-keychain-available",
+ "allow-set-secure-item",
+ "allow-get-secure-item",
+ "allow-clear-secure-item",
]
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 766396a8..20369e6d 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
@@ -366,6 +366,18 @@
"const": "deny-clear-lookup-dictionary",
"markdownDescription": "Denies the clear_lookup_dictionary command without any pre-configured scope."
},
+ {
+ "description": "Enables the clear_secure_item command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-clear-secure-item",
+ "markdownDescription": "Enables the clear_secure_item command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the clear_secure_item command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-clear-secure-item",
+ "markdownDescription": "Denies the clear_secure_item command without any pre-configured scope."
+ },
{
"description": "Enables the clear_sync_passphrase command without any pre-configured scope.",
"type": "string",
@@ -450,6 +462,18 @@
"const": "deny-get-screen-brightness",
"markdownDescription": "Denies the get_screen_brightness command without any pre-configured scope."
},
+ {
+ "description": "Enables the get_secure_item command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-get-secure-item",
+ "markdownDescription": "Enables the get_secure_item command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the get_secure_item command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-get-secure-item",
+ "markdownDescription": "Denies the get_secure_item command without any pre-configured scope."
+ },
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
@@ -738,6 +762,18 @@
"const": "deny-set-screen-brightness",
"markdownDescription": "Denies the set_screen_brightness command without any pre-configured scope."
},
+ {
+ "description": "Enables the set_secure_item command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-set-secure-item",
+ "markdownDescription": "Enables the set_secure_item command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the set_secure_item command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-set-secure-item",
+ "markdownDescription": "Denies the set_secure_item command without any pre-configured scope."
+ },
{
"description": "Enables the set_sync_passphrase command without any pre-configured scope.",
"type": "string",
@@ -787,10 +823,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`",
+ "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`",
"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`"
+ "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`"
}
]
}
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 694fad17..11e56c84 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
@@ -249,3 +249,27 @@ pub(crate) async fn is_sync_keychain_available(
) -> Result {
app.native_bridge().is_sync_keychain_available()
}
+
+#[command]
+pub(crate) async fn set_secure_item(
+ app: AppHandle,
+ payload: SetSecureItemRequest,
+) -> Result {
+ app.native_bridge().set_secure_item(payload)
+}
+
+#[command]
+pub(crate) async fn get_secure_item(
+ app: AppHandle,
+ payload: GetSecureItemRequest,
+) -> Result {
+ app.native_bridge().get_secure_item(payload)
+}
+
+#[command]
+pub(crate) async fn clear_secure_item(
+ app: AppHandle,
+ payload: GetSecureItemRequest,
+) -> Result {
+ app.native_bridge().clear_secure_item(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 73179788..f6ae658c 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
@@ -285,6 +285,66 @@ impl NativeBridge {
.to_string(),
))
}
+
+ // ── Keyed secure key-value store ────────────────────────────────────
+ //
+ // Same keychain backends + fail-loud/fail-soft contract as the sync
+ // passphrase above, but each item gets its own keychain entry keyed by
+ // `key` (the item's `user`/account), so many independent secrets (the
+ // Drive token set, future provider tokens) coexist under one service
+ // without colliding with the passphrase entry (user "default").
+
+ pub fn set_secure_item(
+ &self,
+ payload: SetSecureItemRequest,
+ ) -> crate::Result {
+ match keyring_entry_for(&payload.key).and_then(|e| e.set_password(&payload.value)) {
+ Ok(()) => Ok(SecureItemResponse {
+ success: true,
+ error: None,
+ }),
+ Err(err) => Ok(SecureItemResponse {
+ success: false,
+ error: Some(err.to_string()),
+ }),
+ }
+ }
+
+ pub fn get_secure_item(
+ &self,
+ payload: GetSecureItemRequest,
+ ) -> crate::Result {
+ match keyring_entry_for(&payload.key).and_then(|e| e.get_password()) {
+ Ok(value) => Ok(GetSecureItemResponse {
+ value: Some(value),
+ error: None,
+ }),
+ Err(keyring_core::Error::NoEntry) => Ok(GetSecureItemResponse {
+ value: None,
+ error: None,
+ }),
+ Err(err) => Ok(GetSecureItemResponse {
+ value: None,
+ error: Some(err.to_string()),
+ }),
+ }
+ }
+
+ pub fn clear_secure_item(
+ &self,
+ payload: GetSecureItemRequest,
+ ) -> crate::Result {
+ match keyring_entry_for(&payload.key).and_then(|e| e.delete_credential()) {
+ Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(SecureItemResponse {
+ success: true,
+ error: None,
+ }),
+ Err(err) => Ok(SecureItemResponse {
+ success: false,
+ error: Some(err.to_string()),
+ }),
+ }
+ }
}
const KEYRING_SERVICE: &str = "Readest Safe Storage";
@@ -293,3 +353,9 @@ const KEYRING_USER: &str = "default";
fn keyring_entry() -> std::result::Result {
keyring_core::Entry::new(KEYRING_SERVICE, KEYRING_USER)
}
+
+/// Keychain entry for a keyed secure item — same service as the passphrase,
+/// with the caller's `key` as the per-item account so each secret is distinct.
+fn keyring_entry_for(key: &str) -> std::result::Result {
+ keyring_core::Entry::new(KEYRING_SERVICE, key)
+}
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 368c0055..17d70d08 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
@@ -85,6 +85,9 @@ pub fn init() -> TauriPlugin {
commands::get_sync_passphrase,
commands::clear_sync_passphrase,
commands::is_sync_keychain_available,
+ commands::set_secure_item,
+ commands::get_secure_item,
+ commands::clear_secure_item,
])
.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 d70f1ba3..c962e727 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
@@ -299,6 +299,39 @@ impl NativeBridge {
}
}
+impl NativeBridge {
+ pub fn set_secure_item(
+ &self,
+ payload: SetSecureItemRequest,
+ ) -> crate::Result {
+ self.0
+ .run_mobile_plugin("set_secure_item", payload)
+ .map_err(Into::into)
+ }
+}
+
+impl NativeBridge {
+ pub fn get_secure_item(
+ &self,
+ payload: GetSecureItemRequest,
+ ) -> crate::Result {
+ self.0
+ .run_mobile_plugin("get_secure_item", payload)
+ .map_err(Into::into)
+ }
+}
+
+impl NativeBridge {
+ pub fn clear_secure_item(
+ &self,
+ payload: GetSecureItemRequest,
+ ) -> crate::Result {
+ self.0
+ .run_mobile_plugin("clear_secure_item", payload)
+ .map_err(Into::into)
+ }
+}
+
impl NativeBridge {
/// Open a full-screen `WKWebView` / `WebView` over the main app,
/// navigate to `payload.url` with a real Chrome UA, wait for load
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 50a0a19a..86e86c10 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
@@ -358,3 +358,40 @@ pub struct SyncKeychainAvailableResponse {
pub available: bool,
pub error: Option,
}
+
+// ── Keyed secure key-value store ─────────────────────────────────────────
+//
+// A generic, keyed secret store over the same OS keychain backends as the
+// sync passphrase above, so secrets that aren't the single sync passphrase
+// (the Google Drive OAuth token set, and any future cloud provider's refresh
+// token) get the same cross-launch persistence without each needing its own
+// native command.
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SetSecureItemRequest {
+ pub key: String,
+ pub value: String,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetSecureItemRequest {
+ pub key: String,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SecureItemResponse {
+ pub success: bool,
+ pub error: Option,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetSecureItemResponse {
+ /// Present iff an item is stored under the key. Absent (and `error: None`)
+ /// means "no entry on this device".
+ pub value: Option,
+ pub error: Option,
+}
diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs
index 591ee125..f167df26 100644
--- a/apps/readest-app/src-tauri/src/lib.rs
+++ b/apps/readest-app/src-tauri/src/lib.rs
@@ -33,6 +33,8 @@ mod mobi_parser;
mod nightly_update;
mod parser_common;
mod range_file;
+#[cfg(desktop)]
+mod spawn_fresh_browser;
mod transfer_file;
#[cfg(desktop)]
mod window_state;
@@ -291,6 +293,8 @@ pub fn run() {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
discord_rpc::clear_book_presence,
clip_url::clip_url,
+ #[cfg(desktop)]
+ spawn_fresh_browser::spawn_fresh_browser,
nightly_update::verify_update_signature,
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
nightly_update::install_nightly_update,
diff --git a/apps/readest-app/src-tauri/src/spawn_fresh_browser.rs b/apps/readest-app/src-tauri/src/spawn_fresh_browser.rs
new file mode 100644
index 00000000..9064cdcc
--- /dev/null
+++ b/apps/readest-app/src-tauri/src/spawn_fresh_browser.rs
@@ -0,0 +1,184 @@
+//! Open a URL in a freshly-spawned, isolated browser process.
+//!
+//! Why this exists: Windows snapshots URL-protocol associations per browser
+//! process at launch. A browser already running before the app registered its
+//! reverse-DNS OAuth scheme (`app.deep_link().register_all()`) therefore reports
+//! the scheme as having no registered handler and silently drops the redirect.
+//! Launching a NEW browser process with its OWN `--user-data-dir` forces a cold
+//! association read, so the just-registered scheme routes the redirect back to
+//! the app. This is the fallback the desktop OAuth runner uses when the user's
+//! default browser does not return within the grace period — see
+//! `services/sync/providers/gdrive/auth/oauthDesktop.ts`.
+//!
+//! We spawn the user's OWN default browser when it is Chromium-based (a familiar
+//! window, far less alarming than a surprise foreign browser), falling back to
+//! Microsoft Edge only when the default is not Chromium-based — Edge ships on
+//! every Windows 10/11 install and routes custom schemes reliably. Forcing an
+//! isolated cold process requires the Chromium `--user-data-dir` flag, hence the
+//! family check. A stable per-user profile directory is reused so a returning
+//! user keeps their Google session across reconnects, and is isolated from the
+//! user's real profile so spawning it never disturbs their open browser.
+//!
+//! Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+//! used with the author's explicit permission.
+
+#[cfg(target_os = "windows")]
+use std::path::{Path, PathBuf};
+
+/// Subdirectory (under the system temp dir) for the fallback browser's isolated
+/// profile.
+#[cfg(target_os = "windows")]
+const FALLBACK_PROFILE_DIR: &str = "readest-oauth-browser";
+
+/// Executable stems (lower-cased, no extension) of Chromium-based browsers, which
+/// all accept `--user-data-dir` to spawn an isolated cold process.
+#[cfg(target_os = "windows")]
+const CHROMIUM_BROWSER_STEMS: [&str; 7] = [
+ "chrome", "chromium", "msedge", "brave", "vivaldi", "opera", "thorium",
+];
+
+/// Resolve the user's default `https` browser executable from its per-user
+/// UserChoice association (`ProgId` -> `shell\open\command`). Returns `None` if
+/// the association is missing/unreadable or the resolved path does not exist.
+#[cfg(target_os = "windows")]
+fn resolve_default_browser() -> Option {
+ use winreg::enums::{HKEY_CLASSES_ROOT, HKEY_CURRENT_USER};
+ use winreg::RegKey;
+
+ const USER_CHOICE_KEY: &str =
+ r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice";
+
+ let prog_id: String = RegKey::predef(HKEY_CURRENT_USER)
+ .open_subkey(USER_CHOICE_KEY)
+ .ok()?
+ .get_value("ProgId")
+ .ok()?;
+ let command: String = RegKey::predef(HKEY_CLASSES_ROOT)
+ .open_subkey(format!(r"{prog_id}\shell\open\command"))
+ .ok()?
+ .get_value("")
+ .ok()?;
+ let exe = exe_path_from_command(&command)?;
+ exe.exists().then_some(exe)
+}
+
+/// Extract the executable path from a `shell\open\command` string. Handles the
+/// usual quoted form (`"C:\...\app.exe" --flags %1`) and a best-effort unquoted
+/// form (up to the first space). Pure (no filesystem access) so it is unit-tested.
+#[cfg(target_os = "windows")]
+fn exe_path_from_command(command: &str) -> Option {
+ let trimmed = command.trim_start();
+ let exe = match trimmed.strip_prefix('"') {
+ Some(rest) => rest.split('"').next()?,
+ None => trimmed.split_whitespace().next()?,
+ };
+ (!exe.is_empty()).then(|| PathBuf::from(exe))
+}
+
+/// Whether the executable is a Chromium-based browser (so it accepts
+/// `--user-data-dir`). Matched on the lower-cased file stem.
+#[cfg(target_os = "windows")]
+fn is_chromium_family(exe: &Path) -> bool {
+ exe.file_stem()
+ .and_then(|stem| stem.to_str())
+ .map(|stem| {
+ let lowered = stem.to_ascii_lowercase();
+ CHROMIUM_BROWSER_STEMS.contains(&lowered.as_str())
+ })
+ .unwrap_or(false)
+}
+
+/// Resolve Microsoft Edge's executable from the standard install locations,
+/// honouring a non-default `Program Files` drive via the environment. Probes the
+/// 32-bit root first (Edge's historical home), then both 64-bit roots
+/// (`ProgramW6432` resolves the real 64-bit path even from a 32-bit process).
+#[cfg(target_os = "windows")]
+fn find_edge() -> Option {
+ const EDGE_SUFFIX: &str = r"Microsoft\Edge\Application\msedge.exe";
+ ["ProgramFiles(x86)", "ProgramW6432", "ProgramFiles"]
+ .iter()
+ .filter_map(|var| std::env::var(var).ok())
+ .map(|base| PathBuf::from(base).join(EDGE_SUFFIX))
+ .find(|path| path.exists())
+}
+
+/// Open `url` in a freshly-spawned, isolated (cold) browser process so it routes
+/// the reverse-DNS OAuth redirect back to the app. Prefers the user's own default
+/// browser when it is Chromium-based; otherwise uses Edge. See the module docs
+/// for why a cold process is required.
+#[cfg(all(desktop, target_os = "windows"))]
+#[tauri::command]
+pub async fn spawn_fresh_browser(url: String) -> Result<(), String> {
+ use std::process::Command;
+
+ let browser = resolve_default_browser()
+ .filter(|exe| is_chromium_family(exe))
+ .or_else(find_edge)
+ .ok_or_else(|| "No Chromium-based browser found to open the sign-in window".to_string())?;
+ let profile_dir = std::env::temp_dir().join(FALLBACK_PROFILE_DIR);
+
+ Command::new(browser)
+ .arg(format!("--user-data-dir={}", profile_dir.display()))
+ .arg("--no-first-run")
+ .arg("--no-default-browser-check")
+ .arg("--new-window")
+ .arg(url)
+ .spawn()
+ .map_err(|e| format!("Could not open the sign-in browser: {}", e))?;
+ Ok(())
+}
+
+/// Non-Windows desktop targets are a no-op success: the per-process association
+/// cache that defeats the default browser is a Windows-specific behaviour, so the
+/// default-browser open already covered the redirect path there.
+#[cfg(all(desktop, not(target_os = "windows")))]
+#[tauri::command]
+pub async fn spawn_fresh_browser(_url: String) -> Result<(), String> {
+ Ok(())
+}
+
+#[cfg(all(test, target_os = "windows"))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn extracts_quoted_exe_ignoring_trailing_args() {
+ let command = r#""C:\Users\me\AppData\Local\Chromium\Application\chrome.exe" --foo --single-argument %1"#;
+ assert_eq!(
+ exe_path_from_command(command),
+ Some(PathBuf::from(
+ r"C:\Users\me\AppData\Local\Chromium\Application\chrome.exe"
+ )),
+ );
+ }
+
+ #[test]
+ fn extracts_unquoted_exe_up_to_first_space() {
+ assert_eq!(
+ exe_path_from_command(r"C:\Apps\browser.exe %1"),
+ Some(PathBuf::from(r"C:\Apps\browser.exe")),
+ );
+ }
+
+ #[test]
+ fn returns_none_for_empty_command() {
+ assert_eq!(exe_path_from_command(" "), None);
+ assert_eq!(exe_path_from_command(r#""""#), None);
+ }
+
+ #[test]
+ fn recognises_chromium_browsers_by_stem() {
+ for exe in [r"C:\x\chrome.exe", r"C:\x\msedge.exe", r"C:\x\Brave.exe"] {
+ assert!(
+ is_chromium_family(Path::new(exe)),
+ "{exe} should be Chromium-family"
+ );
+ }
+ }
+
+ #[test]
+ fn rejects_non_chromium_browsers() {
+ assert!(!is_chromium_family(Path::new(r"C:\x\firefox.exe")));
+ assert!(!is_chromium_family(Path::new(r"C:\x\iexplore.exe")));
+ }
+}
diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json
index f6264f75..54718d80 100644
--- a/apps/readest-app/src-tauri/tauri.conf.json
+++ b/apps/readest-app/src-tauri/tauri.conf.json
@@ -160,10 +160,17 @@
"deep-link": {
"mobile": [
{ "scheme": ["https"], "host": "web.readest.com", "appLink": true },
- { "scheme": ["readest"], "appLink": false }
+ { "scheme": ["readest"], "appLink": false },
+ {
+ "scheme": ["com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq"],
+ "appLink": false
+ }
],
"desktop": {
- "schemes": ["readest"]
+ "schemes": [
+ "readest",
+ "com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq"
+ ]
}
},
"updater": {
diff --git a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts
new file mode 100644
index 00000000..8b350cad
--- /dev/null
+++ b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, test } from 'vitest';
+import { withActiveCloudProvider } from '@/components/settings/integrations/cloudSync';
+import { isCloudSyncInPlan } from '@/utils/access';
+import type { SystemSettings } from '@/types/settings';
+
+const base = {
+ webdav: { enabled: true, serverUrl: 'https://dav', username: 'u', password: 'p', rootPath: '/' },
+ googleDrive: { enabled: true, accountLabel: 'a@b.com' },
+} as unknown as SystemSettings;
+
+describe('withActiveCloudProvider', () => {
+ test('enabling WebDAV disables Google Drive (exclusive)', () => {
+ const next = withActiveCloudProvider(base, 'webdav');
+ expect(next.webdav.enabled).toBe(true);
+ expect(next.googleDrive.enabled).toBe(false);
+ });
+
+ test('enabling Google Drive disables WebDAV (exclusive)', () => {
+ const next = withActiveCloudProvider(base, 'gdrive');
+ expect(next.webdav.enabled).toBe(false);
+ expect(next.googleDrive.enabled).toBe(true);
+ });
+
+ test('null disables both', () => {
+ const next = withActiveCloudProvider(base, null);
+ expect(next.webdav.enabled).toBe(false);
+ expect(next.googleDrive.enabled).toBe(false);
+ });
+
+ test('leaves the rest of each provider config untouched', () => {
+ const next = withActiveCloudProvider(base, 'gdrive');
+ expect(next.webdav.serverUrl).toBe('https://dav');
+ expect(next.googleDrive.accountLabel).toBe('a@b.com');
+ });
+});
+
+describe('isCloudSyncInPlan', () => {
+ test('any paid plan can use cloud sync', () => {
+ expect(isCloudSyncInPlan('plus')).toBe(true);
+ expect(isCloudSyncInPlan('pro')).toBe(true);
+ expect(isCloudSyncInPlan('purchase')).toBe(true); // lifetime
+ });
+
+ test('free plan cannot', () => {
+ expect(isCloudSyncInPlan('free')).toBe(false);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/backup-settings.test.ts b/apps/readest-app/src/__tests__/services/backup-settings.test.ts
index 19a581b3..f238a203 100644
--- a/apps/readest-app/src/__tests__/services/backup-settings.test.ts
+++ b/apps/readest-app/src/__tests__/services/backup-settings.test.ts
@@ -61,6 +61,13 @@ function makeSettings(overrides: Partial = {}): SystemSettings {
},
readwise: { enabled: true, accessToken: 'rw-token', lastSyncedAt: 999 },
hardcover: { enabled: false, accessToken: 'hc-token', lastSyncedAt: 888 },
+ googleDrive: {
+ enabled: true,
+ accountLabel: 'me@gmail.com',
+ strategy: 'silent',
+ deviceId: 'gdrive-device-id',
+ lastSyncedAt: 777,
+ },
aiSettings: {
enabled: true,
provider: 'ollama',
@@ -94,6 +101,9 @@ describe('sanitizeSettingsForBackup - blacklist', () => {
const out = rec(sanitizeSettingsForBackup(makeSettings()));
expect(out['replicaDeviceId']).toBeUndefined();
expect(rec(out['kosync'])['deviceId']).toBeUndefined();
+ expect(rec(out['googleDrive'])['deviceId']).toBeUndefined();
+ // Non-identity Drive settings still travel with the backup.
+ expect(rec(out['googleDrive'])['enabled']).toBe(true);
});
it('strips sync cursors', () => {
@@ -104,6 +114,7 @@ describe('sanitizeSettingsForBackup - blacklist', () => {
expect(out['lastSyncedAtReplicas']).toBeUndefined();
expect(rec(out['readwise'])['lastSyncedAt']).toBeUndefined();
expect(rec(out['hardcover'])['lastSyncedAt']).toBeUndefined();
+ expect(rec(out['googleDrive'])['lastSyncedAt']).toBeUndefined();
});
it('strips transient runtime state', () => {
diff --git a/apps/readest-app/src/__tests__/services/sync/file/provider-conformance.test.ts b/apps/readest-app/src/__tests__/services/sync/file/provider-conformance.test.ts
index 40e41214..27a99015 100644
--- a/apps/readest-app/src/__tests__/services/sync/file/provider-conformance.test.ts
+++ b/apps/readest-app/src/__tests__/services/sync/file/provider-conformance.test.ts
@@ -3,6 +3,7 @@ import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVPro
import { FileSyncError } from '@/services/sync/file/provider';
import type { FileSyncProvider } from '@/services/sync/file/provider';
import type { WebDAVSettings } from '@/types/settings';
+import { runSemanticContract } from './providerSemanticContract';
/**
* Contract every {@link FileSyncProvider} must satisfy, exercised here against
@@ -98,6 +99,18 @@ runProviderConformance('WebDAVProvider', () => createWebDAVProvider(settings), {
fetchMock.mockResolvedValueOnce(new Response(body, { status, headers })),
});
+// The same shared semantic contract Drive is held to, proving it is genuinely
+// transport-agnostic (WebDAV does one fetch/op; Drive does several).
+runSemanticContract('WebDAVProvider', () => {
+ const scenarioFetch = vi.fn();
+ globalThis.fetch = scenarioFetch as unknown as typeof fetch;
+ return {
+ makeProvider: () => createWebDAVProvider(settings),
+ stageAbsent: () => scenarioFetch.mockResolvedValueOnce(new Response(null, { status: 404 })),
+ stageAuthFailure: () => scenarioFetch.mockResolvedValueOnce(new Response('', { status: 401 })),
+ };
+});
+
describe('WebDAVProvider construction', () => {
test('rootPath is normalised', () => {
expect(createWebDAVProvider({ ...settings, rootPath: '/MyDav/' }).rootPath).toBe('/MyDav');
diff --git a/apps/readest-app/src/__tests__/services/sync/file/providerRegistry.test.ts b/apps/readest-app/src/__tests__/services/sync/file/providerRegistry.test.ts
new file mode 100644
index 00000000..70bbab52
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/file/providerRegistry.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+vi.mock('@/services/sync/providers/gdrive/buildGoogleDriveProvider', () => ({
+ buildGoogleDriveProvider: vi.fn(),
+}));
+
+import { buildGoogleDriveProvider } from '@/services/sync/providers/gdrive/buildGoogleDriveProvider';
+import {
+ createFileSyncProvider,
+ getEnabledFileSyncBackends,
+} from '@/services/sync/file/providerRegistry';
+import type { FileSyncProvider } from '@/services/sync/file/provider';
+import type { WebDAVSettings } from '@/types/settings';
+
+const webdav: WebDAVSettings = {
+ enabled: true,
+ serverUrl: 'https://dav.example.com',
+ username: 'u',
+ password: 'p',
+ rootPath: '/',
+};
+
+afterEach(() => vi.clearAllMocks());
+
+describe('getEnabledFileSyncBackends', () => {
+ test('lists only switched-on backends in a stable order', () => {
+ expect(getEnabledFileSyncBackends({})).toEqual([]);
+ expect(getEnabledFileSyncBackends({ webdav })).toEqual(['webdav']);
+ expect(getEnabledFileSyncBackends({ webdav, googleDrive: { enabled: true } })).toEqual([
+ 'webdav',
+ 'gdrive',
+ ]);
+ expect(
+ getEnabledFileSyncBackends({
+ webdav: { ...webdav, enabled: false },
+ googleDrive: { enabled: true },
+ }),
+ ).toEqual(['gdrive']);
+ });
+});
+
+describe('createFileSyncProvider', () => {
+ test('builds a WebDAV provider from its settings', async () => {
+ const provider = await createFileSyncProvider('webdav', { webdav });
+ expect(provider?.rootPath).toBe('/');
+ });
+
+ test('returns null for webdav without settings', async () => {
+ expect(await createFileSyncProvider('webdav', {})).toBeNull();
+ });
+
+ test('delegates gdrive to buildGoogleDriveProvider', async () => {
+ const fake = { rootPath: '/' } as unknown as FileSyncProvider;
+ vi.mocked(buildGoogleDriveProvider).mockResolvedValueOnce(fake);
+ expect(await createFileSyncProvider('gdrive', {})).toBe(fake);
+ expect(buildGoogleDriveProvider).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/file/providerSemanticContract.ts b/apps/readest-app/src/__tests__/services/sync/file/providerSemanticContract.ts
new file mode 100644
index 00000000..cadafae2
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/file/providerSemanticContract.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test } from 'vitest';
+import { FileSyncError } from '@/services/sync/file/provider';
+import type { FileSyncProvider } from '@/services/sync/file/provider';
+
+/**
+ * Transport-agnostic semantics every {@link FileSyncProvider} must honor,
+ * regardless of whether the backend is path-addressed (WebDAV — one request per
+ * op) or id-addressed (Drive — several). The original WebDAV conformance suite
+ * baked in wire details (a `content-length` HEAD, a `list` 404 that throws) that
+ * Drive cannot satisfy as-is, so the genuinely shared invariants live here and
+ * each backend supplies a {@link ProviderScenario} that stages its own wire
+ * responses for the abstract situations below.
+ */
+export interface ProviderScenario {
+ makeProvider: () => FileSyncProvider;
+ /** Stage the next op so a read / head / delete sees an absent path. */
+ stageAbsent: () => void;
+ /** Stage the next op so the backend returns an auth failure (HTTP 401). */
+ stageAuthFailure: () => void;
+}
+
+export const runSemanticContract = (name: string, makeScenario: () => ProviderScenario): void => {
+ describe(`${name} — FileSyncProvider semantic contract`, () => {
+ test('readText resolves null for an absent path', async () => {
+ const s = makeScenario();
+ s.stageAbsent();
+ expect(await s.makeProvider().readText('/Readest/x.json')).toBeNull();
+ });
+
+ test('readBinary resolves null for an absent path', async () => {
+ const s = makeScenario();
+ s.stageAbsent();
+ expect(await s.makeProvider().readBinary('/Readest/x.bin')).toBeNull();
+ });
+
+ test('head resolves null for an absent path', async () => {
+ const s = makeScenario();
+ s.stageAbsent();
+ expect(await s.makeProvider().head('/Readest/x')).toBeNull();
+ });
+
+ test('readText maps an auth failure to FileSyncError AUTH_FAILED', async () => {
+ const s = makeScenario();
+ s.stageAuthFailure();
+ const err = await s
+ .makeProvider()
+ .readText('/Readest/x.json')
+ .catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(FileSyncError);
+ expect((err as FileSyncError).code).toBe('AUTH_FAILED');
+ });
+
+ test('list maps an auth failure to FileSyncError AUTH_FAILED', async () => {
+ const s = makeScenario();
+ s.stageAuthFailure();
+ const err = await s
+ .makeProvider()
+ .list('/Readest/books')
+ .catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(FileSyncError);
+ expect((err as FileSyncError).code).toBe('AUTH_FAILED');
+ });
+
+ test('deleteDir treats an absent target as success', async () => {
+ const s = makeScenario();
+ s.stageAbsent();
+ await expect(s.makeProvider().deleteDir('/Readest/books/gone')).resolves.toBeUndefined();
+ });
+ });
+};
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProvider.test.ts
new file mode 100644
index 00000000..49472c2a
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/GoogleDriveProvider.test.ts
@@ -0,0 +1,191 @@
+import { describe, expect, test, vi } from 'vitest';
+import {
+ createGoogleDriveProvider,
+ type DriveAuth,
+ type FetchFn,
+} from '@/services/sync/providers/gdrive/GoogleDriveProvider';
+import { FileSyncError } from '@/services/sync/file/provider';
+import { runSemanticContract } from '@/__tests__/services/sync/file/providerSemanticContract';
+
+const auth: DriveAuth = { getAccessToken: async () => 'TOKEN' };
+
+const json = (body: unknown, status = 200): Response =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+const text = (body: string, status = 200): Response => new Response(body, { status });
+const folder = (id: string) => ({ id, mimeType: 'application/vnd.google-apps.folder' });
+
+interface Harness {
+ provider: ReturnType;
+ fetchMock: ReturnType;
+ sleep: ReturnType;
+ /** URL of the n-th fetch call (0-based). */
+ url: (n: number) => string;
+ /** Method of the n-th fetch call (0-based). */
+ method: (n: number) => string | undefined;
+}
+
+const makeDrive = (): Harness => {
+ const fetchMock = vi.fn();
+ const sleep = vi.fn(async () => {});
+ const provider = createGoogleDriveProvider(auth, fetchMock as unknown as FetchFn, {
+ sleep: sleep as unknown as (ms: number) => Promise,
+ });
+ return {
+ provider,
+ fetchMock,
+ sleep,
+ url: (n) => fetchMock.mock.calls[n]?.[0] as string,
+ method: (n) => (fetchMock.mock.calls[n]?.[1] as RequestInit | undefined)?.method,
+ };
+};
+
+// Drive must satisfy the same semantics as WebDAV; it just stages them over its
+// id-addressed wire (an absent path = an empty `files.list`, not a 404 body).
+runSemanticContract('GoogleDriveProvider', () => {
+ const fetchMock = vi.fn();
+ return {
+ makeProvider: () =>
+ createGoogleDriveProvider(auth, fetchMock as unknown as FetchFn, { sleep: async () => {} }),
+ stageAbsent: () => fetchMock.mockResolvedValueOnce(json({ files: [] })),
+ stageAuthFailure: () => fetchMock.mockResolvedValueOnce(json({}, 401)),
+ };
+});
+
+describe('GoogleDriveProvider — Drive transport', () => {
+ test('readText resolves the path segment-by-segment then downloads, and caches ids', async () => {
+ const h = makeDrive();
+ h.fetchMock
+ .mockResolvedValueOnce(json({ files: [folder('RID')] })) // findChild('Readest')
+ .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] })) // findChild('x.json')
+ .mockResolvedValueOnce(text('HELLO')); // media download
+ expect(await h.provider.readText('/Readest/x.json')).toBe('HELLO');
+ expect(h.fetchMock).toHaveBeenCalledTimes(3);
+ expect(h.url(2)).toContain('/XID?alt=media');
+ expect(new URL(h.url(0)).searchParams.get('q')).toContain("name = 'Readest'");
+
+ // A second read hits the cached file id — only the media GET fires.
+ h.fetchMock.mockResolvedValueOnce(text('HELLO AGAIN'));
+ expect(await h.provider.readText('/Readest/x.json')).toBe('HELLO AGAIN');
+ expect(h.fetchMock).toHaveBeenCalledTimes(4);
+ });
+
+ test('writeText uploads via create-then-name and auto-creates the parent folder', async () => {
+ const h = makeDrive();
+ h.fetchMock
+ .mockResolvedValueOnce(json({ files: [] })) // findChild('Readest') — missing
+ .mockResolvedValueOnce(json({ id: 'RID' })) // createFolder('Readest')
+ .mockResolvedValueOnce(json({ files: [folder('RID')] })) // re-query winner
+ .mockResolvedValueOnce(json({ files: [] })) // findChild('new.json') — not exists
+ .mockResolvedValueOnce(json({ id: 'NID' })) // POST upload
+ .mockResolvedValueOnce(json({ id: 'NID' })); // PATCH name + reparent
+ await h.provider.writeText('/Readest/new.json', '{"a":1}');
+ expect(h.fetchMock).toHaveBeenCalledTimes(6);
+ expect(h.method(1)).toBe('POST'); // create folder
+ expect(h.url(4)).toContain('uploadType=media');
+ expect(h.method(4)).toBe('POST'); // create file bytes
+ expect(h.url(5)).toContain('addParents=RID');
+ expect(h.method(5)).toBe('PATCH'); // name + reparent
+ });
+
+ test('list drains every nextPageToken page', async () => {
+ const h = makeDrive();
+ h.fetchMock
+ .mockResolvedValueOnce(json({ files: [folder('RID')] })) // findChild('Readest')
+ .mockResolvedValueOnce(
+ json({
+ files: [
+ { id: 'A', name: 'a.json' },
+ { id: 'B', name: 'b.json' },
+ ],
+ nextPageToken: 'T2',
+ }),
+ )
+ .mockResolvedValueOnce(json({ files: [{ id: 'C', name: 'c.json' }] }));
+ const entries = await h.provider.list('/Readest');
+ expect(entries.map((e) => e.name)).toEqual(['a.json', 'b.json', 'c.json']);
+ expect(entries[0]).toMatchObject({ path: '/Readest/a.json', isDirectory: false });
+ expect(h.url(2)).toContain('pageToken=T2');
+ });
+
+ test('head returns the byte size and the md5 etag', async () => {
+ const h = makeDrive();
+ h.fetchMock
+ .mockResolvedValueOnce(json({ files: [folder('RID')] }))
+ .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] }))
+ .mockResolvedValueOnce(json({ id: 'XID', name: 'x.json', size: '1234', md5Checksum: 'abc' }));
+ expect(await h.provider.head('/Readest/x.json')).toEqual({ size: 1234, etag: 'abc' });
+ });
+
+ test('deleteDir resolves the folder id and DELETEs it', async () => {
+ const h = makeDrive();
+ h.fetchMock
+ .mockResolvedValueOnce(json({ files: [folder('RID')] })) // Readest
+ .mockResolvedValueOnce(json({ files: [folder('BID')] })) // books
+ .mockResolvedValueOnce(json({ files: [folder('GID')] })) // gone
+ .mockResolvedValueOnce(new Response(null, { status: 204 })); // DELETE
+ await expect(h.provider.deleteDir('/Readest/books/gone')).resolves.toBeUndefined();
+ expect(h.method(3)).toBe('DELETE');
+ expect(h.url(3)).toContain('/GID');
+ });
+
+ test('retries a 429 with backoff and then succeeds', async () => {
+ const h = makeDrive();
+ h.fetchMock
+ .mockResolvedValueOnce(new Response('', { status: 429 })) // findChild('Readest') throttled
+ .mockResolvedValueOnce(json({ files: [folder('RID')] })) // retry succeeds
+ .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] }))
+ .mockResolvedValueOnce(text('OK'));
+ expect(await h.provider.readText('/Readest/x.json')).toBe('OK');
+ expect(h.sleep).toHaveBeenCalledTimes(1);
+ expect(h.fetchMock).toHaveBeenCalledTimes(4);
+ });
+
+ test('classifies a 403 rate-limit as NETWORK and a 403 permission error as AUTH_FAILED', async () => {
+ const rate = makeDrive();
+ rate.fetchMock.mockResolvedValueOnce(
+ json({ error: { errors: [{ reason: 'userRateLimitExceeded' }] } }, 403),
+ );
+ const rateErr = await rate.provider.readText('/Readest/x.json').catch((e: unknown) => e);
+ expect(rateErr).toBeInstanceOf(FileSyncError);
+ expect((rateErr as FileSyncError).code).toBe('NETWORK');
+
+ const perm = makeDrive();
+ perm.fetchMock.mockResolvedValueOnce(
+ json({ error: { errors: [{ reason: 'insufficientPermissions' }] } }, 403),
+ );
+ const permErr = await perm.provider.readText('/Readest/x.json').catch((e: unknown) => e);
+ expect((permErr as FileSyncError).code).toBe('AUTH_FAILED');
+ });
+
+ test('findChild picks the lexicographically smallest id when duplicates exist', async () => {
+ const h = makeDrive();
+ // Two folders both named "Readest" (a create race) — resolution must converge
+ // on the same one for every caller, so the smaller id wins deterministically.
+ h.fetchMock
+ .mockResolvedValueOnce(json({ files: [folder('B'), folder('A')] }))
+ .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] }))
+ .mockResolvedValueOnce(text('DATA'));
+ await h.provider.readText('/Readest/x.json');
+ // The child lookup under "Readest" must query parent 'A', not 'B'.
+ expect(new URL(h.url(1)).searchParams.get('q')).toContain("'A' in parents");
+ });
+
+ test('evicts a stale cached id on a 404 and re-resolves once', async () => {
+ const h = makeDrive();
+ // Warm the cache.
+ h.fetchMock
+ .mockResolvedValueOnce(json({ files: [folder('RID')] }))
+ .mockResolvedValueOnce(json({ files: [{ id: 'XID' }] }))
+ .mockResolvedValueOnce(text('FIRST'));
+ expect(await h.provider.readText('/Readest/x.json')).toBe('FIRST');
+
+ // Second read: cached XID now 404s (deleted + recreated remotely). The
+ // provider evicts, re-resolves the path fresh, and reads the new id.
+ h.fetchMock
+ .mockResolvedValueOnce(new Response(null, { status: 404 })) // media GET on stale XID
+ .mockResolvedValueOnce(json({ files: [folder('RID2')] })) // re-resolve Readest
+ .mockResolvedValueOnce(json({ files: [{ id: 'XID2' }] })) // re-resolve x.json
+ .mockResolvedValueOnce(text('SECOND')); // media GET on XID2
+ expect(await h.provider.readText('/Readest/x.json')).toBe('SECOND');
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/PersistedDriveAuth.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/PersistedDriveAuth.test.ts
new file mode 100644
index 00000000..3eb67284
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/PersistedDriveAuth.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, test, vi } from 'vitest';
+import { PersistedDriveAuth } from '@/services/sync/providers/gdrive/PersistedDriveAuth';
+import type { FetchFn } from '@/services/sync/providers/gdrive/GoogleDriveProvider';
+import type { TokenPersistence } from '@/services/sync/providers/gdrive/driveTokenStore';
+import type { TokenSet } from '@/services/sync/providers/gdrive/auth/tokenStore';
+
+const json = (body: unknown, status = 200): Response =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+
+const makePersistence = (initial: TokenSet | null = null) => {
+ let stored = initial;
+ return {
+ load: vi.fn(async () => stored),
+ save: vi.fn(async (t: TokenSet) => {
+ stored = t;
+ }),
+ clear: vi.fn(async () => {
+ stored = null;
+ }),
+ } satisfies TokenPersistence & { save: ReturnType };
+};
+
+describe('PersistedDriveAuth', () => {
+ test('returns the seeded access token without any network or load', async () => {
+ const fetchFn = vi.fn();
+ const persistence = makePersistence();
+ const auth = new PersistedDriveAuth({
+ clientId: 'cid',
+ fetchFn: fetchFn as unknown as FetchFn,
+ persistence,
+ initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 1000 },
+ now: () => 500,
+ });
+ expect(await auth.getAccessToken()).toBe('AT');
+ expect(fetchFn).not.toHaveBeenCalled();
+ expect(persistence.load).not.toHaveBeenCalled();
+ });
+
+ test('lazily loads tokens from persistence when not seeded', async () => {
+ const persistence = makePersistence({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 1000 });
+ const auth = new PersistedDriveAuth({
+ clientId: 'cid',
+ fetchFn: vi.fn() as unknown as FetchFn,
+ persistence,
+ now: () => 500,
+ });
+ expect(await auth.getAccessToken()).toBe('AT');
+ expect(persistence.load).toHaveBeenCalledTimes(1);
+ });
+
+ test('refreshes an expired token, carries the old refresh token forward, saves once', async () => {
+ const persistence = makePersistence();
+ // Google omits refresh_token on refresh.
+ const fetchFn = vi.fn(async () => json({ access_token: 'AT2', expires_in: 3600 }));
+ const auth = new PersistedDriveAuth({
+ clientId: 'cid',
+ fetchFn: fetchFn as unknown as FetchFn,
+ persistence,
+ initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 0 },
+ now: () => 1000,
+ });
+ expect(await auth.getAccessToken()).toBe('AT2');
+ expect(fetchFn).toHaveBeenCalledTimes(1);
+ expect(persistence.save).toHaveBeenCalledTimes(1);
+ const saved = persistence.save.mock.calls[0]![0] as TokenSet;
+ expect(saved).toMatchObject({ accessToken: 'AT2', refreshToken: 'RT' });
+ });
+
+ test('collapses concurrent refreshes into a single network call + single save', async () => {
+ let resolveFetch!: (res: Response) => void;
+ const fetchFn = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ );
+ const persistence = makePersistence();
+ const auth = new PersistedDriveAuth({
+ clientId: 'cid',
+ fetchFn: fetchFn as unknown as FetchFn,
+ persistence,
+ initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 0 },
+ now: () => 1000,
+ });
+
+ const p1 = auth.getAccessToken();
+ const p2 = auth.getAccessToken();
+ const p3 = auth.getAccessToken();
+ resolveFetch(json({ access_token: 'AT2', expires_in: 3600 }));
+ expect(await Promise.all([p1, p2, p3])).toEqual(['AT2', 'AT2', 'AT2']);
+ expect(fetchFn).toHaveBeenCalledTimes(1);
+ expect(persistence.save).toHaveBeenCalledTimes(1);
+ });
+
+ test('throws AUTH_FAILED when there are no tokens at all', async () => {
+ const auth = new PersistedDriveAuth({
+ clientId: 'cid',
+ fetchFn: vi.fn() as unknown as FetchFn,
+ persistence: makePersistence(null),
+ });
+ await expect(auth.getAccessToken()).rejects.toMatchObject({ code: 'AUTH_FAILED' });
+ });
+
+ test('throws AUTH_FAILED when expired with no refresh token', async () => {
+ const auth = new PersistedDriveAuth({
+ clientId: 'cid',
+ fetchFn: vi.fn() as unknown as FetchFn,
+ persistence: makePersistence(),
+ initialTokens: { accessToken: 'AT', expiresAt: 0 },
+ now: () => 1000,
+ });
+ await expect(auth.getAccessToken()).rejects.toMatchObject({ code: 'AUTH_FAILED' });
+ });
+
+ test('accountLabel reads the email from about.get', async () => {
+ const fetchFn = vi.fn(async (url: string) => {
+ if (url.includes('/about')) {
+ return json({ user: { emailAddress: 'a@b.com', displayName: 'A B' } });
+ }
+ throw new Error(`unexpected fetch ${url}`);
+ });
+ const auth = new PersistedDriveAuth({
+ clientId: 'cid',
+ fetchFn: fetchFn as unknown as FetchFn,
+ persistence: makePersistence(),
+ initialTokens: { accessToken: 'AT', refreshToken: 'RT', expiresAt: 9999 },
+ now: () => 0,
+ });
+ expect(await auth.accountLabel()).toBe('a@b.com');
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthDesktop.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthDesktop.test.ts
new file mode 100644
index 00000000..ef7576dd
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthDesktop.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, test, vi } from 'vitest';
+import {
+ runDesktopDeepLinkOAuth,
+ type DesktopDeepLinkDeps,
+} from '@/services/sync/providers/gdrive/auth/oauthDesktop';
+
+const CLIENT_ID = 'cid.apps.googleusercontent.com';
+const REDIRECT = 'com.googleusercontent.apps.cid:/oauthredirect';
+
+const tokenJson = (): Response =>
+ new Response(JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+
+const exchangeFetch = () => vi.fn(async () => tokenJson());
+
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const baseDeps = (over: Partial): DesktopDeepLinkDeps => ({
+ openDefaultBrowser: vi.fn(async () => {}),
+ spawnFreshBrowser: vi.fn(async () => {}),
+ subscribeRedirects: vi.fn(async () => () => {}),
+ fallbackDelayMs: 25_000,
+ connectDeadlineMs: 900_000,
+ ...over,
+});
+
+// These tests use real (tiny) timeouts rather than fake timers: the PKCE
+// challenge runs on `crypto.subtle.digest`, which resolves on Node's threadpool
+// and is therefore not flushed by fake timers.
+describe('runDesktopDeepLinkOAuth', () => {
+ test('opens the default browser, captures the routed redirect, and exchanges the code', async () => {
+ let onUrl!: (url: string) => void;
+ const deps = baseDeps({
+ subscribeRedirects: vi.fn(async (cb) => {
+ onUrl = cb;
+ return () => {};
+ }),
+ // Simulate the OS routing the redirect back once consent opens, echoing
+ // the exact `state` the flow generated (read off the consent URL).
+ openDefaultBrowser: vi.fn(async (url) => {
+ const state = new URL(url).searchParams.get('state');
+ queueMicrotask(() => onUrl(`${REDIRECT}?code=CODE&state=${state}`));
+ }),
+ });
+ const fetchFn = exchangeFetch();
+
+ const tokens = await runDesktopDeepLinkOAuth(
+ { clientId: CLIENT_ID, scope: 'drive.file' },
+ fetchFn,
+ deps,
+ );
+
+ expect(deps.openDefaultBrowser).toHaveBeenCalledTimes(1);
+ expect(tokens.accessToken).toBe('AT');
+ expect(tokens.refreshToken).toBe('RT');
+ expect(fetchFn).toHaveBeenCalledTimes(1);
+ // The redirect arrived in time, so the cold-browser fallback never fired.
+ expect(deps.spawnFreshBrowser).not.toHaveBeenCalled();
+ });
+
+ test('fires the cold-browser fallback with the consent URL, then rejects at the deadline', async () => {
+ const spawnFreshBrowser = vi.fn(async (_url: string) => {});
+ const deps = baseDeps({
+ subscribeRedirects: vi.fn(async () => () => {}),
+ spawnFreshBrowser,
+ fallbackDelayMs: 10,
+ connectDeadlineMs: 50,
+ });
+ const result = runDesktopDeepLinkOAuth(
+ { clientId: CLIENT_ID, scope: 'drive.file' },
+ exchangeFetch(),
+ deps,
+ ).catch((e: Error) => e.message);
+
+ await delay(90);
+ expect(spawnFreshBrowser).toHaveBeenCalledTimes(1);
+ expect(spawnFreshBrowser.mock.calls[0]![0]).toContain('accounts.google.com');
+ expect(await result).toMatch(/did not complete in time/);
+ });
+
+ test('rejects when no redirect arrives before the hard deadline', async () => {
+ const deps = baseDeps({
+ subscribeRedirects: vi.fn(async () => () => {}),
+ fallbackDelayMs: 1_000_000,
+ connectDeadlineMs: 20,
+ });
+ await expect(
+ runDesktopDeepLinkOAuth({ clientId: CLIENT_ID, scope: 'drive.file' }, exchangeFetch(), deps),
+ ).rejects.toThrow(/did not complete in time/);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthFlow.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthFlow.test.ts
new file mode 100644
index 00000000..62d01aaa
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthFlow.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test, vi } from 'vitest';
+import { runOAuthFlow, type OAuthFlowDeps } from '@/services/sync/providers/gdrive/auth/oauthFlow';
+
+const REDIRECT_URI = 'com.googleusercontent.apps.cid:/oauthredirect';
+
+const makeDeps = (overrides: Partial, order: string[]): OAuthFlowDeps => ({
+ createPkcePair: async () => ({ verifier: 'VER', challenge: 'CHAL' }),
+ newState: () => 'STATE',
+ clientId: 'cid',
+ redirectUri: REDIRECT_URI,
+ openUrl: async () => {
+ order.push('open');
+ },
+ awaitRedirect: async () => {
+ order.push('await');
+ return `${REDIRECT_URI}?code=CODE&state=STATE`;
+ },
+ exchange: async () => ({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 }),
+ ...overrides,
+});
+
+describe('runOAuthFlow', () => {
+ test('arms the redirect capture before opening, then exchanges the code', async () => {
+ const order: string[] = [];
+ let openedUrl = '';
+ const exchange = vi.fn(async () => ({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 }));
+ const deps = makeDeps(
+ {
+ openUrl: async (url) => {
+ order.push('open');
+ openedUrl = url;
+ },
+ exchange,
+ },
+ order,
+ );
+
+ const tokens = await runOAuthFlow('drive.file', deps);
+
+ // The capture must be armed before the consent URL is opened (redirect race).
+ expect(order).toEqual(['await', 'open']);
+ expect(openedUrl).toContain('code_challenge=CHAL');
+ expect(openedUrl).toContain('state=STATE');
+ expect(exchange).toHaveBeenCalledWith({
+ code: 'CODE',
+ verifier: 'VER',
+ redirectUri: REDIRECT_URI,
+ });
+ expect(tokens).toEqual({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 });
+ });
+
+ test('propagates a CSRF state mismatch from the redirect', async () => {
+ const order: string[] = [];
+ const deps = makeDeps(
+ { awaitRedirect: async () => `${REDIRECT_URI}?code=CODE&state=ATTACKER` },
+ order,
+ );
+ await expect(runOAuthFlow('drive.file', deps)).rejects.toThrow(/state mismatch/i);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/parseRedirect.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/parseRedirect.test.ts
new file mode 100644
index 00000000..c2a2be91
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/parseRedirect.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from 'vitest';
+import { parseRedirect } from '@/services/sync/providers/gdrive/auth/parseRedirect';
+
+const REDIRECT_URI = 'com.googleusercontent.apps.cid:/oauthredirect';
+
+describe('parseRedirect', () => {
+ test('returns the code when target, state and code are all valid', () => {
+ const url = `${REDIRECT_URI}?code=AUTH_CODE&state=STATE`;
+ expect(parseRedirect(url, 'STATE', REDIRECT_URI)).toEqual({ code: 'AUTH_CODE' });
+ });
+
+ test('rejects a URL aimed at a different scheme/path (target guard)', () => {
+ const url = 'readest://auth-callback?code=AUTH_CODE&state=STATE';
+ expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/target mismatch/i);
+ });
+
+ test('rejects a right-scheme but wrong-path redirect', () => {
+ const url = 'com.googleusercontent.apps.cid:/somethingelse?code=AUTH_CODE&state=STATE';
+ expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/target mismatch/i);
+ });
+
+ test('surfaces a provider error param ahead of the CSRF/code checks', () => {
+ const url = `${REDIRECT_URI}?error=access_denied&state=WRONG`;
+ expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/access_denied/);
+ });
+
+ test('rejects a state mismatch (CSRF guard)', () => {
+ const url = `${REDIRECT_URI}?code=AUTH_CODE&state=WRONG`;
+ expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(/state mismatch/i);
+ });
+
+ test('rejects a redirect with no code', () => {
+ const url = `${REDIRECT_URI}?state=STATE`;
+ expect(() => parseRedirect(url, 'STATE', REDIRECT_URI)).toThrow(
+ /missing the authorization code/i,
+ );
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/pkce.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/pkce.test.ts
new file mode 100644
index 00000000..20fdd730
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/pkce.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, test } from 'vitest';
+import {
+ buildAuthUrl,
+ computeChallenge,
+ createPkcePair,
+} from '@/services/sync/providers/gdrive/auth/pkce';
+
+describe('pkce', () => {
+ test('computeChallenge matches the RFC 7636 Appendix B known-answer vector', async () => {
+ // RFC 7636 §B: verifier → S256 challenge. Catches the classic bug of hashing
+ // raw verifier bytes instead of the ASCII octets of the verifier string.
+ const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
+ expect(await computeChallenge(verifier)).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
+ });
+
+ test('createPkcePair yields a base64url verifier and its derived challenge', async () => {
+ const { verifier, challenge } = await createPkcePair();
+ // base64url alphabet only (no +, /, or =), and within the RFC length window.
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
+ expect(verifier.length).toBeGreaterThanOrEqual(43);
+ expect(verifier.length).toBeLessThanOrEqual(128);
+ expect(challenge).toBe(await computeChallenge(verifier));
+ });
+
+ test('buildAuthUrl sets the PKCE + offline-consent query parameters', () => {
+ const url = new URL(
+ buildAuthUrl({
+ clientId: 'cid',
+ redirectUri: 'com.googleusercontent.apps.cid:/oauthredirect',
+ scope: 'https://www.googleapis.com/auth/drive.file',
+ challenge: 'CHALLENGE',
+ state: 'STATE',
+ }),
+ );
+ expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
+ const p = url.searchParams;
+ expect(p.get('client_id')).toBe('cid');
+ expect(p.get('redirect_uri')).toBe('com.googleusercontent.apps.cid:/oauthredirect');
+ expect(p.get('response_type')).toBe('code');
+ expect(p.get('scope')).toBe('https://www.googleapis.com/auth/drive.file');
+ expect(p.get('code_challenge')).toBe('CHALLENGE');
+ expect(p.get('code_challenge_method')).toBe('S256');
+ expect(p.get('state')).toBe('STATE');
+ expect(p.get('access_type')).toBe('offline');
+ expect(p.get('prompt')).toBe('consent');
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/reverseDnsRedirect.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/reverseDnsRedirect.test.ts
new file mode 100644
index 00000000..a024dba5
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/reverseDnsRedirect.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, test } from 'vitest';
+import {
+ deriveReverseDnsRedirectScheme,
+ deriveReverseDnsRedirectUri,
+ isGoogleOAuthRedirectUrl,
+ matchesReverseDnsRedirect,
+} from '@/services/sync/providers/gdrive/auth/reverseDnsRedirect';
+
+const CLIENT_ID = '123456789-AbCdEf.apps.googleusercontent.com';
+
+describe('reverseDnsRedirect', () => {
+ test('derives the scheme by stripping the googleusercontent suffix and lowercasing', () => {
+ expect(deriveReverseDnsRedirectScheme(CLIENT_ID)).toBe(
+ 'com.googleusercontent.apps.123456789-abcdef',
+ );
+ });
+
+ test('derives the full redirect URI with a single-slash path', () => {
+ expect(deriveReverseDnsRedirectUri(CLIENT_ID)).toBe(
+ 'com.googleusercontent.apps.123456789-abcdef:/oauthredirect',
+ );
+ });
+
+ test('tolerates a client id that is already just the identifier part', () => {
+ expect(deriveReverseDnsRedirectScheme('rawid')).toBe('com.googleusercontent.apps.rawid');
+ });
+
+ test('matches a redirect URL case-insensitively, scheme-anchored', () => {
+ const scheme = deriveReverseDnsRedirectScheme(CLIENT_ID);
+ expect(matchesReverseDnsRedirect(`${scheme}:/oauthredirect?code=x&state=y`, scheme)).toBe(true);
+ // Windows may relaunch with a differently-cased scheme in argv.
+ expect(matchesReverseDnsRedirect(`${scheme.toUpperCase()}:/oauthredirect`, scheme)).toBe(true);
+ });
+
+ test('does not match a different scheme or a prefix scheme', () => {
+ const scheme = deriveReverseDnsRedirectScheme(CLIENT_ID);
+ expect(matchesReverseDnsRedirect('readest://auth-callback', scheme)).toBe(false);
+ // A scheme that is a strict prefix of a longer one must not false-match.
+ expect(matchesReverseDnsRedirect(`${scheme}extra:/x`, scheme)).toBe(false);
+ });
+
+ test('isGoogleOAuthRedirectUrl flags any Google reverse-DNS redirect for the ingress filter', () => {
+ expect(
+ isGoogleOAuthRedirectUrl('com.googleusercontent.apps.ANY-id:/oauthredirect?code=x'),
+ ).toBe(true);
+ expect(isGoogleOAuthRedirectUrl(deriveReverseDnsRedirectUri(CLIENT_ID))).toBe(true);
+ // Real app/book URLs must pass through to the consumers untouched.
+ expect(isGoogleOAuthRedirectUrl('readest://auth-callback')).toBe(false);
+ expect(isGoogleOAuthRedirectUrl('file:///Users/me/book.epub')).toBe(false);
+ expect(isGoogleOAuthRedirectUrl('https://web.readest.com/s/abc')).toBe(false);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/tokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/tokenStore.test.ts
new file mode 100644
index 00000000..c38c5625
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/tokenStore.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { exchangeCode, refreshAccessToken } from '@/services/sync/providers/gdrive/auth/tokenStore';
+
+const jsonResponse = (body: unknown, status = 200): Response =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+
+describe('tokenStore', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(0);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test('exchangeCode posts the PKCE form (no client secret) and resolves a TokenSet', async () => {
+ let captured: { url: string; init: RequestInit } | undefined;
+ const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
+ captured = { url, init };
+ return jsonResponse({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 });
+ });
+ const tokens = await exchangeCode(
+ { code: 'C', verifier: 'V', clientId: 'cid', redirectUri: 'R' },
+ fetchFn,
+ );
+ expect(captured?.url).toBe('https://oauth2.googleapis.com/token');
+ const form = new URLSearchParams(captured?.init.body as string);
+ expect(form.get('grant_type')).toBe('authorization_code');
+ expect(form.get('code')).toBe('C');
+ expect(form.get('code_verifier')).toBe('V');
+ expect(form.get('client_id')).toBe('cid');
+ expect(form.get('redirect_uri')).toBe('R');
+ expect(form.has('client_secret')).toBe(false);
+ // Date.now() pinned to 0, margin 60s, so expiry = (3600 - 60) * 1000.
+ expect(tokens).toEqual({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 3540 * 1000 });
+ });
+
+ test('refreshAccessToken posts a refresh grant with no redirect_uri or secret', async () => {
+ let captured: { init: RequestInit } | undefined;
+ const fetchFn = vi.fn(async (_url: string, init: RequestInit) => {
+ captured = { init };
+ return jsonResponse({ access_token: 'AT2', expires_in: 3600 });
+ });
+ const tokens = await refreshAccessToken({ refreshToken: 'RT', clientId: 'cid' }, fetchFn);
+ const form = new URLSearchParams(captured?.init.body as string);
+ expect(form.get('grant_type')).toBe('refresh_token');
+ expect(form.get('refresh_token')).toBe('RT');
+ expect(form.get('client_id')).toBe('cid');
+ expect(form.has('client_secret')).toBe(false);
+ // Google omits the refresh token on refresh; the caller keeps the old one.
+ expect(tokens.refreshToken).toBeUndefined();
+ });
+
+ test('clamps expiry so a token shorter than the margin is not already-expired', async () => {
+ const fetchFn = async () => jsonResponse({ access_token: 'AT', expires_in: 30 });
+ const tokens = await exchangeCode(
+ { code: 'C', verifier: 'V', clientId: 'cid', redirectUri: 'R' },
+ fetchFn,
+ );
+ expect(tokens.expiresAt).toBe(0);
+ });
+
+ test('throws with the Google error detail on a non-2xx response', async () => {
+ const fetchFn = async () =>
+ jsonResponse({ error: 'invalid_grant', error_description: 'bad code' }, 400);
+ await expect(
+ exchangeCode({ code: 'C', verifier: 'V', clientId: 'cid', redirectUri: 'R' }, fetchFn),
+ ).rejects.toThrow(/HTTP 400: invalid_grant — bad code/);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts
new file mode 100644
index 00000000..a16c7a2f
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
+vi.mock('@/services/environment', () => ({ isTauriAppPlatform: vi.fn() }));
+vi.mock('@/utils/bridge', () => ({
+ isSyncKeychainAvailable: vi.fn(),
+ getSecureItem: vi.fn(),
+ setSecureItem: vi.fn(),
+ clearSecureItem: vi.fn(),
+}));
+
+import { isTauriAppPlatform } from '@/services/environment';
+import { isSyncKeychainAvailable } from '@/utils/bridge';
+import {
+ buildGoogleDriveProvider,
+ getGoogleClientId,
+} from '@/services/sync/providers/gdrive/buildGoogleDriveProvider';
+
+const CLIENT_ID = 'cid.apps.googleusercontent.com';
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.clearAllMocks();
+});
+
+describe('buildGoogleDriveProvider', () => {
+ test('falls back to the baked official client id when the env override is unset', async () => {
+ vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', '');
+ expect(getGoogleClientId()).toMatch(/\.apps\.googleusercontent\.com$/);
+ // With a baked default + keychain, Drive builds even without an env override.
+ vi.mocked(isTauriAppPlatform).mockReturnValue(true);
+ vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: true });
+ expect(await buildGoogleDriveProvider()).not.toBeNull();
+ });
+
+ test('the env override wins over the baked default', () => {
+ vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', 'forked.apps.googleusercontent.com');
+ expect(getGoogleClientId()).toBe('forked.apps.googleusercontent.com');
+ });
+
+ test('returns null off-Tauri (no secure token storage for the refresh token)', async () => {
+ vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', CLIENT_ID);
+ vi.mocked(isTauriAppPlatform).mockReturnValue(false);
+ expect(await buildGoogleDriveProvider()).toBeNull();
+ expect(isSyncKeychainAvailable).not.toHaveBeenCalled();
+ });
+
+ test('returns null when the keychain is unavailable', async () => {
+ vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', CLIENT_ID);
+ vi.mocked(isTauriAppPlatform).mockReturnValue(true);
+ vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: false });
+ expect(await buildGoogleDriveProvider()).toBeNull();
+ });
+
+ test('builds a provider when client id + keychain are available', async () => {
+ vi.stubEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID', CLIENT_ID);
+ vi.mocked(isTauriAppPlatform).mockReturnValue(true);
+ vi.mocked(isSyncKeychainAvailable).mockResolvedValue({ available: true });
+ const provider = await buildGoogleDriveProvider();
+ expect(provider).not.toBeNull();
+ expect(provider?.rootPath).toBe('/');
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/connectGoogleDrive.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/connectGoogleDrive.test.ts
new file mode 100644
index 00000000..5d7be067
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/connectGoogleDrive.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, test, vi } from 'vitest';
+import {
+ connectGoogleDrive,
+ disconnectGoogleDrive,
+ DRIVE_FILE_SCOPE,
+} from '@/services/sync/providers/gdrive/connectGoogleDrive';
+import { FileSyncError } from '@/services/sync/file/provider';
+import type { FetchFn } from '@/services/sync/providers/gdrive/GoogleDriveProvider';
+import type { TokenPersistence } from '@/services/sync/providers/gdrive/driveTokenStore';
+import type { TokenSet } from '@/services/sync/providers/gdrive/auth/tokenStore';
+
+const tokens: TokenSet = { accessToken: 'AT', refreshToken: 'RT', expiresAt: 9_999_999_999_999 };
+
+const json = (body: unknown, status = 200): Response =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+
+const makePersistence = (): TokenPersistence & {
+ save: ReturnType;
+ clear: ReturnType;
+} => ({
+ load: vi.fn(async () => null),
+ save: vi.fn(async () => {}),
+ clear: vi.fn(async () => {}),
+});
+
+describe('connectGoogleDrive', () => {
+ test('runs OAuth with the drive.file scope, saves the token, returns the account label', async () => {
+ const persistence = makePersistence();
+ const runOAuth = vi.fn(async () => tokens);
+ const fetchFn = vi.fn(async (url: string) =>
+ url.includes('/about')
+ ? json({ user: { emailAddress: 'a@b.com' } })
+ : new Response('', { status: 404 }),
+ );
+
+ const res = await connectGoogleDrive({
+ clientId: 'cid',
+ fetchFn: fetchFn as unknown as FetchFn,
+ persistence,
+ runOAuth,
+ });
+
+ expect(runOAuth).toHaveBeenCalledWith({ clientId: 'cid', scope: DRIVE_FILE_SCOPE }, fetchFn);
+ expect(persistence.save).toHaveBeenCalledWith(tokens);
+ expect(res.accountLabel).toBe('a@b.com');
+ });
+
+ test('does NOT report connected when the token cannot be persisted', async () => {
+ const persistence = makePersistence();
+ persistence.save.mockRejectedValueOnce(new FileSyncError('keychain denied', 'AUTH_FAILED'));
+ await expect(
+ connectGoogleDrive({
+ clientId: 'cid',
+ fetchFn: vi.fn() as unknown as FetchFn,
+ persistence,
+ runOAuth: async () => tokens,
+ }),
+ ).rejects.toThrow(/keychain denied/);
+ });
+
+ test('account label is best-effort: null when about.get fails, token still saved', async () => {
+ const persistence = makePersistence();
+ const fetchFn = vi.fn(async () => new Response('', { status: 500 }));
+ const res = await connectGoogleDrive({
+ clientId: 'cid',
+ fetchFn: fetchFn as unknown as FetchFn,
+ persistence,
+ runOAuth: async () => tokens,
+ });
+ expect(res.accountLabel).toBeNull();
+ expect(persistence.save).toHaveBeenCalledWith(tokens);
+ });
+});
+
+describe('disconnectGoogleDrive', () => {
+ test('clears the stored token', async () => {
+ const persistence = makePersistence();
+ await disconnectGoogleDrive(persistence);
+ expect(persistence.clear).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveRest.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveRest.test.ts
new file mode 100644
index 00000000..e084aee4
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveRest.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, test } from 'vitest';
+import {
+ aboutUrl,
+ childrenQuery,
+ deleteUrl,
+ escapeDriveLiteral,
+ FILES_ENDPOINT,
+ listQuery,
+ listUrl,
+ mediaDownloadUrl,
+ mediaUpdateUrl,
+ metadataUrl,
+ reparentUrl,
+ simpleUploadUrl,
+} from '@/services/sync/providers/gdrive/driveRest';
+
+describe('driveRest', () => {
+ test('escapes single quotes in query literals', () => {
+ expect(escapeDriveLiteral("O'Brien")).toBe("O\\'Brien");
+ });
+
+ test('escapes backslashes (first) so they cannot break out of the literal', () => {
+ // A lone backslash is doubled.
+ expect(escapeDriveLiteral('a\\b')).toBe('a\\\\b');
+ // A trailing backslash would otherwise escape the closing quote.
+ expect(escapeDriveLiteral('dir\\')).toBe('dir\\\\');
+ // Backslash-then-quote: backslash doubled, then the quote escaped — never
+ // \' (which Drive would read as an escaped quote from the original input).
+ expect(escapeDriveLiteral("a\\'b")).toBe("a\\\\\\'b");
+ });
+
+ test('listQuery scopes by name + parent + not-trashed', () => {
+ expect(listQuery('config.json', 'PARENT')).toBe(
+ "name = 'config.json' and 'PARENT' in parents and trashed = false",
+ );
+ });
+
+ test('childrenQuery enumerates live children of a parent', () => {
+ expect(childrenQuery('PARENT')).toBe("'PARENT' in parents and trashed = false");
+ });
+
+ test('mediaDownloadUrl uses alt=media', () => {
+ expect(mediaDownloadUrl('FID')).toBe(`${FILES_ENDPOINT}/FID?alt=media`);
+ });
+
+ test('upload URLs use uploadType=media and request id/md5/size', () => {
+ expect(simpleUploadUrl()).toContain('uploadType=media');
+ expect(simpleUploadUrl()).toContain('fields=id,md5Checksum,size');
+ expect(mediaUpdateUrl('FID')).toContain('/FID?uploadType=media');
+ });
+
+ test('metadataUrl + deleteUrl target the file id', () => {
+ expect(metadataUrl('FID')).toBe(
+ `${FILES_ENDPOINT}/FID?fields=id,name,mimeType,size,modifiedTime,md5Checksum`,
+ );
+ expect(deleteUrl('FID')).toBe(`${FILES_ENDPOINT}/FID`);
+ });
+
+ test('reparentUrl moves a file from one parent to another via query params', () => {
+ const url = new URL(reparentUrl('FID', 'NEWPARENT', 'root'));
+ expect(url.pathname.endsWith('/FID')).toBe(true);
+ expect(url.searchParams.get('addParents')).toBe('NEWPARENT');
+ expect(url.searchParams.get('removeParents')).toBe('root');
+ });
+
+ test('aboutUrl requests only the user identity fields', () => {
+ expect(aboutUrl()).toBe(
+ 'https://www.googleapis.com/drive/v3/about?fields=user(displayName,emailAddress)',
+ );
+ });
+
+ test('listUrl requests nextPageToken + a page size and threads a page token', () => {
+ const first = new URL(listUrl(childrenQuery('PARENT')));
+ expect(first.searchParams.get('q')).toBe("'PARENT' in parents and trashed = false");
+ expect(first.searchParams.get('fields')).toBe(
+ 'nextPageToken,files(id,name,mimeType,size,modifiedTime,md5Checksum)',
+ );
+ expect(first.searchParams.get('pageSize')).toBe('1000');
+ expect(first.searchParams.get('pageToken')).toBeNull();
+
+ const next = new URL(listUrl(childrenQuery('PARENT'), 'TOKEN2'));
+ expect(next.searchParams.get('pageToken')).toBe('TOKEN2');
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveTokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveTokenStore.test.ts
new file mode 100644
index 00000000..401e77f2
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/driveTokenStore.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+vi.mock('@/utils/bridge', () => ({
+ getSecureItem: vi.fn(),
+ setSecureItem: vi.fn(),
+ clearSecureItem: vi.fn(),
+ isSyncKeychainAvailable: vi.fn(),
+}));
+vi.mock('@/services/environment', () => ({ isTauriAppPlatform: vi.fn() }));
+
+import {
+ clearSecureItem,
+ getSecureItem,
+ isSyncKeychainAvailable,
+ setSecureItem,
+} from '@/utils/bridge';
+import { isTauriAppPlatform } from '@/services/environment';
+import { FileSyncError } from '@/services/sync/file/provider';
+import {
+ createDriveTokenPersistence,
+ DRIVE_TOKEN_KEY,
+ KeychainTokenPersistence,
+} from '@/services/sync/providers/gdrive/driveTokenStore';
+
+const tokens = { accessToken: 'AT', refreshToken: 'RT', expiresAt: 123 };
+
+afterEach(() => vi.clearAllMocks());
+
+describe('KeychainTokenPersistence', () => {
+ test('save serialises through the keyed secure-KV and fails loud on rejection', async () => {
+ vi.mocked(setSecureItem).mockResolvedValueOnce({ success: true });
+ await new KeychainTokenPersistence().save(tokens);
+ expect(setSecureItem).toHaveBeenCalledWith({
+ key: DRIVE_TOKEN_KEY,
+ value: JSON.stringify(tokens),
+ });
+
+ vi.mocked(setSecureItem).mockResolvedValueOnce({ success: false, error: 'denied' });
+ await expect(new KeychainTokenPersistence().save(tokens)).rejects.toBeInstanceOf(FileSyncError);
+ });
+
+ test('load parses a stored token set; returns null when absent or on error', async () => {
+ vi.mocked(getSecureItem).mockResolvedValueOnce({ value: JSON.stringify(tokens) });
+ expect(await new KeychainTokenPersistence().load()).toEqual(tokens);
+
+ vi.mocked(getSecureItem).mockResolvedValueOnce({ error: 'no item' });
+ expect(await new KeychainTokenPersistence().load()).toBeNull();
+
+ vi.mocked(getSecureItem).mockResolvedValueOnce({});
+ expect(await new KeychainTokenPersistence().load()).toBeNull();
+ });
+
+ test('clear delegates to the keyed secure-KV', async () => {
+ vi.mocked(clearSecureItem).mockResolvedValueOnce({ success: true });
+ await new KeychainTokenPersistence().clear();
+ expect(clearSecureItem).toHaveBeenCalledWith({ key: DRIVE_TOKEN_KEY });
+ });
+});
+
+describe('createDriveTokenPersistence', () => {
+ test('returns null off-Tauri (no ephemeral fallback for the refresh token)', async () => {
+ vi.mocked(isTauriAppPlatform).mockReturnValue(false);
+ expect(await createDriveTokenPersistence()).toBeNull();
+ expect(isSyncKeychainAvailable).not.toHaveBeenCalled();
+ });
+
+ test('returns a keychain store when the probe reports available', async () => {
+ vi.mocked(isTauriAppPlatform).mockReturnValue(true);
+ vi.mocked(isSyncKeychainAvailable).mockResolvedValueOnce({ available: true });
+ expect(await createDriveTokenPersistence()).toBeInstanceOf(KeychainTokenPersistence);
+ });
+
+ test('returns null when the keychain is unavailable', async () => {
+ vi.mocked(isTauriAppPlatform).mockReturnValue(true);
+ vi.mocked(isSyncKeychainAvailable).mockResolvedValueOnce({ available: false });
+ expect(await createDriveTokenPersistence()).toBeNull();
+ });
+});
diff --git a/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts b/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts
new file mode 100644
index 00000000..ded19d85
--- /dev/null
+++ b/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts
@@ -0,0 +1,47 @@
+import { beforeEach, describe, expect, test } from 'vitest';
+import { useFileSyncStore } from '@/store/fileSyncStore';
+
+const reset = () => useFileSyncStore.setState({ byKind: {}, activeKind: null });
+
+describe('fileSyncStore', () => {
+ beforeEach(reset);
+
+ test('beginSync acquires the mutex and marks the backend syncing', () => {
+ const { beginSync } = useFileSyncStore.getState();
+ expect(beginSync('webdav', 'Syncing 0 / 3')).toBe(true);
+ const s = useFileSyncStore.getState();
+ expect(s.activeKind).toBe('webdav');
+ expect(s.byKind.webdav?.isSyncing).toBe(true);
+ expect(s.byKind.webdav?.progressLabel).toBe('Syncing 0 / 3');
+ });
+
+ test('a second backend cannot begin while another holds the lock', () => {
+ const { beginSync } = useFileSyncStore.getState();
+ expect(beginSync('webdav', 'a')).toBe(true);
+ // Drive must not start a library sync while WebDAV is mid-run.
+ expect(beginSync('gdrive', 'b')).toBe(false);
+ expect(useFileSyncStore.getState().byKind.gdrive).toBeUndefined();
+ expect(useFileSyncStore.getState().activeKind).toBe('webdav');
+ });
+
+ test('endSync releases the lock and resets that backend to idle', () => {
+ const { beginSync, endSync } = useFileSyncStore.getState();
+ beginSync('webdav', 'a');
+ endSync('webdav');
+ const s = useFileSyncStore.getState();
+ expect(s.activeKind).toBeNull();
+ expect(s.byKind.webdav?.isSyncing).toBe(false);
+ // Lock is free again for any backend.
+ expect(s.beginSync('gdrive', 'c')).toBe(true);
+ expect(useFileSyncStore.getState().activeKind).toBe('gdrive');
+ });
+
+ test('updateProgress sets the label + detail for the active backend', () => {
+ const { beginSync, updateProgress } = useFileSyncStore.getState();
+ beginSync('webdav', 'start');
+ updateProgress('webdav', 'Uploading 2 / 3', 'Project Hail Mary');
+ const p = useFileSyncStore.getState().byKind.webdav;
+ expect(p?.progressLabel).toBe('Uploading 2 / 3');
+ expect(p?.progressDetail).toBe('Project Hail Mary');
+ });
+});
diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
index 1062a28b..e6cf550b 100644
--- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
+++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
@@ -25,7 +25,7 @@ import { useAutoFocus } from '@/hooks/useAutoFocus';
import { useTranslation } from '@/hooks/useTranslation';
import { useEinkMode } from '@/hooks/useEinkMode';
import { useKOSync } from '../hooks/useKOSync';
-import { useWebDAVSync } from '../hooks/useWebDAVSync';
+import { useFileSync } from '../hooks/useFileSync';
import {
applyFixedlayoutStyles,
applyImageStyle,
@@ -148,7 +148,7 @@ const FoliateViewer: React.FC<{
useProgressAutoSave(bookKey);
useBookCoverAutoSave(bookKey);
const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey);
- useWebDAVSync(bookKey);
+ useFileSync(bookKey);
useTextTranslation(bookKey, viewRef.current);
// Coalesce setProgress writes within a single animation frame.
diff --git a/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts b/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts
index 304aa314..73610973 100644
--- a/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts
+++ b/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts
@@ -111,7 +111,7 @@ async function tryWriteFullCoverFromBook(
* we keep the user-supplied path on the Book record and must resolve
* against it (with base `None`) rather than the synthetic
* `Books//.` path `getLocalBookFilename` builds.
- * Mirrors the same in-place handling in `useWebDAVSync.pushBookFileNow`
+ * Mirrors the same in-place handling in `useFileSync.pushBookFileNow`
* and `cloudService.uploadBook`.
*/
filePath?: string;
diff --git a/apps/readest-app/src/app/reader/hooks/useFileSync.ts b/apps/readest-app/src/app/reader/hooks/useFileSync.ts
new file mode 100644
index 00000000..6685bbe3
--- /dev/null
+++ b/apps/readest-app/src/app/reader/hooks/useFileSync.ts
@@ -0,0 +1,479 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { v4 as uuidv4 } from 'uuid';
+import { useEnv } from '@/context/EnvContext';
+import { useBookDataStore } from '@/store/bookDataStore';
+import { useReaderStore } from '@/store/readerStore';
+import { useBookProgress } from '@/store/readerProgressStore';
+import { useSettingsStore } from '@/store/settingsStore';
+import { useTranslation } from '@/hooks/useTranslation';
+import { useQuotaStats } from '@/hooks/useQuotaStats';
+import { isCloudSyncInPlan } from '@/utils/access';
+import { debounce } from '@/utils/debounce';
+import { eventDispatcher } from '@/utils/event';
+import { FileSyncEngine } from '@/services/sync/file/engine';
+import { FileSyncError } from '@/services/sync/file/provider';
+import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
+import {
+ createFileSyncProvider,
+ type FileSyncBackendKind,
+} from '@/services/sync/file/providerRegistry';
+import { removeBookNoteOverlays } from '../utils/annotatorUtil';
+import { useWindowActiveChanged } from './useWindowActiveChanged';
+
+/**
+ * Per-book file-sync hook for the active third-party cloud provider.
+ *
+ * The third-party cloud providers (WebDAV, Google Drive) are mutually exclusive
+ * — only one is enabled at a time (see the Cloud Sync settings page) — so this
+ * hook drives exactly that one active backend, built through the provider
+ * registry. Same architecture as `useKOSync` / `useProgressSync`: pull-once on
+ * book open, debounced push on progress / booknote changes, manual flush on the
+ * `flush-file-sync` event.
+ *
+ * Energy budget — these constants are deliberately tuned for mobile:
+ * - Push debounce: 15 s. Real reading sessions involve continuous page-turns,
+ * so a longer window collapses many turns into one PUT.
+ * - Pull cooldown: 60 s. Window focus shouldn't trigger a fresh fetch on every
+ * alt-tab; once a minute is plenty for cross-device drift.
+ * - Open-pull skip: 30 s. Quickly closing/reopening a book shouldn't re-fetch
+ * the same config that's already current in memory.
+ *
+ * Gating: the active provider's `enabled` master switch, plus WebDAV's
+ * serverUrl/username (the Connect flow guarantees these). Google Drive's token
+ * lives in the OS keychain, so `enabled` is the only settings gate there.
+ *
+ * Strategy semantics — same vocabulary as KOSync:
+ * - 'silent' (default): always push and always pull, latest writer wins
+ * - 'send': push only, never pull (this device feeds others)
+ * - 'receive': pull only, never push (this device follows others)
+ * - 'prompt': not implemented — falls back to 'silent'
+ */
+
+/** Debounce window for auto-push triggered by progress / booknote churn. */
+const PUSH_DEBOUNCE_MS = 15_000;
+/** Minimum gap between automatic pulls (e.g. window-focus, open-book). */
+const PULL_COOLDOWN_MS = 60_000;
+/**
+ * If this hook ran a successful pull less than this long ago for the current
+ * book, skip the open-book pull entirely. `lastPulledAtRef` is instance state,
+ * so close-then-reopen resets it; the skip only fires on re-runs within one hook
+ * lifetime (navigating between books, or progress arriving in two ticks).
+ */
+const OPEN_PULL_SKIP_MS = 30_000;
+
+/** Settings key for a backend kind. */
+const settingsKeyFor = (kind: FileSyncBackendKind): 'webdav' | 'googleDrive' =>
+ kind === 'gdrive' ? 'googleDrive' : 'webdav';
+
+export const useFileSync = (bookKey: string) => {
+ const _ = useTranslation();
+ const { envConfig, appService } = useEnv();
+ const { settings, setSettings, saveSettings } = useSettingsStore();
+ const getViewsById = useReaderStore((s) => s.getViewsById);
+ const getView = useReaderStore((s) => s.getView);
+ const getConfig = useBookDataStore((s) => s.getConfig);
+ const setConfig = useBookDataStore((s) => s.setConfig);
+ const getBookData = useBookDataStore((s) => s.getBookData);
+ const saveConfig = useBookDataStore((s) => s.saveConfig);
+ // Reactive: triggers the auto-push effect on page turns.
+ const progress = useBookProgress(bookKey);
+
+ // The single active cloud provider (WebDAV and Google Drive are exclusive).
+ const activeKind: FileSyncBackendKind | null = settings.webdav?.enabled
+ ? 'webdav'
+ : settings.googleDrive?.enabled
+ ? 'gdrive'
+ : null;
+ const providerSettings = activeKind === 'gdrive' ? settings.googleDrive : settings.webdav;
+
+ /** Flips true on the first local change after a push, false right before each push. */
+ const dirtyRef = useRef(false);
+ /** Last successful pull timestamp; gates window-focus and open-book pulls. */
+ const lastPulledAtRef = useRef(0);
+ const hasPulledOnce = useRef(false);
+ /** Per-instance lock for the book-file uploader (hash-keyed content). */
+ const fileSyncedRef = useRef(false);
+ /** Per-instance lock for the cover uploader (gated differently than files). */
+ const coverSyncedRef = useRef(false);
+
+ // Switching the active provider mid-session resets the per-book locks so the
+ // newly-active backend does a fresh pull-on-open and re-checks file/cover.
+ useEffect(() => {
+ hasPulledOnce.current = false;
+ fileSyncedRef.current = false;
+ coverSyncedRef.current = false;
+ lastPulledAtRef.current = 0;
+ dirtyRef.current = false;
+ }, [activeKind]);
+
+ // Read latest settings from the store (not the closure) when patching the
+ // active provider's slice: pull → push can fire back-to-back when a book
+ // opens, and a closure-based merge could clobber a sibling write.
+ const ensureDeviceId = useCallback((): string => {
+ const latest = useSettingsStore.getState().settings;
+ const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
+ let id = latest[key]?.deviceId;
+ if (!id) {
+ id = uuidv4();
+ const next = { ...latest, [key]: { ...latest[key], deviceId: id } };
+ setSettings(next);
+ saveSettings(envConfig, next);
+ }
+ return id;
+ }, [activeKind, envConfig, setSettings, saveSettings]);
+
+ const updateLastSyncedAt = useCallback(
+ async (ts: number) => {
+ const latest = useSettingsStore.getState().settings;
+ const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
+ const next = { ...latest, [key]: { ...latest[key], lastSyncedAt: ts } };
+ setSettings(next);
+ await saveSettings(envConfig, next);
+ },
+ [activeKind, envConfig, setSettings, saveSettings],
+ );
+
+ // Third-party cloud sync is a premium feature; the reader's auto-sync stays
+ // off for free plans (and stops if a user downgrades) even if a provider's
+ // `enabled` flag lingers in settings.
+ const { userProfilePlan } = useQuotaStats();
+ const isPremium = !!userProfilePlan && isCloudSyncInPlan(userProfilePlan);
+
+ const isReady = useMemo(() => {
+ if (!isPremium) return false;
+ if (activeKind === 'webdav') {
+ const w = settings.webdav;
+ return !!(w?.enabled && w?.serverUrl && w?.username);
+ }
+ if (activeKind === 'gdrive') return !!settings.googleDrive?.enabled;
+ return false;
+ }, [isPremium, activeKind, settings.webdav, settings.googleDrive]);
+
+ const strategy = providerSettings?.strategy ?? 'silent';
+ const allowPush = isReady && strategy !== 'receive';
+ const allowPull = isReady && strategy !== 'send';
+
+ // The engine is built asynchronously: the Google Drive provider probes the OS
+ // keychain to assemble its token store. Keyed on the connection-relevant
+ // settings (not the whole settings object) so a `lastSyncedAt` write doesn't
+ // rebuild it — which for Drive would re-probe the keychain on every push.
+ const engineKey = useMemo(() => {
+ if (activeKind === 'webdav') {
+ const w = settings.webdav;
+ return `webdav:${w?.enabled}:${w?.serverUrl}:${w?.username}:${w?.password}:${w?.rootPath}`;
+ }
+ if (activeKind === 'gdrive') return `gdrive:${settings.googleDrive?.enabled}`;
+ return 'none';
+ }, [activeKind, settings.webdav, settings.googleDrive]);
+
+ const [engine, setEngine] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ setEngine(null);
+ if (!isReady || !appService || activeKind === null) return;
+ (async () => {
+ const current = useSettingsStore.getState().settings;
+ const provider = await createFileSyncProvider(activeKind, current);
+ if (cancelled || !provider) return;
+ const store = createAppLocalStore({ appService, settings: current, envConfig });
+ setEngine(new FileSyncEngine(provider, store));
+ })();
+ return () => {
+ cancelled = true;
+ };
+ // `engineKey` captures the connection-relevant settings; the rest is read
+ // fresh inside the effect so unrelated settings writes don't rebuild.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [engineKey, isReady, activeKind, appService, envConfig]);
+
+ const authFailedToast = useCallback(() => {
+ eventDispatcher.dispatch('toast', {
+ type: 'error',
+ message: _('Cloud sync authentication failed. Reconnect in Settings.'),
+ });
+ }, [_]);
+
+ /**
+ * Push the latest config (progress + booknotes) to the remote. Skips while the
+ * user is previewing a deep-link target — that in-memory position reflects the
+ * annotation, not actual reading.
+ */
+ const pushNow = useCallback(async () => {
+ if (!allowPush) return;
+ if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return;
+ const wantProgress = providerSettings?.syncProgress ?? true;
+ const wantNotes = providerSettings?.syncNotes ?? true;
+ if (!wantProgress && !wantNotes) return;
+
+ const config = getConfig(bookKey);
+ const book = getBookData(bookKey)?.book;
+ if (!config || !book || !engine) return;
+
+ try {
+ const deviceId = ensureDeviceId();
+ await engine.pushBookConfig(book, config, deviceId);
+ dirtyRef.current = false;
+ await updateLastSyncedAt(Date.now());
+ } catch (e) {
+ if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
+ else console.warn('file sync push failed', e);
+ }
+ }, [
+ allowPush,
+ bookKey,
+ getConfig,
+ getBookData,
+ ensureDeviceId,
+ engine,
+ providerSettings,
+ updateLastSyncedAt,
+ authFailedToast,
+ ]);
+
+ /**
+ * Upload the book binary if syncBooks is on and the remote doesn't already
+ * have a same-sized copy. Cheap on the steady state (a single HEAD per book
+ * per session); re-runs within the same instance no-op via `fileSyncedRef`.
+ */
+ const pushBookFileNow = useCallback(async () => {
+ if (!allowPush) return;
+ if (!(providerSettings?.syncBooks ?? false)) return;
+ if (fileSyncedRef.current) return;
+ fileSyncedRef.current = true;
+
+ const book = getBookData(bookKey)?.book;
+ if (!book || !engine) return;
+
+ try {
+ const result = await engine.pushBookFile(book);
+ if (result.uploaded) await updateLastSyncedAt(Date.now());
+ } catch (e) {
+ // Reset the lock on failure so a later trigger retries.
+ fileSyncedRef.current = false;
+ if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
+ else console.warn('file sync book push failed', e);
+ }
+ }, [
+ allowPush,
+ providerSettings,
+ getBookData,
+ bookKey,
+ engine,
+ updateLastSyncedAt,
+ authFailedToast,
+ ]);
+
+ /**
+ * Push the local cover image, independent of `syncBooks` — covers are part of
+ * the book's metadata and the receiving device can't regenerate them without
+ * the book bytes. Best-effort: a missing local cover silently no-ops.
+ */
+ const pushBookCoverNow = useCallback(async () => {
+ if (!allowPush) return;
+ if (coverSyncedRef.current) return;
+ coverSyncedRef.current = true;
+
+ const book = getBookData(bookKey)?.book;
+ if (!book || !engine) return;
+
+ try {
+ await engine.pushBookCover(book);
+ } catch (e) {
+ coverSyncedRef.current = false;
+ if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
+ else console.warn('file sync cover push failed', e);
+ }
+ }, [allowPush, getBookData, bookKey, engine, authFailedToast]);
+
+ /**
+ * Pull, merge, and persist, using the same per-config / per-note merge as the
+ * native cloud sync. Returns `true` when the remote had a payload (merge
+ * happened), `false` when empty — callers use that to bootstrap an initial push.
+ */
+ const pullNow = useCallback(async (): Promise => {
+ if (!allowPull) return false;
+ const wantProgress = providerSettings?.syncProgress ?? true;
+ const wantNotes = providerSettings?.syncNotes ?? true;
+ if (!wantProgress && !wantNotes) return false;
+
+ const config = getConfig(bookKey);
+ const book = getBookData(bookKey)?.book;
+ if (!config || !book || !engine) return false;
+
+ try {
+ const result = await engine.pullBookConfig(book, config);
+ lastPulledAtRef.current = Date.now();
+ if (!result.applied || !result.mergedConfig) return false;
+
+ // Surface merged notes through the live view so highlights re-appear /
+ // disappear without waiting for the next render pass.
+ if (wantNotes && result.mergedNotes) {
+ const view = getView(bookKey);
+ const previousById = new Map((config.booknotes ?? []).map((n) => [n.id, n]));
+ for (const note of result.mergedNotes) {
+ const prev = previousById.get(note.id);
+ if (note.deletedAt && (!prev || !prev.deletedAt)) {
+ getViewsById(bookKey.split('-')[0]!).forEach((v) => removeBookNoteOverlays(v, note));
+ } else if (!note.deletedAt && note.cfi && view) {
+ try {
+ view.addAnnotation(note);
+ } catch {
+ // The annotation may not belong to the current spine index.
+ }
+ }
+ }
+ }
+
+ // Honour sub-toggles: drop the parts the user opted out of.
+ const toApply = { ...result.mergedConfig };
+ if (!wantProgress) {
+ toApply.progress = config.progress;
+ toApply.location = config.location;
+ toApply.xpointer = config.xpointer;
+ }
+ if (!wantNotes) {
+ toApply.booknotes = config.booknotes;
+ }
+
+ setConfig(bookKey, toApply);
+ const latest = getConfig(bookKey);
+ if (latest) await saveConfig(envConfig, bookKey, latest, settings);
+ await updateLastSyncedAt(Date.now());
+ return true;
+ } catch (e) {
+ if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
+ else console.warn('file sync pull failed', e);
+ return false;
+ }
+ }, [
+ allowPull,
+ bookKey,
+ getConfig,
+ getBookData,
+ getView,
+ getViewsById,
+ setConfig,
+ saveConfig,
+ engine,
+ envConfig,
+ settings,
+ providerSettings,
+ updateLastSyncedAt,
+ authFailedToast,
+ ]);
+
+ // Stash the latest callbacks in a ref so the event-bridge effect doesn't
+ // re-bind on every render (pattern from useKOSync).
+ const syncRefs = useRef({ pushNow, pullNow, pushBookFileNow, pushBookCoverNow });
+ useEffect(() => {
+ syncRefs.current = { pushNow, pullNow, pushBookFileNow, pushBookCoverNow };
+ }, [pushNow, pullNow, pushBookFileNow, pushBookCoverNow]);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const debouncedPush = useCallback(
+ debounce(() => {
+ if (!dirtyRef.current) return;
+ syncRefs.current.pushNow();
+ }, PUSH_DEBOUNCE_MS),
+ [],
+ );
+
+ const markDirtyAndSchedule = useCallback(() => {
+ dirtyRef.current = true;
+ debouncedPush();
+ }, [debouncedPush]);
+
+ // Pull once on book open (waiting for the async engine + a known location),
+ // then push if the remote was empty so the per-book directory is created on
+ // first use. The book-file/cover uploads ride along on the same trigger.
+ useEffect(() => {
+ if (!isReady || !engine) return;
+ if (!progress?.location) return;
+ if (hasPulledOnce.current) return;
+ hasPulledOnce.current = true;
+ if (Date.now() - lastPulledAtRef.current < OPEN_PULL_SKIP_MS) return;
+ (async () => {
+ const merged = await syncRefs.current.pullNow();
+ if (!merged) {
+ dirtyRef.current = true;
+ await syncRefs.current.pushNow();
+ }
+ await Promise.all([syncRefs.current.pushBookCoverNow(), syncRefs.current.pushBookFileNow()]);
+ })();
+ }, [isReady, engine, progress?.location]);
+
+ // Auto-push on progress changes (debounced; the dirty check short-circuits).
+ useEffect(() => {
+ if (!isReady) return;
+ if (!progress?.location) return;
+ markDirtyAndSchedule();
+ }, [isReady, progress?.location, markDirtyAndSchedule]);
+
+ // Booknote mutations: hash on length + max(updatedAt, deletedAt) so a pure
+ // re-render with a fresh array reference doesn't fire a push.
+ const config = getConfig(bookKey);
+ const booknoteFingerprint = useMemo(() => {
+ const notes = config?.booknotes ?? [];
+ let max = 0;
+ for (const n of notes) {
+ const t = Math.max(n.updatedAt ?? 0, n.deletedAt ?? 0);
+ if (t > max) max = t;
+ }
+ return `${notes.length}:${max}`;
+ }, [config?.booknotes]);
+ useEffect(() => {
+ if (!isReady) return;
+ // The first render after a pull populates booknotes; don't treat as an edit.
+ if (Date.now() - lastPulledAtRef.current < 1_000) return;
+ markDirtyAndSchedule();
+ }, [isReady, booknoteFingerprint, markDirtyAndSchedule]);
+
+ // Manual triggers: settings UI / reader-close can dispatch these.
+ useEffect(() => {
+ const handlePush = (event: CustomEvent) => {
+ if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return;
+ dirtyRef.current = true;
+ fileSyncedRef.current = false;
+ coverSyncedRef.current = false;
+ debouncedPush.flush();
+ syncRefs.current.pushBookFileNow();
+ syncRefs.current.pushBookCoverNow();
+ };
+ const handlePull = (event: CustomEvent) => {
+ if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return;
+ lastPulledAtRef.current = 0;
+ hasPulledOnce.current = false;
+ syncRefs.current.pullNow();
+ };
+ eventDispatcher.on('push-file-sync', handlePush);
+ eventDispatcher.on('pull-file-sync', handlePull);
+ eventDispatcher.on('flush-file-sync', handlePush);
+ return () => {
+ eventDispatcher.off('push-file-sync', handlePush);
+ eventDispatcher.off('pull-file-sync', handlePull);
+ eventDispatcher.off('flush-file-sync', handlePush);
+ };
+ }, [bookKey, debouncedPush]);
+
+ // Window blur ⇒ push pending changes. Window focus ⇒ pull (cooldown-gated).
+ useWindowActiveChanged((isActive) => {
+ if (!isReady) return;
+ if (isActive) {
+ if (Date.now() - lastPulledAtRef.current < PULL_COOLDOWN_MS) return;
+ syncRefs.current.pullNow();
+ } else if (dirtyRef.current) {
+ debouncedPush.flush();
+ }
+ });
+
+ // Flush any pending debounced push when the hook unmounts (book closed).
+ useEffect(() => {
+ return () => {
+ debouncedPush.flush();
+ };
+ }, [debouncedPush]);
+
+ return { pushNow, pullNow };
+};
+
+export default useFileSync;
diff --git a/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts b/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts
deleted file mode 100644
index 66af2351..00000000
--- a/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts
+++ /dev/null
@@ -1,550 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef } from 'react';
-import { v4 as uuidv4 } from 'uuid';
-import { useEnv } from '@/context/EnvContext';
-import { useBookDataStore } from '@/store/bookDataStore';
-import { useReaderStore } from '@/store/readerStore';
-import { useBookProgress } from '@/store/readerProgressStore';
-import { useSettingsStore } from '@/store/settingsStore';
-import { useTranslation } from '@/hooks/useTranslation';
-import { debounce } from '@/utils/debounce';
-import { eventDispatcher } from '@/utils/event';
-import { FileSyncEngine } from '@/services/sync/file/engine';
-import { FileSyncError } from '@/services/sync/file/provider';
-import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
-import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
-import { removeBookNoteOverlays } from '../utils/annotatorUtil';
-import { useWindowActiveChanged } from './useWindowActiveChanged';
-
-/**
- * WebDAV per-book sync hook.
- *
- * Mirrors the architecture of `useKOSync` / `useProgressSync`: a single
- * Reader-level hook drives both progress and booknote sync against
- * `/Readest/books//config.json`. Pull-once on book open,
- * debounced push on progress / booknote changes, manual flush on
- * `flush-webdav-sync` event.
- *
- * Energy budget — these constants are deliberately tuned for mobile:
- * - Push debounce: 15 s. Real reading sessions involve continuous
- * page-turns, so a longer window collapses many turns into one PUT.
- * - Pull cooldown: 60 s. Window focus shouldn't trigger a fresh PROPFIND
- * on every alt-tab; once a minute is plenty for cross-device drift.
- * - Open-pull skip: 30 s. Quickly closing/reopening a book shouldn't
- * re-fetch the same config that's already current in memory.
- *
- * Gating:
- * - `settings.webdav.enabled` must be true (master switch on the WebDAV
- * sub-page in Integrations)
- * - `settings.webdav.serverUrl` and `settings.webdav.username` must be
- * non-empty (the Connect flow guarantees this when enabled is true,
- * but defensive check is cheap)
- *
- * Strategy semantics — same vocabulary as KOSync so users only learn one:
- * - 'silent' (default): always push and always pull, latest writer wins
- * - 'send': push only, never pull (this device feeds others)
- * - 'receive': pull only, never push (this device follows others)
- * - 'prompt': not implemented in v1 — falls back to 'silent'
- */
-
-/** Debounce window for auto-push triggered by progress / booknote churn. */
-const PUSH_DEBOUNCE_MS = 15_000;
-/** Minimum gap between automatic pulls (e.g. window-focus, open-book). */
-const PULL_COOLDOWN_MS = 60_000;
-/**
- * If this hook ran a successful pull less than this long ago for the
- * current book, skip the open-book pull entirely.
- *
- * Note: `lastPulledAtRef` is component-instance state, so closing the
- * reader unmounts the hook and resets the ref. A real "close-then-
- * reopen" therefore *doesn't* trigger this guard — the new instance
- * starts at 0 and proceeds to pull. The skip only fires when the
- * open-book effect re-runs within a single hook lifetime (e.g.
- * navigating between two books in the same reader window, or
- * progress arriving in two ticks before `hasPulledOnce` is set),
- * which is the common case we actually want to deduplicate.
- */
-const OPEN_PULL_SKIP_MS = 30_000;
-
-export const useWebDAVSync = (bookKey: string) => {
- const _ = useTranslation();
- const { envConfig, appService } = useEnv();
- const { settings, setSettings, saveSettings } = useSettingsStore();
- const getViewsById = useReaderStore((s) => s.getViewsById);
- const getView = useReaderStore((s) => s.getView);
- const getConfig = useBookDataStore((s) => s.getConfig);
- const setConfig = useBookDataStore((s) => s.setConfig);
- const getBookData = useBookDataStore((s) => s.getBookData);
- const saveConfig = useBookDataStore((s) => s.saveConfig);
- // Reactive: triggers the auto-push effect on page turns. Imperative reads
- // elsewhere in this hook still go through useReaderStore.getState() /
- // getBookProgress() via callbacks where they are bound directly.
- const progress = useBookProgress(bookKey);
-
- /**
- * `dirtyRef` flips to true on the first locally-driven change after a
- * successful push, and back to false right before each push fires. We
- * use it to skip no-op flushes (e.g., user just opens then closes a
- * book without reading) so mobile doesn't burn a PUT for no reason.
- */
- const dirtyRef = useRef(false);
- /** Last successful pull timestamp; gates window-focus and open-book pulls. */
- const lastPulledAtRef = useRef(0);
- const hasPulledOnce = useRef(false);
- /**
- * Per-instance lock for the book-file uploader. Once we've HEAD-probed
- * (and possibly uploaded) the binary for this book in this hook
- * lifetime, we never re-do it — the file's content is hash-keyed so
- * the only thing that could change is the friendly filename, which is
- * a metadata-only operation handled elsewhere.
- */
- const fileSyncedRef = useRef(false);
- /**
- * Per-instance lock for the cover uploader. Same shape as
- * `fileSyncedRef` but tracked separately because covers are gated
- * differently than book files: cover sync runs whenever we're allowed
- * to push at all (independent of `syncBooks`), so they need their own
- * "already done in this hook lifetime" bit.
- */
- const coverSyncedRef = useRef(false);
-
- // The deviceId is generated lazily on first push so users who never
- // enable WebDAV don't carry it around.
- //
- // Read latest settings from the store rather than the closure for the
- // same reason `updateLastSyncedAt` does: `pullNow → pushNow` can fire
- // back-to-back when a book opens, and the closure's `settings` may
- // not reflect a sibling write that just landed (e.g. the settings
- // panel flipping `syncBooks`). A closure-based merge here would
- // rebuild the webdav block from a stale snapshot and silently
- // clobber that write.
- const ensureDeviceId = useCallback((): string => {
- const latest = useSettingsStore.getState().settings;
- let id = latest.webdav?.deviceId;
- if (!id) {
- id = uuidv4();
- const next = { ...latest, webdav: { ...latest.webdav, deviceId: id } };
- setSettings(next);
- saveSettings(envConfig, next);
- }
- return id;
- }, [envConfig, setSettings, saveSettings]);
-
- const updateLastSyncedAt = useCallback(
- async (ts: number) => {
- // Read the latest settings from the store rather than the
- // closure: pullNow → pushNow → pushBookFileNow can fire
- // back-to-back when a book opens, and the closure's `settings`
- // doesn't reflect interim writes by the prior call. Using the
- // closure here would let a second `updateLastSyncedAt` rebuild
- // the webdav object from a stale snapshot, clobbering whatever
- // the first call (or a sibling write like `syncBooks` from the
- // settings panel) just committed.
- const latest = useSettingsStore.getState().settings;
- const next = { ...latest, webdav: { ...latest.webdav, lastSyncedAt: ts } };
- setSettings(next);
- await saveSettings(envConfig, next);
- },
- [envConfig, setSettings, saveSettings],
- );
-
- const isReady = useMemo(() => {
- const w = settings.webdav;
- return !!(w?.enabled && w?.serverUrl && w?.username);
- }, [settings.webdav]);
-
- const strategy = settings.webdav?.strategy ?? 'silent';
- const allowPush = isReady && strategy !== 'receive';
- const allowPull = isReady && strategy !== 'send';
-
- // One engine per (credentials, appService) — the provider owns the WebDAV
- // URL + auth and the streaming transport; the shared local-store bridge owns
- // the on-disk book/cover I/O. Rebuilt when settings change (cheap closures).
- const engine = useMemo(() => {
- const w = settings.webdav;
- if (!w?.enabled || !w?.serverUrl || !w?.username || !appService) return null;
- const provider = createWebDAVProvider(w);
- const store = createAppLocalStore({ appService, settings, envConfig });
- return new FileSyncEngine(provider, store);
- }, [settings, appService, envConfig]);
-
- /**
- * Push the latest config (progress + booknotes) to the remote.
- * Skips while the user is previewing a deep-link target — the in-memory
- * position there reflects the annotation, not actual reading.
- */
- const pushNow = useCallback(async () => {
- if (!allowPush) return;
- if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return;
- // Default-on semantics for older settings.json files that predate
- // these keys (undefined in storage → opt in, not opt out).
- const wantProgress = settings.webdav?.syncProgress ?? true;
- const wantNotes = settings.webdav?.syncNotes ?? true;
- if (!wantProgress && !wantNotes) return;
-
- const config = getConfig(bookKey);
- const book = getBookData(bookKey)?.book;
- if (!config || !book || !engine) return;
-
- try {
- const deviceId = ensureDeviceId();
- // We always push the full envelope; sub-toggles only gate _which_
- // fields the local writer applies on pull. This keeps the wire
- // schema stable across users with different toggle combinations.
- await engine.pushBookConfig(book, config, deviceId);
- dirtyRef.current = false;
- await updateLastSyncedAt(Date.now());
- } catch (e) {
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
- eventDispatcher.dispatch('toast', {
- type: 'error',
- message: _('WebDAV authentication failed. Reconnect in Settings.'),
- });
- } else {
- console.warn('WD push failed', e);
- }
- }
- }, [
- allowPush,
- bookKey,
- getConfig,
- getBookData,
- ensureDeviceId,
- engine,
- settings.webdav,
- updateLastSyncedAt,
- _,
- ]);
-
- /**
- * Upload the book binary if syncBooks is on and the remote doesn't
- * already have a same-sized copy. Designed to be cheap on the steady
- * state — the underlying `pushBookFile` does a single HEAD before any
- * PUT, so for already-mirrored books we burn just one round-trip per
- * book per session. Re-runs of this callback within the same hook
- * instance no-op via `fileSyncedRef`.
- */
- const pushBookFileNow = useCallback(async () => {
- if (!allowPush) return;
- if (!(settings.webdav?.syncBooks ?? false)) return;
- if (fileSyncedRef.current) return;
- fileSyncedRef.current = true;
-
- const book = getBookData(bookKey)?.book;
- if (!book || !engine) return;
-
- try {
- // The engine prefers the provider's streaming upload (Tauri) and falls
- // back to a buffered PUT on web — both resolve the local bytes/path via
- // the shared local store, so this hook no longer owns that plumbing.
- const result = await engine.pushBookFile(book);
- if (result.uploaded) {
- await updateLastSyncedAt(Date.now());
- }
- } catch (e) {
- // Reset the lock on failure so a manual Sync now or a subsequent
- // open retries — otherwise a transient hiccup would mark this
- // book "synced" for the rest of the session.
- fileSyncedRef.current = false;
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
- eventDispatcher.dispatch('toast', {
- type: 'error',
- message: _('WebDAV authentication failed. Reconnect in Settings.'),
- });
- } else {
- console.warn('WD book file push failed', e);
- }
- }
- }, [allowPush, settings.webdav, getBookData, bookKey, engine, updateLastSyncedAt, _]);
-
- /**
- * Push the local cover image to the remote, independent of
- * `syncBooks`. Covers are part of the book's metadata: at ~30–60 KB
- * each (after the import-time downscale) the bandwidth cost is
- * negligible, but the receiving device cannot regenerate them when
- * `syncBooks=false` (it has no book bytes to extract from), so a
- * user who only opts into progress + notes still needs the covers
- * ride-along to see proper bookshelf art.
- *
- * Failures are best-effort warnings: a missing local cover (TXT/MD
- * imports without metadata, etc.) silently no-ops, and a network
- * blip is logged but doesn't surface a toast.
- */
- const pushBookCoverNow = useCallback(async () => {
- if (!allowPush) return;
- if (coverSyncedRef.current) return;
- coverSyncedRef.current = true;
-
- const book = getBookData(bookKey)?.book;
- if (!book || !engine) return;
-
- try {
- await engine.pushBookCover(book);
- } catch (e) {
- // Reset the lock so a manual "Sync now" or a subsequent open
- // can retry, mirroring `pushBookFileNow`'s recovery model.
- coverSyncedRef.current = false;
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
- eventDispatcher.dispatch('toast', {
- type: 'error',
- message: _('WebDAV authentication failed. Reconnect in Settings.'),
- });
- } else {
- console.warn('WD book cover push failed', e);
- }
- }
- }, [allowPush, getBookData, bookKey, engine, _]);
-
- /**
- * Pull, merge, and persist. Uses the same per-config / per-note merge
- * semantics as the native cloud sync so a user running both feels the
- * same behaviour from each.
- *
- * Returns `true` when remote already had a payload (merge happened),
- * `false` when remote was empty. Callers use that to decide whether to
- * follow up with an initial push (which is how the per-book directory
- * actually gets created on first use).
- */
- const pullNow = useCallback(async (): Promise => {
- if (!allowPull) return false;
- const wantProgress = settings.webdav?.syncProgress ?? true;
- const wantNotes = settings.webdav?.syncNotes ?? true;
- if (!wantProgress && !wantNotes) return false;
-
- const config = getConfig(bookKey);
- const book = getBookData(bookKey)?.book;
- if (!config || !book || !engine) return false;
-
- try {
- const result = await engine.pullBookConfig(book, config);
- lastPulledAtRef.current = Date.now();
- if (!result.applied || !result.mergedConfig) return false;
-
- // Surface merged notes through the live view so highlights re-appear /
- // disappear without waiting for the next render pass.
- if (wantNotes && result.mergedNotes) {
- const view = getView(bookKey);
- const previousById = new Map((config.booknotes ?? []).map((n) => [n.id, n]));
- for (const note of result.mergedNotes) {
- const prev = previousById.get(note.id);
- if (note.deletedAt && (!prev || !prev.deletedAt)) {
- // Newly soft-deleted on the remote — strip overlays locally.
- getViewsById(bookKey.split('-')[0]!).forEach((v) => removeBookNoteOverlays(v, note));
- } else if (!note.deletedAt && note.cfi && view) {
- // Newly added or re-surrected; only render in the live spine.
- try {
- view.addAnnotation(note);
- } catch {
- // The annotation may not belong to the current spine index;
- // it'll get rendered when that section is loaded.
- }
- }
- }
- }
-
- // Honour sub-toggles: drop the parts the user opted out of before
- // writing back to the local config store.
- const toApply = { ...result.mergedConfig };
- if (!wantProgress) {
- toApply.progress = config.progress;
- toApply.location = config.location;
- toApply.xpointer = config.xpointer;
- }
- if (!wantNotes) {
- toApply.booknotes = config.booknotes;
- }
-
- setConfig(bookKey, toApply);
- // Persist locally so a later session sees the merged state even if
- // the user closes the book without further interaction.
- const latest = getConfig(bookKey);
- if (latest) await saveConfig(envConfig, bookKey, latest, settings);
- await updateLastSyncedAt(Date.now());
- return true;
- } catch (e) {
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
- eventDispatcher.dispatch('toast', {
- type: 'error',
- message: _('WebDAV authentication failed. Reconnect in Settings.'),
- });
- } else {
- console.warn('WD pull failed', e);
- }
- return false;
- }
- }, [
- allowPull,
- bookKey,
- getConfig,
- getBookData,
- getView,
- getViewsById,
- setConfig,
- saveConfig,
- engine,
- envConfig,
- settings,
- updateLastSyncedAt,
- _,
- ]);
-
- // Stash the latest pull/push callbacks in a ref so the event-bridge
- // useEffect below doesn't have to re-bind on every render. Pattern
- // taken from useKOSync.
- const syncRefs = useRef({ pushNow, pullNow, pushBookFileNow, pushBookCoverNow });
- useEffect(() => {
- syncRefs.current = { pushNow, pullNow, pushBookFileNow, pushBookCoverNow };
- }, [pushNow, pullNow, pushBookFileNow, pushBookCoverNow]);
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- const debouncedPush = useCallback(
- debounce(() => {
- // Skip the network round-trip when nothing has changed since the
- // last successful push (the dirtyRef.current = false in pushNow's
- // success path).
- if (!dirtyRef.current) return;
- syncRefs.current.pushNow();
- }, PUSH_DEBOUNCE_MS),
- [],
- );
-
- /**
- * Mark dirty + schedule a debounced push. Centralises the pattern so
- * progress / booknote effects don't both have to remember to flip the
- * dirty bit.
- */
- const markDirtyAndSchedule = useCallback(() => {
- dirtyRef.current = true;
- debouncedPush();
- }, [debouncedPush]);
-
- // Pull once on book open, then push if remote was empty so the per-book
- // directory gets created on first use rather than waiting for the user
- // to scroll several pages. We hold off until progress.location is known
- // so the merge has a real local side to compare against. The book file
- // upload is gated by syncBooks and rides along on the same trigger.
- useEffect(() => {
- if (!isReady) return;
- if (!progress?.location) return;
- if (hasPulledOnce.current) return;
- hasPulledOnce.current = true;
- // Same-instance dedupe — if we already pulled for this book within
- // the cooldown window, skip the second pull (and its bootstrap push)
- // since the remote almost certainly hasn't moved. See the comment
- // on `OPEN_PULL_SKIP_MS`: this guard only fires on re-runs of this
- // effect within one hook lifetime, not on close-then-reopen.
- if (Date.now() - lastPulledAtRef.current < OPEN_PULL_SKIP_MS) return;
- (async () => {
- const merged = await syncRefs.current.pullNow();
- if (!merged) {
- // Remote had nothing for this book yet — bootstrap the directory
- // structure (Readest/books//) by uploading the local config.
- // Force-push (mark dirty first) so the bootstrap actually fires.
- dirtyRef.current = true;
- await syncRefs.current.pushNow();
- }
- // Cover sync is independent of `syncBooks`: even users who keep
- // book bytes off the wire still want their shelf art mirrored.
- // Run it in parallel with the file upload — they hit different
- // remote paths and the cover is tiny, so there's no reason to
- // serialize them. The HEAD probe inside each makes the steady
- // state near-free for already-mirrored books.
- await Promise.all([syncRefs.current.pushBookCoverNow(), syncRefs.current.pushBookFileNow()]);
- })();
- }, [isReady, progress?.location]);
-
- // Auto-push on progress changes. The debounce holds the network call
- // off until the user has stopped turning pages for PUSH_DEBOUNCE_MS,
- // and the dirty check inside debouncedPush short-circuits no-op flushes.
- useEffect(() => {
- if (!isReady) return;
- if (!progress?.location) return;
- markDirtyAndSchedule();
- }, [isReady, progress?.location, markDirtyAndSchedule]);
-
- // Booknote mutations: hash on length + max(updatedAt, deletedAt) so a
- // pure re-render that produces a fresh `booknotes` array reference
- // without any real change doesn't fire a push. The hash is cheap
- // enough to recompute every render and keeps the effect dependency
- // primitive.
- const config = getConfig(bookKey);
- const booknoteFingerprint = useMemo(() => {
- const notes = config?.booknotes ?? [];
- let max = 0;
- for (const n of notes) {
- const t = Math.max(n.updatedAt ?? 0, n.deletedAt ?? 0);
- if (t > max) max = t;
- }
- return `${notes.length}:${max}`;
- }, [config?.booknotes]);
- useEffect(() => {
- if (!isReady) return;
- // The very first render after a pull populates the booknotes; we
- // shouldn't treat that as a local edit. lastPulledAtRef being recent
- // is a sufficient proxy.
- if (Date.now() - lastPulledAtRef.current < 1_000) return;
- markDirtyAndSchedule();
- }, [isReady, booknoteFingerprint, markDirtyAndSchedule]);
-
- // Manual triggers: settings UI dispatches these via "Sync now" buttons,
- // and the reader emits flush-webdav-sync on close so we don't lose the
- // last few seconds of reading progress.
- useEffect(() => {
- const handlePush = (event: CustomEvent) => {
- if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return;
- // User-triggered push is unconditional — flip dirty so the flush
- // actually does something, and re-run the book-file + cover
- // upload checks so a freshly-toggled "Sync Book Files" picks up
- // the binary, and any cover that wasn't on the wire yet (e.g.
- // hit a transient failure earlier in the session) gets retried.
- dirtyRef.current = true;
- fileSyncedRef.current = false;
- coverSyncedRef.current = false;
- debouncedPush.flush();
- syncRefs.current.pushBookFileNow();
- syncRefs.current.pushBookCoverNow();
- };
- const handlePull = (event: CustomEvent) => {
- if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return;
- lastPulledAtRef.current = 0; // bypass cooldown for explicit pulls
- hasPulledOnce.current = false;
- syncRefs.current.pullNow();
- };
- eventDispatcher.on('push-webdav-sync', handlePush);
- eventDispatcher.on('pull-webdav-sync', handlePull);
- eventDispatcher.on('flush-webdav-sync', handlePush);
- return () => {
- eventDispatcher.off('push-webdav-sync', handlePush);
- eventDispatcher.off('pull-webdav-sync', handlePull);
- eventDispatcher.off('flush-webdav-sync', handlePush);
- };
- }, [bookKey, debouncedPush]);
-
- // Window blur ⇒ push pending changes if any. Window focus ⇒ pull, but
- // only if we haven't pulled within PULL_COOLDOWN_MS — important on
- // mobile where alt-tab equivalents (notifications, app switch) can
- // fire many times per minute.
- useWindowActiveChanged((isActive) => {
- if (!isReady) return;
- if (isActive) {
- if (Date.now() - lastPulledAtRef.current < PULL_COOLDOWN_MS) return;
- syncRefs.current.pullNow();
- } else if (dirtyRef.current) {
- debouncedPush.flush();
- }
- });
-
- // Flush any pending debounced push when the hook unmounts (book closed,
- // user navigated away). Without this, a quick read-then-close session
- // can lose its tail-end progress because the debounce timer never
- // fires. The dirty check inside debouncedPush keeps no-op flushes out
- // of the wire.
- useEffect(() => {
- return () => {
- debouncedPush.flush();
- };
- }, [debouncedPush]);
-
- return { pushNow, pullNow };
-};
-
-export default useWebDAVSync;
diff --git a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx
index 686494af..8c2d765b 100644
--- a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx
+++ b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx
@@ -10,26 +10,32 @@ import {
RiDiscordLine,
RiSendPlaneLine,
RiCloudLine,
+ RiGoogleLine,
} from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
+import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
-import { useWebDAVSyncStore } from '@/store/webdavSyncStore';
+import { useFileSyncStore } from '@/store/fileSyncStore';
import { CatalogManager } from '@/app/opds/components/CatalogManager';
import { saveSysSettings } from '@/helpers/settings';
-import { navigateToLogin } from '@/utils/nav';
+import { isCloudSyncInPlan } from '@/utils/access';
+import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import KOSyncForm from './integrations/KOSyncForm';
import ReadwiseForm from './integrations/ReadwiseForm';
import HardcoverForm from './integrations/HardcoverForm';
import SendToReadestForm from './integrations/SendToReadestForm';
import WebDAVForm from './integrations/WebDAVForm';
+import GoogleDriveForm from './integrations/GoogleDriveForm';
+import { withActiveCloudProvider } from './integrations/cloudSync';
+import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
import SubPageHeader from './SubPageHeader';
import { SectionTitle, SettingLabel } from './primitives';
-type SubPage = 'kosync' | 'webdav' | 'readwise' | 'hardcover' | 'opds' | 'send' | null;
+type SubPage = 'kosync' | 'webdav' | 'gdrive' | 'readwise' | 'hardcover' | 'opds' | 'send' | null;
/**
* Integrations panel — single point of discovery for external service config:
@@ -48,13 +54,19 @@ const IntegrationsPanel: React.FC = () => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { user } = useAuth();
- const { settings, requestedSubPage, setRequestedSubPage } = useSettingsStore();
+ const { settings, setSettings, saveSettings, requestedSubPage, setRequestedSubPage } =
+ useSettingsStore();
const opdsCatalogs = useCustomOPDSStore((s) => s.catalogs);
const opdsCount = opdsCatalogs.filter((c) => !c.deletedAt).length;
// Surface a library-wide WebDAV sync that's mid-flight in the row's
// status line. Keeps the user from feeling like the run was lost
// when they back out of the WebDAV sub-page or close the dialog.
- const isWebDAVSyncing = useWebDAVSyncStore((s) => s.isSyncing);
+ const isWebDAVSyncing = useFileSyncStore((s) => s.byKind.webdav?.isSyncing ?? false);
+ const isGDriveSyncing = useFileSyncStore((s) => s.byKind.gdrive?.isSyncing ?? false);
+ // Third-party cloud sync is a premium feature (any paid plan). `undefined`
+ // while the plan is still loading — treated as not-yet-premium.
+ const { userProfilePlan } = useQuotaStats();
+ const isCloudSyncPremium = !!userProfilePlan && isCloudSyncInPlan(userProfilePlan);
const [subPage, setSubPage] = useState(null);
@@ -86,18 +98,33 @@ const IntegrationsPanel: React.FC = () => {
// stick to the next open. Recognised values match the SubPage union.
useEffect(() => {
if (!requestedSubPage) return;
+ const isCloudRequest =
+ requestedSubPage === 'webdav' ||
+ requestedSubPage === 'gdrive' ||
+ requestedSubPage === 'cloudsync';
+ // Cloud-sync sub-pages are premium-gated. If the plan is still loading, wait
+ // (don't consume the request); once known, only honor it for paid plans.
+ if (isCloudRequest && !isCloudSyncPremium) {
+ if (userProfilePlan === undefined) return;
+ setRequestedSubPage(null);
+ return;
+ }
if (
requestedSubPage === 'kosync' ||
requestedSubPage === 'webdav' ||
+ requestedSubPage === 'gdrive' ||
requestedSubPage === 'readwise' ||
requestedSubPage === 'hardcover' ||
requestedSubPage === 'opds' ||
requestedSubPage === 'send'
) {
setSubPage(requestedSubPage);
+ } else if (requestedSubPage === 'cloudsync') {
+ // Back-compat with the brief unified "Cloud Sync" page.
+ setSubPage('gdrive');
}
setRequestedSubPage(null);
- }, [requestedSubPage, setRequestedSubPage]);
+ }, [requestedSubPage, setRequestedSubPage, isCloudSyncPremium, userProfilePlan]);
// Sub-page wrapper matches the list-view's `my-4 w-full` so the
// SubPageHeader's "Integrations" label lands at the exact same Y position
@@ -112,7 +139,29 @@ const IntegrationsPanel: React.FC = () => {
if (subPage === 'webdav')
return (
- setSubPage(null)} />
+ setSubPage(null)}
+ />
+
+
+ );
+ if (subPage === 'gdrive')
+ return (
+
+ setSubPage(null)}
+ />
+
);
if (subPage === 'readwise')
@@ -154,13 +203,39 @@ const IntegrationsPanel: React.FC = () => {
const readwiseStatus = settings.readwise?.enabled ? _('Connected') : _('Not connected');
const hardcoverStatus = settings.hardcover?.enabled ? _('Connected') : _('Not connected');
- const webdavStatus = isWebDAVSyncing
- ? _('Syncing…')
- : settings.webdav?.enabled
- ? settings.webdav.username
- ? _('Connected as {{user}}', { user: settings.webdav.username })
- : _('Connected')
+
+ // Third-party cloud providers are mutually exclusive: at most one is the
+ // active sync target. A "configured" provider (WebDAV creds / a Drive token)
+ // can be switched on inline; an unconfigured one must be opened to connect.
+ const activeCloudKind: FileSyncBackendKind | null = settings.webdav?.enabled
+ ? 'webdav'
+ : settings.googleDrive?.enabled
+ ? 'gdrive'
+ : null;
+ const webdavConfigured = !!(settings.webdav?.serverUrl && settings.webdav?.username);
+ const gdriveConfigured = !!settings.googleDrive?.accountLabel;
+ const webdavStatus = settings.webdav?.enabled
+ ? isWebDAVSyncing
+ ? _('Syncing…')
+ : _('Active')
+ : webdavConfigured
+ ? _('Configured')
: _('Not connected');
+ const gdriveStatus = settings.googleDrive?.enabled
+ ? isGDriveSyncing
+ ? _('Syncing…')
+ : _('Active')
+ : gdriveConfigured
+ ? _('Configured')
+ : _('Not connected');
+
+ const activateCloudProvider = async (kind: FileSyncBackendKind) => {
+ const latest = useSettingsStore.getState().settings;
+ const next = withActiveCloudProvider(latest, kind);
+ setSettings(next);
+ await saveSettings(envConfig, next);
+ };
+
const opdsStatus =
opdsCount > 0 ? _('{{count}} catalog', { count: opdsCount }) : _('No catalogs');
@@ -183,12 +258,6 @@ const IntegrationsPanel: React.FC = () => {
status={koSyncStatus}
onClick={() => setSubPage('kosync')}
/>
- setSubPage('webdav')}
- />
{
+
+
{_('Third-party Cloud Sync')}
+
+
+ {isCloudSyncPremium ? (
+ <>
+ activateCloudProvider('webdav')}
+ onOpen={() => setSubPage('webdav')}
+ activateLabel={_('Use WebDAV')}
+ />
+ {appService?.isDesktopApp && (
+ activateCloudProvider('gdrive')}
+ onOpen={() => setSubPage('gdrive')}
+ activateLabel={_('Use Google Drive')}
+ />
+ )}
+ >
+ ) : (
+ // Premium-gated: free users get an upgrade prompt instead of the
+ // provider rows. Tapping opens the plans page.
+ navigateToProfile(router)}
+ />
+ )}
+
+
+
+
{_('Content Sources')}
@@ -282,6 +394,86 @@ const IntegrationRow: React.FC
= ({ icon: Icon, title, stat
);
};
+interface CloudProviderRowProps {
+ icon: React.ElementType;
+ title: string;
+ status: string;
+ /** This provider is the active sync target. */
+ isActive: boolean;
+ /** Configured (credentials / token present) — can be switched on inline. */
+ canActivate: boolean;
+ onActivate: () => void;
+ onOpen: () => void;
+ /** Accessible label for the activate radio (e.g. "Use WebDAV"). */
+ activateLabel: string;
+}
+
+/**
+ * A third-party cloud-sync provider row. Two controls: a trailing radio that
+ * makes this provider the (single) active one inline — enabled only when it's
+ * already configured — and the row body / chevron that opens its config
+ * sub-page (connect, sync options, disconnect).
+ */
+const CloudProviderRow: React.FC = ({
+ icon: Icon,
+ title,
+ status,
+ isActive,
+ canActivate,
+ onActivate,
+ onOpen,
+ activateLabel,
+}) => {
+ return (
+
+
+
+
+
+ );
+};
+
interface IntegrationToggleRowProps {
icon: React.ElementType;
title: string;
diff --git a/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx b/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx
new file mode 100644
index 00000000..46da68db
--- /dev/null
+++ b/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx
@@ -0,0 +1,229 @@
+import clsx from 'clsx';
+import React from 'react';
+import { MdCloudSync } from 'react-icons/md';
+import { v4 as uuidv4 } from 'uuid';
+import { useEnv } from '@/context/EnvContext';
+import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation';
+import { useSettingsStore } from '@/store/settingsStore';
+import { useLibraryStore } from '@/store/libraryStore';
+import { useFileSyncStore } from '@/store/fileSyncStore';
+import { eventDispatcher } from '@/utils/event';
+import { FileSyncEngine } from '@/services/sync/file/engine';
+import { FileSyncError } from '@/services/sync/file/provider';
+import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
+import {
+ createFileSyncProvider,
+ type FileSyncBackendKind,
+} from '@/services/sync/file/providerRegistry';
+import type { KOSyncStrategy } from '@/types/settings';
+import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from '../primitives';
+
+/** The settings fields the shared sync controls read/write (WebDAV + Drive share these). */
+export interface FileSyncFormSettings {
+ enabled?: boolean;
+ syncBooks?: boolean;
+ fullSync?: boolean;
+ strategy?: KOSyncStrategy;
+ deviceId?: string;
+ lastSyncedAt?: number;
+}
+
+interface FileSyncFormProps {
+ /** Which backend these controls drive (also keys the progress store + mutex). */
+ kind: FileSyncBackendKind;
+ /** This backend's settings slice. */
+ stored: FileSyncFormSettings;
+ /** Persist a patch into this backend's settings slice (must merge store-latest). */
+ persist: (patch: Partial) => Promise;
+}
+
+/**
+ * Translate a sync-time error into a user-facing string. Backend-neutral: the
+ * provider maps every failure to a {@link FileSyncError} with a normalised `code`
+ * so we never show a raw English `e.message`.
+ */
+const formatSyncError = (_: TranslationFunc, e: unknown): string => {
+ if (e instanceof FileSyncError) {
+ switch (e.code) {
+ case 'AUTH_FAILED':
+ return _('Authentication failed. Reconnect in Settings.');
+ case 'NOT_FOUND':
+ return _('Remote resource not found');
+ case 'NETWORK':
+ return _('Network error');
+ }
+ if (typeof e.status === 'number') {
+ return _('Sync failed (status {{status}})', { status: e.status });
+ }
+ }
+ return _('Sync failed.');
+};
+
+/**
+ * The provider-agnostic sync controls shared by every file-sync backend's
+ * settings form: the sub-category toggles, the conflict strategy, and a manual
+ * "Sync now" button with progress + result toast. The backend-specific connect
+ * panel (WebDAV URL/credentials, the Drive Connect button) lives in the parent
+ * form; everything below the connect line is identical across backends, so it
+ * lives here once and is parameterised by {@link FileSyncFormProps.kind}.
+ */
+const FileSyncForm: React.FC = ({ kind, stored, persist }) => {
+ const _ = useTranslation();
+ const { settings } = useSettingsStore();
+ const { envConfig } = useEnv();
+
+ const isSyncing = useFileSyncStore((s) => s.byKind[kind]?.isSyncing ?? false);
+ const syncProgressLabel = useFileSyncStore((s) => s.byKind[kind]?.progressLabel ?? null);
+ const syncProgressDetail = useFileSyncStore((s) => s.byKind[kind]?.progressDetail ?? null);
+ const beginSync = useFileSyncStore((s) => s.beginSync);
+ const updateProgress = useFileSyncStore((s) => s.updateProgress);
+ const endSync = useFileSyncStore((s) => s.endSync);
+
+ const handleToggleSyncBooks = () => persist({ syncBooks: !(stored.syncBooks ?? false) });
+ const handleToggleFullSync = () => persist({ fullSync: !(stored.fullSync ?? false) });
+ const handleStrategyChange = async (e: React.ChangeEvent) => {
+ await persist({ strategy: e.target.value as KOSyncStrategy });
+ };
+
+ /**
+ * Manual "Sync now" — reconcile the local library with the remote over a
+ * bounded-concurrency pool. Incremental by default (only books whose local
+ * copy differs from the shared index); "Full Sync" re-checks every book. The
+ * provider is built by kind through the registry so this stays backend-neutral.
+ */
+ const handleSyncNow = async () => {
+ if (useFileSyncStore.getState().byKind[kind]?.isSyncing) return;
+ if (!stored.enabled) return;
+
+ const { libraryLoaded, library } = useLibraryStore.getState();
+ const appService = await envConfig.getAppService();
+
+ let currentLibrary = library ?? [];
+ if (!libraryLoaded && appService) {
+ currentLibrary = await appService.loadLibraryBooks();
+ // Hydrate the store before syncing so the engine's addBookToLibrary /
+ // updateBookMetadata merge against the real library, not an empty one.
+ useLibraryStore.getState().setLibrary(currentLibrary);
+ }
+
+ const eligibleBooks = currentLibrary.filter((b) => !b.deletedAt);
+
+ // Lazily ensure a deviceId so the first cross-device sync attributes its
+ // rows correctly (the reader hook also touches this on first push).
+ let deviceId = stored.deviceId;
+ if (!deviceId) {
+ deviceId = uuidv4();
+ await persist({ deviceId });
+ }
+
+ // Acquire the global library-sync mutex; bail if another backend's Sync now
+ // is already mutating the local library.
+ if (!beginSync(kind, _('Syncing {{n}} / {{total}}', { n: 0, total: eligibleBooks.length }))) {
+ return;
+ }
+
+ try {
+ const provider = await createFileSyncProvider(kind, settings);
+ if (!provider) {
+ throw new FileSyncError('Sync backend is not available on this device', 'UNKNOWN');
+ }
+ const store = createAppLocalStore({ appService, settings, envConfig });
+ const engine = new FileSyncEngine(provider, store);
+ const result = await engine.syncLibrary(eligibleBooks, {
+ strategy: stored.strategy === 'prompt' ? 'silent' : stored.strategy,
+ syncBooks: stored.syncBooks ?? false,
+ fullSync: stored.fullSync ?? false,
+ deviceId: deviceId as string,
+ onProgress: ({ book, index, total, action }) => {
+ const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading');
+ updateProgress(
+ kind,
+ _('{{action}} {{n}} / {{total}}', { action: actionStr, n: index + 1, total }),
+ book.title || book.hash.slice(0, 8),
+ );
+ },
+ });
+
+ await persist({ lastSyncedAt: Date.now() });
+ if (result.failures > 0) {
+ eventDispatcher.dispatch('toast', {
+ type: 'warning',
+ message: _('Sync finished with {{failed}} failure(s). {{ok}} ok.', {
+ failed: result.failures,
+ ok: Math.max(0, result.totalBooks - result.failures),
+ }),
+ });
+ } else {
+ eventDispatcher.dispatch('toast', {
+ type: 'info',
+ message: _('{{count}} book(s) synced', { count: result.booksSynced }),
+ });
+ }
+ } catch (e) {
+ eventDispatcher.dispatch('toast', { type: 'error', message: formatSyncError(_, e) });
+ } finally {
+ endSync(kind);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {syncProgressDetail}
+ ) : undefined
+ }
+ >
+
+
+
+ );
+};
+
+export default FileSyncForm;
diff --git a/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx b/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx
new file mode 100644
index 00000000..073ca756
--- /dev/null
+++ b/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx
@@ -0,0 +1,165 @@
+import clsx from 'clsx';
+import React, { useState } from 'react';
+import { useEnv } from '@/context/EnvContext';
+import { useTranslation } from '@/hooks/useTranslation';
+import { useSettingsStore } from '@/store/settingsStore';
+import { eventDispatcher } from '@/utils/event';
+import {
+ runGoogleDriveConnect,
+ runGoogleDriveDisconnect,
+} from '@/services/sync/providers/gdrive/googleDriveConnect';
+import { Tips } from '../primitives';
+import FileSyncForm from './FileSyncForm';
+import { withActiveCloudProvider } from './cloudSync';
+
+const disconnectButtonClass = clsx(
+ 'eink-bordered',
+ 'h-10 rounded-lg px-4 text-sm font-medium',
+ 'text-error hover:bg-error/10',
+ 'transition-colors duration-150',
+ 'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
+);
+
+const primaryButtonClass = clsx(
+ 'btn btn-primary',
+ 'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
+ 'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
+);
+
+/**
+ * Google Drive provider panel, embedded in the Integrations Google Drive
+ * sub-page (which owns the header). Three states:
+ *
+ * - **Active** (`googleDrive.enabled`): the shared {@link FileSyncForm} controls
+ * + Disconnect (which clears the keychain token — a full teardown).
+ * - **Configured but inactive** (a token exists — `accountLabel` is set — but
+ * another provider is active): "Use Google Drive" re-activates it WITHOUT a
+ * fresh sign-in, so switching back is frictionless; Disconnect tears it down.
+ * - **Not connected**: the OAuth Connect button.
+ *
+ * Activating makes Drive the single active cloud provider (turns WebDAV off).
+ */
+const GoogleDriveForm: React.FC = () => {
+ const _ = useTranslation();
+ const { settings, setSettings, saveSettings } = useSettingsStore();
+ const { envConfig } = useEnv();
+
+ const stored = settings.googleDrive;
+ const isActive = !!stored?.enabled;
+ const isConfigured = !!stored?.accountLabel;
+ const [isConnecting, setIsConnecting] = useState(false);
+
+ const persistGDrive = async (patch: Partial) => {
+ const latest = useSettingsStore.getState().settings;
+ const next = { ...latest, googleDrive: { ...latest.googleDrive, ...patch } };
+ setSettings(next);
+ await saveSettings(envConfig, next);
+ };
+
+ // Make Drive the active provider (turns WebDAV off), optionally stamping a
+ // freshly-resolved account label.
+ const activate = async (accountLabel?: string) => {
+ const latest = useSettingsStore.getState().settings;
+ const withLabel =
+ accountLabel === undefined
+ ? latest
+ : { ...latest, googleDrive: { ...latest.googleDrive, accountLabel } };
+ const next = withActiveCloudProvider(withLabel, 'gdrive');
+ setSettings(next);
+ await saveSettings(envConfig, next);
+ };
+
+ const handleConnect = async () => {
+ if (isConnecting) return;
+ setIsConnecting(true);
+ try {
+ const { accountLabel } = await runGoogleDriveConnect();
+ // Only mark connected after the token persisted (runGoogleDriveConnect
+ // throws if the keychain save fails).
+ await activate(accountLabel ?? undefined);
+ eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
+ } catch (e) {
+ console.warn('[gdrive] connect failed', e);
+ eventDispatcher.dispatch('toast', { type: 'error', message: _('Failed to connect') });
+ } finally {
+ setIsConnecting(false);
+ }
+ };
+
+ const handleActivate = async () => {
+ await activate();
+ eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
+ };
+
+ const handleDisconnect = async () => {
+ await runGoogleDriveDisconnect();
+ const latest = useSettingsStore.getState().settings;
+ const base = withActiveCloudProvider(latest, null);
+ const next = { ...base, googleDrive: { ...base.googleDrive, accountLabel: undefined } };
+ setSettings(next);
+ await saveSettings(envConfig, next);
+ eventDispatcher.dispatch('toast', { type: 'info', message: _('Disconnected') });
+ };
+
+ if (isActive) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (isConfigured) {
+ return (
+
+
+
+ {_('Connected as {{account}}', { account: stored.accountLabel })}
+ {_('. Make Google Drive the active cloud provider.')}
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {_('Sign-in opens in your browser.')}
+ {_('Readest only accesses the files it creates in your Drive.')}
+
+
+
+
+
+ );
+};
+
+export default GoogleDriveForm;
diff --git a/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx b/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx
index 46466c58..79252d1a 100644
--- a/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx
+++ b/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx
@@ -1,45 +1,24 @@
import clsx from 'clsx';
import React, { useState } from 'react';
-import { MdVisibility, MdVisibilityOff, MdCloudSync } from 'react-icons/md';
-import { v4 as uuidv4 } from 'uuid';
+import { MdVisibility, MdVisibilityOff } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
-import { useTranslation } from '@/hooks/useTranslation';
+import { useTranslation, type TranslationFunc } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
-import { useLibraryStore } from '@/store/libraryStore';
-import { useWebDAVSyncStore } from '@/store/webdavSyncStore';
import { eventDispatcher } from '@/utils/event';
import {
checkConnection,
normalizeRootPath,
WebDAVConnectResult,
} from '@/services/sync/providers/webdav/client';
-import { type TranslationFunc } from '@/hooks/useTranslation';
-import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings';
-import { FileSyncEngine } from '@/services/sync/file/engine';
-import { FileSyncError } from '@/services/sync/file/provider';
-import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
-import SubPageHeader from '../SubPageHeader';
-import {
- BoxedList,
- SectionTitle,
- SettingsRow,
- SettingsSwitchRow,
- SettingsSelect,
-} from '../primitives';
+import { SectionTitle } from '../primitives';
+import FileSyncForm from './FileSyncForm';
import WebDAVBrowsePane from './WebDAVBrowsePane';
-
-interface WebDAVFormProps {
- onBack: () => void;
-}
+import { withActiveCloudProvider } from './cloudSync';
/**
- * Translate a connection-probe failure into a user-facing string.
- *
- * Each branch must be a literal `_('...')` call so the i18next-scanner
- * picks the keys up — that's why this is a switch on `result.code`
- * rather than the previous `_(result.message || 'Connection error')`
- * pattern, which the scanner couldn't see into.
+ * Translate a connection-probe failure into a user-facing string. Each branch is
+ * a literal `_('...')` call so the i18next-scanner picks the keys up.
*/
const formatConnectError = (_: TranslationFunc, result: WebDAVConnectResult): string => {
switch (result.code) {
@@ -50,9 +29,7 @@ const formatConnectError = (_: TranslationFunc, result: WebDAVConnectResult): st
case 'ROOT_NOT_FOUND':
return _('Root directory not found');
case 'UNEXPECTED_STATUS':
- return _('Unexpected server response (status {{status}})', {
- status: result.status ?? 0,
- });
+ return _('Unexpected server response (status {{status}})', { status: result.status ?? 0 });
case 'NETWORK':
default:
return _('Network error');
@@ -60,76 +37,30 @@ const formatConnectError = (_: TranslationFunc, result: WebDAVConnectResult): st
};
/**
- * Translate a sync-time error into a user-facing string. WebDAVRequestError
- * carries a `code` that lets us map to a specific message without ever
- * showing the raw English `e.message` to the user.
- */
-const formatSyncError = (_: TranslationFunc, e: unknown): string => {
- if (e instanceof FileSyncError) {
- switch (e.code) {
- case 'AUTH_FAILED':
- return _('WebDAV authentication failed. Reconnect in Settings.');
- case 'NOT_FOUND':
- return _('Remote resource not found');
- case 'NETWORK':
- return _('Network error');
- }
- if (typeof e.status === 'number') {
- return _('Sync failed (status {{status}})', { status: e.status });
- }
- }
- return _('Sync failed.');
-};
-
-/**
- * WebDAV integration form. Two modes share the same panel:
+ * WebDAV provider panel, embedded in the Integrations WebDAV sub-page (which
+ * owns the header). Two states:
*
- * - Configuration: editable URL/username/password/root + Connect button.
- * Lives in local state until Connect succeeds — only then do we
- * persist the credentials via `saveSettings`. Failures surface via
- * toast.
- *
- * - Connected: renders the per-page sync controls (sub-toggles, Sync
- * now, sync-history) plus the {@link WebDAVBrowsePane} for the
- * stored root, and a Disconnect button. The browse pane is its own
- * component to keep this file legible — see its docstring.
+ * - **Active** (`webdav.enabled`): the shared {@link FileSyncForm} sync controls
+ * + the {@link WebDAVBrowsePane} + a Disconnect button.
+ * - **Inactive**: the URL/credentials form (pre-filled from saved settings, so a
+ * previously-configured server reconnects in one click). Connecting makes
+ * WebDAV the active provider and turns Google Drive off (cloud providers are
+ * mutually exclusive).
*/
-const WebDAVForm: React.FC = ({ onBack }) => {
+const WebDAVForm: React.FC = () => {
const _ = useTranslation();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { envConfig } = useEnv();
const stored = settings.webdav;
- // Show the browse view only when an active connection is configured.
- // We rely on `enabled` (set by Connect, cleared by Disconnect) rather
- // than looking at serverUrl/username so Disconnect always returns the
- // user to the configuration form even if we keep their previous URL
- // pre-filled.
- const isConfigured = !!stored?.enabled && !!stored?.serverUrl;
+ const isActive = !!stored?.enabled;
- // Editable form state — initialised from saved settings so re-entering
- // the sub-page after a previous configure preserves what the user
- // typed.
const [url, setUrl] = useState(stored?.serverUrl || '');
const [username, setUsername] = useState(stored?.username || '');
const [password, setPassword] = useState(stored?.password || '');
const [rootPath, setRootPath] = useState(stored?.rootPath || '/');
const [isConnecting, setIsConnecting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
- // Library-wide Sync now state — stored in a process-local zustand
- // store rather than component state so the run survives navigation
- // events that would otherwise unmount us (drilling back to the
- // Integrations list, closing the SettingsDialog and reopening it).
- // Without this hoist, the user would see the button re-enable, no
- // progress affordance, and could trigger a second concurrent
- // syncLibrary while the first was still in flight against the
- // server. See `webdavSyncStore.ts` for the design rationale.
- const isSyncing = useWebDAVSyncStore((s) => s.isSyncing);
- const syncProgressLabel = useWebDAVSyncStore((s) => s.progressLabel);
- const syncProgressDetail = useWebDAVSyncStore((s) => s.progressDetail);
- const beginSync = useWebDAVSyncStore((s) => s.beginSync);
- const updateProgress = useWebDAVSyncStore((s) => s.updateProgress);
- const endSync = useWebDAVSyncStore((s) => s.endSync);
const handleConnect = async () => {
if (!url || !username) return;
@@ -144,57 +75,35 @@ const WebDAVForm: React.FC = ({ onBack }) => {
setIsConnecting(false);
return;
}
- // Spread previous webdav state so a reconnect preserves bookkeeping
- // fields earned by prior use — deviceId, syncBooks, strategy,
- // syncProgress, syncNotes, lastSyncedAt. Rotating deviceId on
- // reconnect would make this device look new to the cross-device
- // clobber check in `RemoteBookConfig.writerDeviceId`.
- const newSettings = {
- ...settings,
- webdav: buildWebDAVConnectSettings(settings.webdav, {
+ const latest = useSettingsStore.getState().settings;
+ // Build the WebDAV connect settings (preserves deviceId / sub-toggles), then
+ // make WebDAV the single active cloud provider (turns Google Drive off).
+ const connected = {
+ ...latest,
+ webdav: buildWebDAVConnectSettings(latest.webdav, {
serverUrl: url,
username,
password,
rootPath: normalizedRoot,
}),
};
- setSettings(newSettings);
- await saveSettings(envConfig, newSettings);
+ const next = withActiveCloudProvider(connected, 'webdav');
+ setSettings(next);
+ await saveSettings(envConfig, next);
setIsConnecting(false);
eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
};
const handleDisconnect = async () => {
- const newSettings = {
- ...settings,
- webdav: {
- ...settings.webdav,
- enabled: false,
- },
- };
- setSettings(newSettings);
- await saveSettings(envConfig, newSettings);
- // Keep the password pre-filled (masked) so the user can reconnect
- // with a single click — they can still toggle visibility via the
- // eye icon.
+ const latest = useSettingsStore.getState().settings;
+ // Deactivate (keep the credentials so a later reconnect is one click).
+ const next = withActiveCloudProvider(latest, null);
+ setSettings(next);
+ await saveSettings(envConfig, next);
setShowPassword(false);
eventDispatcher.dispatch('toast', { type: 'info', message: _('Disconnected') });
};
- // —— Sync sub-toggles & manual triggers ——
- // The toggles persist via saveSettings synchronously (debouncing
- // isn't worth the extra state — users tap each toggle at most once
- // per session).
- //
- // IMPORTANT: read latest settings from the store (NOT the closure
- // variable) when computing `next`. Several persistWebdav calls can
- // land back-to-back — e.g. `handleSyncNow` writes `deviceId` up front
- // and `lastSyncedAt` when it finishes, and the user may flip a toggle
- // in between. The closure's `settings` was captured before those
- // writes, so a closure-based merge would rebuild the webdav object
- // from a stale snapshot and clobber a freshly-written field. Use
- // `useSettingsStore.getState()` so each call merges into whatever's
- // currently committed.
const persistWebdav = async (patch: Partial) => {
const latest = useSettingsStore.getState().settings;
const next = { ...latest, webdav: { ...latest.webdav, ...patch } };
@@ -202,328 +111,138 @@ const WebDAVForm: React.FC = ({ onBack }) => {
await saveSettings(envConfig, next);
};
- // Reading progress and annotations are always synced when WebDAV is
- // enabled — anyone bothering to set up cloud sync wants those. Only
- // book files stay opt-in because they're bandwidth/storage heavy.
- const handleToggleSyncBooks = () => persistWebdav({ syncBooks: !(stored?.syncBooks ?? false) });
- const handleToggleFullSync = () => persistWebdav({ fullSync: !(stored?.fullSync ?? false) });
- const handleStrategyChange = async (e: React.ChangeEvent) => {
- await persistWebdav({ strategy: e.target.value as typeof stored.strategy });
- };
+ if (isActive) {
+ return (
+
+
- /**
- * Manual "Sync now" — reconcile the local library with the remote over a
- * bounded-concurrency pool. By default this is incremental: only books whose
- * local copy differs from the shared library.json index are processed
- * (`book.updatedAt` is the per-book change marker). The "Full Sync" toggle
- * re-checks every book. The engine pulls peers' changes and pushes ours; the
- * per-book Reader hook still handles live changes as the user reads.
- *
- * Concurrency is capped (engine default 4) so shared WebDAV servers
- * (NextCloud, Synology, …) aren't hammered while still hiding per-request
- * latency. The run is async relative to the UI — we surface a status string
- * and disable the button.
- */
- const handleSyncNow = async () => {
- // Re-entrancy gate must read the live store, not the closure: a
- // second click after we re-mount could otherwise see the captured
- // `isSyncing` from this render rather than the up-to-date one.
- if (useWebDAVSyncStore.getState().isSyncing) return;
- if (!stored?.enabled || !stored.serverUrl) return;
+
- // Load library from disk if not loaded yet
- const { libraryLoaded, library } = useLibraryStore.getState();
- const appService = await envConfig.getAppService();
-
- let currentLibrary = library ?? [];
- if (!libraryLoaded && appService) {
- currentLibrary = await appService.loadLibraryBooks();
- // Hydrate the store before syncing. The engine's addBookToLibrary /
- // updateBookMetadata merge against the in-memory library; if it were
- // still empty here, a downloaded book or a metadata update would persist
- // as the *entire* library and clobber what's on disk. setLibrary also
- // flips libraryLoaded so the per-book store calls see a loaded store.
- useLibraryStore.getState().setLibrary(currentLibrary);
- }
-
- const eligibleBooks = currentLibrary.filter((b) => !b.deletedAt);
-
- // Lazily ensure a deviceId so the first cross-device sync
- // attributes its rows correctly. The same field is also touched by
- // the Reader hook on first push; doing it here too keeps the Sync
- // now path self-sufficient when the user has never opened a book
- // yet.
- let deviceId = stored.deviceId;
- if (!deviceId) {
- deviceId = uuidv4();
- await persistWebdav({ deviceId });
- }
-
- beginSync(_('Syncing {{n}} / {{total}}', { n: 0, total: eligibleBooks.length }));
-
- try {
- // The provider owns the WebDAV URL + auth + streaming transport; the
- // shared local-store bridge owns all on-disk book/cover/config I/O
- // (including the in-place vs hash-copy path resolution and the Tauri
- // streaming fast path). This form no longer knows any WebDAV specifics.
- const provider = createWebDAVProvider(stored);
- const store = createAppLocalStore({ appService, settings, envConfig });
- const engine = new FileSyncEngine(provider, store);
- const result = await engine.syncLibrary(eligibleBooks, {
- strategy: stored.strategy === 'prompt' ? 'silent' : stored.strategy,
- syncBooks: stored.syncBooks ?? false,
- fullSync: stored.fullSync ?? false,
- deviceId: deviceId as string,
- onProgress: ({ book, index, total, action }) => {
- const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading');
- updateProgress(
- _('{{action}} {{n}} / {{total}}', { action: actionStr, n: index + 1, total }),
- book.title || book.hash.slice(0, 8),
- );
- },
- });
-
- await persistWebdav({ lastSyncedAt: Date.now() });
- // Keep the toast as simple as the native cloud sync: a single-line
- // "{{count}} book(s) synced" info message. Failures still surface as a
- // warning so a partial sync isn't reported as a clean success.
- if (result.failures > 0) {
- eventDispatcher.dispatch('toast', {
- type: 'warning',
- message: _('Sync finished with {{failed}} failure(s). {{ok}} ok.', {
- failed: result.failures,
- ok: Math.max(0, result.totalBooks - result.failures),
- }),
- });
- } else {
- eventDispatcher.dispatch('toast', {
- type: 'info',
- message: _('{{count}} book(s) synced', { count: result.booksSynced }),
- });
- }
- } catch (e) {
- const message = formatSyncError(_, e);
- eventDispatcher.dispatch('toast', { type: 'error', message });
- } finally {
- endSync();
- }
- };
-
- const description: string = isConfigured
- ? _('Browsing {{path}} on {{server}}', {
- path: normalizeRootPath(stored.rootPath || '/'),
- server: stored.serverUrl,
- })
- : _('Connect to a WebDAV server to browse your remote files.');
+
+
+
+
+ );
+ }
return (
-
-
+
);
};
diff --git a/apps/readest-app/src/components/settings/integrations/cloudSync.ts b/apps/readest-app/src/components/settings/integrations/cloudSync.ts
new file mode 100644
index 00000000..7b7147d4
--- /dev/null
+++ b/apps/readest-app/src/components/settings/integrations/cloudSync.ts
@@ -0,0 +1,19 @@
+import type { SystemSettings } from '@/types/settings';
+import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
+
+/**
+ * Return settings with exactly one third-party cloud-sync provider active (or
+ * none). WebDAV and Google Drive are mutually exclusive — only one syncs the
+ * library at a time — so enabling one always disables the other. Provider config
+ * (WebDAV creds, the Drive keychain token) is left untouched, so switching back
+ * to a previously-configured provider needs no re-entry; only an explicit
+ * Disconnect tears a provider's config down.
+ */
+export const withActiveCloudProvider = (
+ settings: SystemSettings,
+ active: FileSyncBackendKind | null,
+): SystemSettings => ({
+ ...settings,
+ webdav: { ...settings.webdav, enabled: active === 'webdav' },
+ googleDrive: { ...settings.googleDrive, enabled: active === 'gdrive' },
+});
diff --git a/apps/readest-app/src/hooks/useAppUrlIngress.ts b/apps/readest-app/src/hooks/useAppUrlIngress.ts
index f778cdf9..bf56f398 100644
--- a/apps/readest-app/src/hooks/useAppUrlIngress.ts
+++ b/apps/readest-app/src/hooks/useAppUrlIngress.ts
@@ -5,6 +5,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { useEnv } from '@/context/EnvContext';
import { isTauriAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
+import { isGoogleOAuthRedirectUrl } from '@/services/sync/providers/gdrive/auth/reverseDnsRedirect';
interface SingleInstancePayload {
args: string[];
@@ -73,9 +74,14 @@ export function useAppUrlIngress() {
// and native-side listener bookkeeping in lockstep.
const dispatch = (urls: string[], action?: 'VIEW' | 'SEND') => {
- if (!urls.length) return;
- console.log('App incoming URL:', urls, 'action:', action);
- eventDispatcher.dispatch('app-incoming-url', { urls, action });
+ // Drop Google OAuth redirects at the source: the Drive sign-in runner
+ // captures them via its own single-instance / onOpenUrl listeners, and
+ // they must never reach a consumer (the book-import path would otherwise
+ // mistake the reverse-DNS redirect URL for a file to open).
+ const appUrls = urls.filter((url) => !isGoogleOAuthRedirectUrl(url));
+ if (!appUrls.length) return;
+ console.log('App incoming URL:', appUrls, 'action:', action);
+ eventDispatcher.dispatch('app-incoming-url', { urls: appUrls, action });
};
const unlistenSingleInstance = getCurrentWindow().listen
(
diff --git a/apps/readest-app/src/services/backupService.ts b/apps/readest-app/src/services/backupService.ts
index 5c220100..4b0ca662 100644
--- a/apps/readest-app/src/services/backupService.ts
+++ b/apps/readest-app/src/services/backupService.ts
@@ -48,6 +48,8 @@ export const BACKUP_SETTINGS_BLACKLIST = [
'lastSyncedAtReplicas',
'readwise.lastSyncedAt',
'hardcover.lastSyncedAt',
+ 'googleDrive.deviceId',
+ 'googleDrive.lastSyncedAt',
// Transient runtime state — book keys may not exist post-restore; screen
// brightness is live device state.
'lastOpenBooks',
diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts
index bb64847f..c7423456 100644
--- a/apps/readest-app/src/services/constants.ts
+++ b/apps/readest-app/src/services/constants.ts
@@ -26,6 +26,7 @@ import {
ReadwiseSettings,
SystemSettings,
WebDAVSettings,
+ GoogleDriveSettings,
} from '@/types/settings';
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota';
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
@@ -102,6 +103,16 @@ export const DEFAULT_WEBDAV_SETTINGS = {
lastSyncedAt: 0,
} as WebDAVSettings;
+export const DEFAULT_GOOGLE_DRIVE_SETTINGS = {
+ enabled: false,
+ syncProgress: true,
+ syncNotes: true,
+ syncBooks: false,
+ strategy: 'silent',
+ deviceId: '',
+ lastSyncedAt: 0,
+} as GoogleDriveSettings;
+
export const DEFAULT_SYSTEM_SETTINGS: Partial = {
keepLogin: false,
autoUpload: true,
@@ -153,6 +164,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = {
readwise: DEFAULT_READWISE_SETTINGS,
hardcover: DEFAULT_HARDCOVER_SETTINGS,
webdav: DEFAULT_WEBDAV_SETTINGS,
+ googleDrive: DEFAULT_GOOGLE_DRIVE_SETTINGS,
aiSettings: DEFAULT_AI_SETTINGS,
lastSyncedAtBooks: 0,
diff --git a/apps/readest-app/src/services/sync/file/providerRegistry.ts b/apps/readest-app/src/services/sync/file/providerRegistry.ts
new file mode 100644
index 00000000..ac5439ed
--- /dev/null
+++ b/apps/readest-app/src/services/sync/file/providerRegistry.ts
@@ -0,0 +1,49 @@
+/**
+ * Registry that maps a backend kind to a concrete {@link FileSyncProvider}, so
+ * the reader hook and the Sync-now form stay backend-agnostic: they ask which
+ * backends are enabled and build each one by kind, never naming WebDAV or Drive
+ * directly.
+ *
+ * The settings view here is intentionally narrow — just the `enabled` flags plus
+ * the WebDAV transport config — so this PR can land the seam without depending on
+ * the full Google Drive settings shape and Integrations UI (which arrive with the
+ * settings-UI phase). Drive builds itself from the env-baked client id + the
+ * keychain token, so it needs no settings to construct.
+ */
+import type { FileSyncProvider } from './provider';
+import type { WebDAVSettings } from '@/types/settings';
+import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
+import { buildGoogleDriveProvider } from '@/services/sync/providers/gdrive/buildGoogleDriveProvider';
+
+export type FileSyncBackendKind = 'webdav' | 'gdrive';
+
+/** Minimal settings the registry reads to pick + build backends. */
+export interface FileSyncBackendsSettings {
+ webdav?: WebDAVSettings;
+ googleDrive?: { enabled?: boolean };
+}
+
+/** The backends the user has switched on, in a stable order. */
+export const getEnabledFileSyncBackends = (
+ settings: FileSyncBackendsSettings,
+): FileSyncBackendKind[] => {
+ const enabled: FileSyncBackendKind[] = [];
+ if (settings.webdav?.enabled) enabled.push('webdav');
+ if (settings.googleDrive?.enabled) enabled.push('gdrive');
+ return enabled;
+};
+
+/**
+ * Build the provider for one backend, or `null` when it cannot run here (WebDAV
+ * without config, Drive without a baked client id / secure storage). Async
+ * because Drive probes the keychain to assemble its token store.
+ */
+export const createFileSyncProvider = async (
+ kind: FileSyncBackendKind,
+ settings: FileSyncBackendsSettings,
+): Promise => {
+ if (kind === 'webdav') {
+ return settings.webdav ? createWebDAVProvider(settings.webdav) : null;
+ }
+ return buildGoogleDriveProvider();
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/GoogleDriveProvider.ts b/apps/readest-app/src/services/sync/providers/gdrive/GoogleDriveProvider.ts
new file mode 100644
index 00000000..d61a326c
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/GoogleDriveProvider.ts
@@ -0,0 +1,658 @@
+/**
+ * Google Drive implementation of {@link FileSyncProvider} — the second concrete
+ * backend for the provider-agnostic file-sync engine (after WebDAV).
+ *
+ * Two facts shape the whole design:
+ *
+ * - **`drive.file` scope.** The app sees *only* the files it created, so Drive
+ * behaves like a private app folder rooted at `'root'`. That makes Drive's
+ * real root a safe namespace for our `/Readest/...` layout — no clash with the
+ * user's own files is possible because we cannot even see them.
+ * - **Drive is ID-addressed, not path-addressed.** A logical path like
+ * `/Readest/books//config.json` must be resolved segment-by-segment into
+ * Drive file ids via `files.list` search queries, then operated on by id.
+ * Resolved ids are memoised in {@link idCache} (scoped to this provider
+ * instance) so repeated access under one folder does not re-walk the tree.
+ *
+ * Differences from the WebDAV wire that this layer absorbs so nothing above it
+ * knows the backend is Drive:
+ * - every Drive HTTP failure is translated into the engine's neutral
+ * {@link FileSyncError} ({@link mapDriveError}); the reference threw plain
+ * `Error`, which the engine cannot branch on;
+ * - 429 / 5xx responses are retried with `Retry-After`-aware backoff;
+ * - `files.list` is drained across `nextPageToken` pages (no silent truncation);
+ * - concurrent folder creation is serialised per logical path and dup names are
+ * collapsed deterministically (the engine runs books at concurrency 4, which
+ * otherwise races to create `/Readest` several times on a fresh remote).
+ *
+ * Tokens are supplied by an injected {@link DriveAuth}; `fetch` is injected as
+ * {@link FetchFn}; `sleep` is injected so backoff is instant under test. All
+ * three keep the provider unit-testable against a mocked Drive.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+
+import {
+ FileEntry,
+ FileHead,
+ FileSyncError,
+ FileSyncErrorCode,
+ FileSyncProvider,
+} from '@/services/sync/file/provider';
+import {
+ childrenQuery,
+ deleteUrl,
+ FILES_ENDPOINT,
+ FOLDER_MIME,
+ listQuery,
+ listUrl,
+ mediaDownloadUrl,
+ mediaUpdateUrl,
+ metadataUrl,
+ reparentUrl,
+ simpleUploadUrl,
+} from './driveRest';
+
+/** Token source for Drive requests, supplied by the OAuth/token layer. */
+export interface DriveAuth {
+ /** A currently-valid access token; refreshes under the hood as needed. */
+ getAccessToken(): Promise;
+}
+
+/**
+ * Injected `fetch`, typed to exactly what the provider uses (a string URL plus
+ * optional init) so the platform's `fetch` or a test stub drops in.
+ */
+export type FetchFn = (input: string, init?: RequestInit) => Promise;
+
+/** Injected sleep so the retry backoff is instant under test. */
+export type SleepFn = (ms: number) => Promise;
+
+export interface GoogleDriveProviderOptions {
+ sleep?: SleepFn;
+}
+
+/** Drive's real root, which `drive.file` scope makes our private namespace root. */
+const DRIVE_ROOT_ID = 'root';
+
+const HTTP_GET = 'GET';
+const HTTP_POST = 'POST';
+const HTTP_PATCH = 'PATCH';
+const HTTP_DELETE = 'DELETE';
+
+const HTTP_UNAUTHORIZED = 401;
+const HTTP_FORBIDDEN = 403;
+const HTTP_NOT_FOUND = 404;
+const HTTP_REQUEST_TIMEOUT = 408;
+const HTTP_CONFLICT = 409;
+const HTTP_TOO_MANY_REQUESTS = 429;
+const HTTP_NO_CONTENT = 204;
+const HTTP_SERVER_ERROR_FLOOR = 500;
+
+const CONTENT_TYPE_HEADER = 'Content-Type';
+const JSON_CONTENT_TYPE = 'application/json';
+const DEFAULT_TEXT_CONTENT_TYPE = 'application/json';
+const DEFAULT_BINARY_CONTENT_TYPE = 'application/octet-stream';
+
+/** Backoff: up to this many retries on a transient (429/5xx) response. */
+const MAX_BACKOFF_RETRIES = 4;
+/** Base delay for exponential backoff when no `Retry-After` is supplied. */
+const BASE_BACKOFF_MS = 500;
+
+const MS_PER_SEC = 1000;
+
+/**
+ * Drive `error.errors[].reason` values that are *transient* even under a 403.
+ * Drive overloads 403 for both rate/quota limits (retry) and genuine permission
+ * failures (re-auth), so 403 must be classified by reason, not status alone.
+ */
+const RATE_LIMIT_REASONS = new Set([
+ 'rateLimitExceeded',
+ 'userRateLimitExceeded',
+ 'dailyLimitExceeded',
+ 'quotaExceeded',
+ 'sharingRateLimitExceeded',
+ 'backendError',
+]);
+
+/** Request-name for error messages; names which operation/path failed. */
+type DriveOperation =
+ | 'list'
+ | 'download'
+ | 'upload'
+ | 'create folder'
+ | 'set metadata'
+ | 'stat'
+ | 'delete';
+
+/** Raw Drive JSON for a single file/folder, restricted to requested `fields`. */
+interface DriveFile {
+ id: string;
+ name?: string;
+ /** Equals {@link FOLDER_MIME} for folders; absent/other for file blobs. */
+ mimeType?: string;
+ /** Byte size as a *string* (Drive returns numbers as strings in JSON). */
+ size?: string;
+ modifiedTime?: string;
+ md5Checksum?: string;
+}
+
+/** Shape of a `files.list` response page. */
+interface DriveFileListPage {
+ files?: DriveFile[];
+ nextPageToken?: string;
+}
+
+/** Drive's structured error body, used to classify a 403 by its `reason`. */
+interface DriveErrorBody {
+ error?: { errors?: { reason?: string }[]; message?: string };
+}
+
+/**
+ * An HTTP failure from a Drive request, carrying the status (and the 403
+ * `reason`) so {@link mapDriveError} can produce the right {@link FileSyncError}
+ * code without re-reading the response body.
+ */
+class DriveHttpError extends Error {
+ constructor(
+ readonly status: number,
+ readonly reason: string | undefined,
+ message: string,
+ ) {
+ super(message);
+ this.name = 'DriveHttpError';
+ }
+}
+
+/**
+ * Translate any Drive failure into the engine's neutral {@link FileSyncError}.
+ * 401 → AUTH_FAILED; 403 → AUTH_FAILED unless its `reason` is a rate/quota limit
+ * (then NETWORK); 404 → NOT_FOUND; 408/429/5xx → NETWORK; 409 → CONFLICT; a
+ * thrown `fetch` (TypeError) → NETWORK; anything else → UNKNOWN.
+ */
+const mapDriveError = (e: unknown): FileSyncError => {
+ if (e instanceof FileSyncError) return e;
+ if (e instanceof DriveHttpError) {
+ let code: FileSyncErrorCode;
+ if (e.status === HTTP_UNAUTHORIZED) {
+ code = 'AUTH_FAILED';
+ } else if (e.status === HTTP_FORBIDDEN) {
+ code = RATE_LIMIT_REASONS.has(e.reason ?? '') ? 'NETWORK' : 'AUTH_FAILED';
+ } else if (e.status === HTTP_NOT_FOUND) {
+ code = 'NOT_FOUND';
+ } else if (
+ e.status === HTTP_REQUEST_TIMEOUT ||
+ e.status === HTTP_TOO_MANY_REQUESTS ||
+ e.status >= HTTP_SERVER_ERROR_FLOOR
+ ) {
+ code = 'NETWORK';
+ } else if (e.status === HTTP_CONFLICT) {
+ code = 'CONFLICT';
+ } else {
+ code = 'UNKNOWN';
+ }
+ return new FileSyncError(e.message, code, e.status);
+ }
+ // A thrown fetch (offline / DNS / reset) surfaces as a TypeError.
+ const networkLike = e instanceof TypeError;
+ return new FileSyncError(
+ e instanceof Error ? e.message : String(e),
+ networkLike ? 'NETWORK' : 'UNKNOWN',
+ );
+};
+
+/** Run a provider operation, mapping any Drive failure to a {@link FileSyncError}. */
+const wrap = async (fn: () => Promise): Promise => {
+ try {
+ return await fn();
+ } catch (e) {
+ throw mapDriveError(e);
+ }
+};
+
+/** Split an absolute logical path into non-empty segments. */
+const splitSegments = (path: string): string[] => path.split('/').filter((s) => s.length > 0);
+
+/** Join an absolute parent path and a child name. */
+const joinAbs = (parent: string, name: string): string =>
+ parent === '/' || parent === '' ? `/${name}` : `${parent}/${name}`;
+
+/** Absolute prefix of the first `count` segments (e.g. `/Readest/books`). */
+const prefixOf = (segments: string[], count: number): string =>
+ `/${segments.slice(0, count).join('/')}`;
+
+/** Map Drive file metadata onto a {@link FileEntry} at the given absolute path. */
+const toFileEntry = (path: string, file: DriveFile): FileEntry => {
+ const isDirectory = file.mimeType === FOLDER_MIME;
+ return {
+ name: file.name ?? splitSegments(path).pop() ?? path,
+ path,
+ isDirectory,
+ size: !isDirectory && file.size !== undefined ? Number(file.size) : undefined,
+ lastModified: file.modifiedTime,
+ };
+};
+
+/** Parse a `Retry-After` header (delta-seconds) into ms, or undefined for a date/absent. */
+const parseRetryAfterMs = (header: string | null): number | undefined => {
+ if (!header) return undefined;
+ const seconds = Number(header);
+ return Number.isFinite(seconds) ? seconds * MS_PER_SEC : undefined;
+};
+
+const defaultSleep: SleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+class DriveProviderImpl {
+ readonly rootPath = '/';
+
+ /**
+ * Memoised absolute-path → Drive file id, scoped to this instance. The engine
+ * rebuilds the provider whenever settings change, so the cache lifetime is one
+ * sync session — long enough to avoid re-walking the tree, short enough that
+ * cross-session staleness cannot accumulate. Stale entries are also evicted on
+ * a 404 (see {@link evict}).
+ */
+ private readonly idCache = new Map();
+
+ /**
+ * In-flight folder creations keyed by absolute prefix. The engine runs books
+ * at concurrency 4, so several workers can simultaneously find `/Readest`
+ * missing and each create it. Serialising per prefix collapses that to one
+ * create; {@link findChild} then picks a deterministic winner if a race still
+ * produced duplicate folders.
+ */
+ private readonly folderLocks = new Map>();
+
+ constructor(
+ private readonly auth: DriveAuth,
+ private readonly fetchFn: FetchFn,
+ private readonly sleep: SleepFn = defaultSleep,
+ ) {}
+
+ async readText(path: string): Promise {
+ const res = await this.getMedia(path);
+ return res ? res.text() : null;
+ }
+
+ async readBinary(path: string): Promise {
+ const res = await this.getMedia(path);
+ return res ? res.arrayBuffer() : null;
+ }
+
+ async head(path: string): Promise {
+ const file = await this.statFresh(path, (id) => metadataUrl(id), 'stat');
+ if (file === null) return null;
+ return {
+ size: file.size !== undefined ? Number(file.size) : undefined,
+ etag: file.md5Checksum,
+ };
+ }
+
+ async list(path: string): Promise {
+ const folderId = await this.resolveFolderSegments(splitSegments(path), false);
+ // Absent folder -> empty listing (Drive has no path, so a missing parent is
+ // simply "no children"). Matches what the engine's discovery expects.
+ if (folderId === null) return [];
+ const entries: FileEntry[] = [];
+ let pageToken: string | undefined;
+ do {
+ const res = await this.authedFetch(listUrl(childrenQuery(folderId), pageToken), HTTP_GET);
+ await this.ensureOk(res, 'list', path);
+ const data = (await res.json()) as DriveFileListPage;
+ for (const file of data.files ?? []) {
+ const childPath = joinAbs(path, file.name ?? file.id);
+ this.idCache.set(childPath, file.id);
+ entries.push(toFileEntry(childPath, file));
+ }
+ pageToken = data.nextPageToken;
+ } while (pageToken);
+ return entries;
+ }
+
+ writeText(
+ path: string,
+ body: string,
+ contentType: string = DEFAULT_TEXT_CONTENT_TYPE,
+ ): Promise {
+ return this.writeBinary(
+ path,
+ new TextEncoder().encode(body).buffer as ArrayBuffer,
+ contentType,
+ );
+ }
+
+ async writeBinary(
+ path: string,
+ body: ArrayBuffer,
+ contentType: string = DEFAULT_BINARY_CONTENT_TYPE,
+ ): Promise {
+ const segments = splitSegments(path);
+ const name = segments.pop();
+ if (name === undefined)
+ throw new DriveHttpError(0, undefined, `Drive upload failed: empty path`);
+ // Auto-create the containing chain as a backup; the engine usually calls
+ // ensureDir first, but a write must still materialise its parents.
+ const folderId = await this.resolveFolderSegments(segments, true);
+ if (folderId === null) {
+ throw new DriveHttpError(
+ 0,
+ undefined,
+ `Drive upload failed: could not create folder for ${path}`,
+ );
+ }
+
+ const existingId = await this.findChild(name, folderId);
+ if (existingId !== null) {
+ // Overwrite in place, preserving the file id (and any links) rather than
+ // orphaning it and creating a duplicate name.
+ await this.uploadMedia(mediaUpdateUrl(existingId), HTTP_PATCH, body, contentType, path);
+ this.idCache.set(path, existingId);
+ } else {
+ // `uploadType=media` carries no metadata, so create-then-name: POST the
+ // bytes to root, then PATCH name + reparent under the resolved folder.
+ const created = await this.uploadMedia(simpleUploadUrl(), HTTP_POST, body, contentType, path);
+ await this.nameAndReparent(created.id, name, folderId, path);
+ this.idCache.set(path, created.id);
+ }
+ }
+
+ async ensureDir(paths: string[]): Promise {
+ // Create each directory's full chain (idempotent via cache + findChild). The
+ // engine passes ancestors top-down, so deeper paths reuse the cached parents.
+ for (const path of paths) {
+ await this.resolveFolderSegments(splitSegments(path), true);
+ }
+ }
+
+ async deleteDir(path: string): Promise {
+ const folderId = await this.resolveFolderSegments(splitSegments(path), false);
+ // Already absent — nothing to delete (idempotent).
+ if (folderId === null) return;
+ const res = await this.authedFetch(deleteUrl(folderId), HTTP_DELETE);
+ // Tolerate a 404: the dir may have been deleted concurrently, which still
+ // satisfies the caller's intent.
+ if (res.status === HTTP_NOT_FOUND) {
+ this.evict(path);
+ return;
+ }
+ await this.ensureOk(res, 'delete', path);
+ this.evict(path);
+ }
+
+ // --- read plumbing (with one stale-cache eviction retry) -----------------
+
+ /**
+ * GET a file's bytes, returning the Response or null when the file is absent.
+ * If a *cached* id 404s, the cache is stale (the file was deleted/recreated):
+ * evict and resolve once more before giving up.
+ */
+ private async getMedia(path: string): Promise {
+ const cachedId = this.idCache.get(path);
+ let fileId = await this.resolveFile(path);
+ if (fileId === null) return null;
+ let res = await this.authedFetch(mediaDownloadUrl(fileId), HTTP_GET);
+ if (res.status === HTTP_NOT_FOUND && cachedId !== undefined) {
+ this.evict(path);
+ fileId = await this.resolveFile(path);
+ if (fileId === null) return null;
+ res = await this.authedFetch(mediaDownloadUrl(fileId), HTTP_GET);
+ }
+ if (res.status === HTTP_NOT_FOUND) return null;
+ await this.ensureOk(res, 'download', path);
+ return res;
+ }
+
+ /** GET a file's metadata, with the same stale-cache eviction retry as reads. */
+ private async statFresh(
+ path: string,
+ urlFor: (id: string) => string,
+ operation: DriveOperation,
+ ): Promise {
+ const cachedId = this.idCache.get(path);
+ let fileId = await this.resolveFile(path);
+ if (fileId === null) return null;
+ let res = await this.authedFetch(urlFor(fileId), HTTP_GET);
+ if (res.status === HTTP_NOT_FOUND && cachedId !== undefined) {
+ this.evict(path);
+ fileId = await this.resolveFile(path);
+ if (fileId === null) return null;
+ res = await this.authedFetch(urlFor(fileId), HTTP_GET);
+ }
+ if (res.status === HTTP_NOT_FOUND) return null;
+ await this.ensureOk(res, operation, path);
+ return (await res.json()) as DriveFile;
+ }
+
+ // --- path resolution -----------------------------------------------------
+
+ /** Resolve an absolute file path to its Drive id, or null when any part is absent. */
+ private async resolveFile(path: string): Promise {
+ const cached = this.idCache.get(path);
+ if (cached !== undefined) return cached;
+
+ const segments = splitSegments(path);
+ const name = segments.pop();
+ if (name === undefined) return null;
+ const folderId = await this.resolveFolderSegments(segments, false);
+ if (folderId === null) return null;
+
+ const fileId = await this.findChild(name, folderId);
+ if (fileId !== null) this.idCache.set(path, fileId);
+ return fileId;
+ }
+
+ /**
+ * Resolve a chain of folder-name segments to the deepest folder id, from the
+ * Drive root. `create=true` materialises missing folders (write path);
+ * `create=false` returns null at the first missing segment (read path).
+ */
+ private async resolveFolderSegments(segments: string[], create: boolean): Promise {
+ let parentId = DRIVE_ROOT_ID;
+ for (let i = 0; i < segments.length; i++) {
+ const segment = segments[i]!;
+ const prefix = prefixOf(segments, i + 1);
+
+ const cached = this.idCache.get(prefix);
+ if (cached !== undefined) {
+ parentId = cached;
+ continue;
+ }
+
+ if (create) {
+ parentId = await this.resolveOrCreateFolder(prefix, segment, parentId);
+ } else {
+ const childId = await this.findChild(segment, parentId);
+ if (childId === null) return null;
+ this.idCache.set(prefix, childId);
+ parentId = childId;
+ }
+ }
+ return parentId;
+ }
+
+ /**
+ * Resolve-or-create one folder segment, serialised per prefix so concurrent
+ * workers create it at most once. After creating, re-query and pick the
+ * deterministic winner so a race that still produced duplicates converges.
+ */
+ private async resolveOrCreateFolder(
+ prefix: string,
+ segment: string,
+ parentId: string,
+ ): Promise {
+ const cached = this.idCache.get(prefix);
+ if (cached !== undefined) return cached;
+ const inflight = this.folderLocks.get(prefix);
+ if (inflight) return inflight;
+
+ const promise = (async (): Promise => {
+ const found = await this.findChild(segment, parentId);
+ if (found !== null) {
+ this.idCache.set(prefix, found);
+ return found;
+ }
+ const created = await this.createFolder(segment, parentId);
+ // Re-query so concurrent creators of the same name converge on one id
+ // (findChild picks the lexicographically smallest), not their own create.
+ const winner = (await this.findChild(segment, parentId)) ?? created;
+ this.idCache.set(prefix, winner);
+ return winner;
+ })();
+
+ this.folderLocks.set(prefix, promise);
+ try {
+ return await promise;
+ } finally {
+ this.folderLocks.delete(prefix);
+ }
+ }
+
+ /**
+ * Find a directly-nested child by name under `parentId`. Returns the
+ * deterministic (lexicographically smallest) id when multiple same-named
+ * children exist, so racing resolvers converge on one; null when none exist.
+ */
+ private async findChild(name: string, parentId: string): Promise {
+ const res = await this.authedFetch(listUrl(listQuery(name, parentId)), HTTP_GET);
+ await this.ensureOk(res, 'list', name);
+ const data = (await res.json()) as DriveFileListPage;
+ const ids = (data.files ?? []).map((f) => f.id);
+ if (ids.length === 0) return null;
+ ids.sort();
+ return ids[0]!;
+ }
+
+ /** Create an empty folder named `name` under `parentId`; return its new id. */
+ private async createFolder(name: string, parentId: string): Promise {
+ const res = await this.authedFetch(FILES_ENDPOINT, HTTP_POST, {
+ headers: { [CONTENT_TYPE_HEADER]: JSON_CONTENT_TYPE },
+ body: JSON.stringify({ name, mimeType: FOLDER_MIME, parents: [parentId] }),
+ });
+ await this.ensureOk(res, 'create folder', name);
+ const file = (await res.json()) as DriveFile;
+ return file.id;
+ }
+
+ /** Set a freshly-uploaded file's name and move it under its target folder. */
+ private async nameAndReparent(
+ fileId: string,
+ name: string,
+ folderId: string,
+ path: string,
+ ): Promise {
+ const res = await this.authedFetch(reparentUrl(fileId, folderId, DRIVE_ROOT_ID), HTTP_PATCH, {
+ headers: { [CONTENT_TYPE_HEADER]: JSON_CONTENT_TYPE },
+ body: JSON.stringify({ name }),
+ });
+ await this.ensureOk(res, 'set metadata', path);
+ }
+
+ /** Send a simple media upload (create POST or overwrite PATCH) and parse the result. */
+ private async uploadMedia(
+ url: string,
+ method: typeof HTTP_POST | typeof HTTP_PATCH,
+ body: ArrayBuffer,
+ contentType: string,
+ path: string,
+ ): Promise {
+ const res = await this.authedFetch(url, method, {
+ headers: { [CONTENT_TYPE_HEADER]: contentType },
+ body,
+ });
+ await this.ensureOk(res, 'upload', path);
+ return (await res.json()) as DriveFile;
+ }
+
+ // --- request plumbing ----------------------------------------------------
+
+ /** Issue an authenticated Drive request, retried on transient failures. */
+ private async authedFetch(url: string, method: string, init?: RequestInit): Promise {
+ return this.withBackoff(async () => {
+ const token = await this.auth.getAccessToken();
+ const headers: Record = {
+ Authorization: `Bearer ${token}`,
+ ...(init?.headers as Record | undefined),
+ };
+ return this.fetchFn(url, { ...init, method, headers });
+ });
+ }
+
+ /**
+ * Retry `fn` on a 429 / 5xx response with `Retry-After`-aware exponential
+ * backoff. A first full-sync of a large library at concurrency 4 multiplies
+ * Drive calls (per-segment resolution + 2-write create-then-name), so a 429 is
+ * expected, not exceptional. A thrown fetch is *not* retried here — it
+ * propagates and is mapped to NETWORK by {@link mapDriveError}.
+ */
+ private async withBackoff(fn: () => Promise): Promise {
+ for (let attempt = 0; ; attempt++) {
+ const res = await fn();
+ const transient =
+ res.status === HTTP_TOO_MANY_REQUESTS || res.status >= HTTP_SERVER_ERROR_FLOOR;
+ if (!transient || attempt >= MAX_BACKOFF_RETRIES) return res;
+ const retryAfter = parseRetryAfterMs(res.headers.get('Retry-After'));
+ await this.sleep(retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt);
+ }
+ }
+
+ /**
+ * Throw a context-carrying {@link DriveHttpError} for any non-success response.
+ * Callers handle the expected 404/null cases before calling this, so any status
+ * reaching here is a genuine failure. Reads Drive's error body to recover the
+ * `reason` (used to tell a 403 rate-limit from a 403 permission failure).
+ */
+ private async ensureOk(res: Response, operation: DriveOperation, path: string): Promise {
+ if (res.ok || res.status === HTTP_NO_CONTENT) return;
+ const reason = await readDriveErrorReason(res);
+ throw new DriveHttpError(
+ res.status,
+ reason,
+ `Drive ${operation} failed: HTTP ${res.status}${reason ? ` (${reason})` : ''} for ${path}`,
+ );
+ }
+
+ /** Evict an absolute path and every ancestor prefix from the id cache. */
+ private evict(path: string): void {
+ const segments = splitSegments(path);
+ for (let i = segments.length; i >= 1; i--) {
+ this.idCache.delete(prefixOf(segments, i));
+ }
+ }
+}
+
+/** Best-effort read of Drive's `error.errors[0].reason` for 403 classification. */
+const readDriveErrorReason = async (res: Response): Promise => {
+ try {
+ const body = (await res.json()) as DriveErrorBody;
+ return body.error?.errors?.[0]?.reason;
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Build a Google Drive {@link FileSyncProvider}. Each public method is wrapped so
+ * a Drive HTTP failure surfaces as a {@link FileSyncError} the engine can branch
+ * on — mirroring `createWebDAVProvider`. Streaming is intentionally omitted
+ * (PR1): the engine falls back to buffered read/write, and resumable Drive upload
+ * lands in a later phase to unlock large-book sync on mobile.
+ */
+export const createGoogleDriveProvider = (
+ auth: DriveAuth,
+ fetchFn: FetchFn,
+ options: GoogleDriveProviderOptions = {},
+): FileSyncProvider => {
+ const impl = new DriveProviderImpl(auth, fetchFn, options.sleep);
+ return {
+ rootPath: impl.rootPath,
+ readText: (path) => wrap(() => impl.readText(path)),
+ readBinary: (path) => wrap(() => impl.readBinary(path)),
+ head: (path) => wrap(() => impl.head(path)),
+ list: (path) => wrap(() => impl.list(path)),
+ writeText: (path, body, contentType) => wrap(() => impl.writeText(path, body, contentType)),
+ writeBinary: (path, body, contentType) => wrap(() => impl.writeBinary(path, body, contentType)),
+ ensureDir: (paths) => wrap(() => impl.ensureDir(paths)),
+ deleteDir: (path) => wrap(() => impl.deleteDir(path)),
+ };
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/PersistedDriveAuth.ts b/apps/readest-app/src/services/sync/providers/gdrive/PersistedDriveAuth.ts
new file mode 100644
index 00000000..f424d64c
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/PersistedDriveAuth.ts
@@ -0,0 +1,113 @@
+/**
+ * Token-managing {@link DriveAuth}: hands the provider a currently-valid access
+ * token, refreshing transparently and persisting the result.
+ *
+ * The file-sync engine is concurrent (books at concurrency 4), so several Drive
+ * requests can find the access token expired at the same instant. Without care
+ * that would fire several parallel refreshes and several racing keychain writes.
+ * A single-flight guard collapses them: the first expiry starts one refresh, the
+ * rest await it, and exactly one merged token set is saved. Google omits the
+ * refresh token on a refresh response, so the previous one is carried forward.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+import { FileSyncError } from '@/services/sync/file/provider';
+import type { DriveAuth, FetchFn } from './GoogleDriveProvider';
+import { aboutUrl } from './driveRest';
+import { refreshAccessToken, type TokenSet } from './auth/tokenStore';
+import type { TokenPersistence } from './driveTokenStore';
+
+export interface PersistedDriveAuthDeps {
+ /** OAuth client ID (needed for the refresh grant). */
+ clientId: string;
+ /** Platform `fetch` for the refresh + `about.get` calls. */
+ fetchFn: FetchFn;
+ /** Where the token set is loaded from and saved to. */
+ persistence: TokenPersistence;
+ /**
+ * Token set captured at connect time, seeded so the first request needs no
+ * keychain read. Omit to lazily load from `persistence` on first use.
+ */
+ initialTokens?: TokenSet;
+ /** Injectable clock (epoch ms); defaults to {@link Date.now} so tests can pin it. */
+ now?: () => number;
+}
+
+export class PersistedDriveAuth implements DriveAuth {
+ private tokens: TokenSet | null;
+ private loaded: boolean;
+ private refreshInFlight: Promise | null = null;
+
+ constructor(private readonly deps: PersistedDriveAuthDeps) {
+ this.tokens = deps.initialTokens ?? null;
+ // A seeded token set means we already hold the freshest tokens; skip the
+ // lazy load so a just-connected session does not hit the keychain again.
+ this.loaded = deps.initialTokens !== undefined;
+ }
+
+ async getAccessToken(): Promise {
+ const tokens = await this.ensureValidTokens();
+ return tokens.accessToken;
+ }
+
+ /** Human-readable account label (email, falling back to display name) or null. */
+ async accountLabel(): Promise {
+ const token = await this.getAccessToken();
+ const res = await this.deps.fetchFn(aboutUrl(), {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) return null;
+ const body = (await res.json()) as { user?: { emailAddress?: string; displayName?: string } };
+ return body.user?.emailAddress ?? body.user?.displayName ?? null;
+ }
+
+ private now(): number {
+ return this.deps.now ? this.deps.now() : Date.now();
+ }
+
+ private async ensureValidTokens(): Promise {
+ if (!this.loaded) {
+ this.tokens = await this.deps.persistence.load();
+ this.loaded = true;
+ }
+ if (!this.tokens) {
+ throw new FileSyncError('Google Drive is not connected', 'AUTH_FAILED');
+ }
+ if (this.now() < this.tokens.expiresAt) return this.tokens;
+ return this.refresh();
+ }
+
+ private refresh(): Promise {
+ // Single-flight: concurrent callers share one in-flight refresh.
+ if (this.refreshInFlight) return this.refreshInFlight;
+
+ this.refreshInFlight = (async (): Promise => {
+ try {
+ // Re-check after winning the race: a refresh that completed while we were
+ // queued may already have produced a valid token — don't refresh twice.
+ if (this.tokens && this.now() < this.tokens.expiresAt) return this.tokens;
+ const refreshToken = this.tokens?.refreshToken;
+ if (!refreshToken) {
+ throw new FileSyncError('Google Drive session expired; reconnect', 'AUTH_FAILED');
+ }
+ const refreshed = await refreshAccessToken(
+ { refreshToken, clientId: this.deps.clientId },
+ this.deps.fetchFn,
+ );
+ // Google omits the refresh token on a refresh — carry the old one forward.
+ const merged: TokenSet = {
+ ...refreshed,
+ refreshToken: refreshed.refreshToken ?? refreshToken,
+ };
+ this.tokens = merged;
+ await this.deps.persistence.save(merged);
+ return merged;
+ } finally {
+ this.refreshInFlight = null;
+ }
+ })();
+
+ return this.refreshInFlight;
+ }
+}
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthDesktop.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthDesktop.ts
new file mode 100644
index 00000000..be236695
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthDesktop.ts
@@ -0,0 +1,236 @@
+/**
+ * Desktop wiring of the OAuth authorization-code + PKCE flow: the system browser
+ * plus a reverse-DNS custom-scheme deep link.
+ *
+ * Readest uses ONE iOS-type Google client (no secret) on every platform, whose
+ * only Google-accepted redirect is the reverse-DNS scheme
+ * `com.googleusercontent.apps.:/oauthredirect`. On desktop we open consent in
+ * the user's default browser; Google redirects to that scheme; the OS routes it
+ * back to the app (which self-registers it via `deep_link().register_all()` — no
+ * installer, no admin), and the `single-instance` / `onOpenUrl` channels deliver
+ * the redirect URL. Same client, same reverse-DNS redirect, same PKCE exchange —
+ * only the capture differs from the mobile runners.
+ *
+ * The one Windows subtlety: a browser process only recognises the scheme if it
+ * STARTED AFTER the scheme was registered (Windows snapshots protocol
+ * associations per-process at launch). The user's everyday browser is usually
+ * already running, so it may silently fail to route the redirect. To stay
+ * reliable we open the default browser first (best UX — keeps the Google
+ * session) and, if no redirect returns within a grace period, re-open consent in
+ * a freshly-spawned cold browser (the native `spawn_fresh_browser` command).
+ * Whichever browser returns first wins.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+import { invoke } from '@tauri-apps/api/core';
+import { getCurrentWindow } from '@tauri-apps/api/window';
+import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
+import { openUrl } from '@tauri-apps/plugin-opener';
+import { createPkcePair } from './pkce';
+import {
+ deriveReverseDnsRedirectScheme,
+ deriveReverseDnsRedirectUri,
+ matchesReverseDnsRedirect,
+} from './reverseDnsRedirect';
+import { runOAuthFlow, type OAuthClientConfig } from './oauthFlow';
+import { exchangeCode, type FetchFn, type TokenSet } from './tokenStore';
+
+/**
+ * Grace period before the cold-browser fallback. Long enough that a user
+ * consenting in their already-cold default browser completes first (so most
+ * connects never spawn a second window), short enough that the silent
+ * already-running-browser failure is recovered without the user feeling stuck.
+ */
+export const DEFAULT_FALLBACK_DELAY_MS = 25_000;
+
+/**
+ * Hard deadline after which an unfinished connect gives up and REJECTS, so the
+ * UI spinner clears and the user can retry. The external browser gives the app
+ * no signal when the user closes the consent tab, so without this an abandoned
+ * sign-in would hang forever. Deliberately generous: a real sign-in (account
+ * pick + password + 2FA, in a session-less window) can take many minutes.
+ */
+export const CONNECT_DEADLINE_MS = 15 * 60_000;
+
+/** Native command that opens a URL in a freshly-spawned, isolated browser. */
+const SPAWN_FRESH_BROWSER_COMMAND = 'spawn_fresh_browser';
+
+/** Payload of the desktop `single-instance` event (mirrors `useAppUrlIngress`). */
+interface SingleInstancePayload {
+ args: string[];
+ cwd: string;
+}
+
+/**
+ * Platform mechanics the desktop runner needs, injected so the
+ * open-browser/capture/fallback orchestration can be exercised headlessly. The
+ * production default ({@link defaultDesktopDeepLinkDeps}) binds these to the real
+ * Tauri plugins; tests pass fakes.
+ */
+export interface DesktopDeepLinkDeps {
+ /** Open the consent URL in the user's default browser. */
+ openDefaultBrowser: (url: string) => Promise;
+ /** Open the consent URL in a freshly-spawned cold browser process (fallback). */
+ spawnFreshBrowser: (url: string) => Promise;
+ /**
+ * Subscribe to every redirect URL the OS delivers, invoking `onUrl` for each.
+ * Resolves with an unlisten function. The runner filters the stream down to
+ * this client's reverse-DNS redirect itself.
+ */
+ subscribeRedirects: (onUrl: (url: string) => void) => Promise<() => void>;
+ /** Grace period before the cold-browser fallback fires. */
+ fallbackDelayMs: number;
+ /** Hard deadline after which an unfinished connect rejects. */
+ connectDeadlineMs: number;
+}
+
+/**
+ * Real Tauri capture: the OS hands a routed deep link to an already-running app
+ * via the `single-instance` event (Windows/Linux relaunch — the URL is `args[1]`)
+ * and via the Tauri v2 `onOpenUrl` channel. Both are armed; the caller unlistens
+ * on resolve. Cold-start launch URLs are intentionally not read here.
+ */
+const subscribeRedirectsViaTauri = async (onUrl: (url: string) => void): Promise<() => void> => {
+ const unlistenSingleInstance = await getCurrentWindow().listen(
+ 'single-instance',
+ ({ payload }) => {
+ const url = payload.args?.[1];
+ if (url) onUrl(url);
+ },
+ );
+ let unlistenOpenUrl: () => void;
+ try {
+ unlistenOpenUrl = await onOpenUrl((urls) => {
+ urls.forEach(onUrl);
+ });
+ } catch (error) {
+ // Don't leak the already-attached first listener if the second registration
+ // fails — an orphaned listener would cross-wire a later attempt.
+ unlistenSingleInstance();
+ throw error;
+ }
+ return () => {
+ unlistenSingleInstance();
+ unlistenOpenUrl();
+ };
+};
+
+/** Production desktop deps, bound to the real Tauri plugins. */
+export const defaultDesktopDeepLinkDeps: DesktopDeepLinkDeps = {
+ openDefaultBrowser: (url) => openUrl(url),
+ spawnFreshBrowser: (url) => invoke(SPAWN_FRESH_BROWSER_COMMAND, { url }),
+ subscribeRedirects: subscribeRedirectsViaTauri,
+ fallbackDelayMs: DEFAULT_FALLBACK_DELAY_MS,
+ connectDeadlineMs: CONNECT_DEADLINE_MS,
+};
+
+/**
+ * Await the reverse-DNS redirect, arming the cold-browser fallback and a hard
+ * deadline. Resolves with the first incoming URL whose scheme matches `scheme`;
+ * ignores every other URL; rejects if nothing arrives before the deadline.
+ * Listeners and timers are torn down on any outcome so a later attempt cannot
+ * cross-wire this one.
+ */
+const awaitRedirectWithFallback = (
+ scheme: string,
+ authUrlReady: Promise,
+ deps: DesktopDeepLinkDeps,
+): Promise =>
+ new Promise((resolve, reject) => {
+ let unlisten: (() => void) | undefined;
+ let settled = false;
+ const timers: ReturnType[] = [];
+
+ const cleanup = () => {
+ timers.forEach(clearTimeout);
+ unlisten?.();
+ };
+ const finish = (url: string) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve(url);
+ };
+ const fail = (error: unknown) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ reject(error);
+ };
+
+ deps
+ .subscribeRedirects((url) => {
+ if (matchesReverseDnsRedirect(url, scheme)) finish(url);
+ })
+ .then((dispose) => {
+ unlisten = dispose;
+ // The redirect can land before `subscribeRedirects` resolves its
+ // unlisten; dispose immediately if so to avoid leaking the listener.
+ if (settled) dispose();
+ })
+ .catch(fail);
+
+ // Fallback: the user's default browser may have started before the app
+ // registered the scheme, so it can't route the redirect. After a grace
+ // period, open consent again in a freshly-spawned (cold) browser process,
+ // which CAN route it back. The original listeners stay armed, so whichever
+ // browser returns first wins. A fallback failure (e.g. Edge absent) is
+ // logged, not fatal — the deadline below still bounds the wait.
+ timers.push(
+ setTimeout(() => {
+ authUrlReady
+ .then((authUrl) => deps.spawnFreshBrowser(authUrl))
+ .catch((error) => console.warn('Drive sync: cold-browser fallback failed', error));
+ }, deps.fallbackDelayMs),
+ );
+
+ // Hard deadline so an abandoned sign-in rejects and clears the UI spinner.
+ timers.push(
+ setTimeout(
+ () => fail(new Error('Google sign-in did not complete in time')),
+ deps.connectDeadlineMs,
+ ),
+ );
+ });
+
+/**
+ * Run the desktop reverse-DNS deep-link OAuth flow and return the resulting
+ * tokens. Wires {@link runOAuthFlow} with the desktop mechanics: open consent in
+ * the default browser and resolve with the redirect the OS routes back, falling
+ * back to a cold browser if the default one cannot route it.
+ *
+ * @param config - OAuth client identity + scopes (the iOS-type client, no secret).
+ * @param fetchFn - platform `fetch` used for the token exchange.
+ * @param deps - injected platform mechanics; defaults to the real Tauri wiring.
+ */
+export const runDesktopDeepLinkOAuth = (
+ config: OAuthClientConfig,
+ fetchFn: FetchFn,
+ deps: DesktopDeepLinkDeps = defaultDesktopDeepLinkDeps,
+): Promise => {
+ const redirectUri = deriveReverseDnsRedirectUri(config.clientId);
+ const scheme = deriveReverseDnsRedirectScheme(config.clientId);
+
+ // The cold-browser fallback needs the consent URL, which `runOAuthFlow` only
+ // hands to `openUrl`. Bridge the two with a deferred: `openUrl` supplies the
+ // URL, the fallback awaits it.
+ let provideAuthUrl!: (url: string) => void;
+ const authUrlReady = new Promise((resolve) => {
+ provideAuthUrl = resolve;
+ });
+
+ return runOAuthFlow(config.scope, {
+ createPkcePair,
+ newState: () => crypto.randomUUID(),
+ clientId: config.clientId,
+ openUrl: async (url) => {
+ provideAuthUrl(url);
+ await deps.openDefaultBrowser(url);
+ },
+ awaitRedirect: () => awaitRedirectWithFallback(scheme, authUrlReady, deps),
+ redirectUri,
+ exchange: ({ code, verifier, redirectUri: uri }) =>
+ exchangeCode({ code, verifier, clientId: config.clientId, redirectUri: uri }, fetchFn),
+ });
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthFlow.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthFlow.ts
new file mode 100644
index 00000000..e37c8ad8
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthFlow.ts
@@ -0,0 +1,93 @@
+/**
+ * Provider-agnostic orchestration of the OAuth 2.0 authorization-code flow with
+ * PKCE — the single place where the end-to-end sequence lives.
+ *
+ * The sequence (mint PKCE + state → build the auth URL → open it and capture the
+ * redirect → verify target/state and pull the code → exchange for tokens) is
+ * identical on every platform; only *how* the URL is opened and *how* the
+ * redirect is captured differ. Those platform mechanics are injected as
+ * {@link OAuthFlowDeps} so this module needs no Tauri plugins and no network,
+ * which makes the security-critical glue (state/PKCE handling) unit-testable
+ * headlessly while the desktop/mobile wrappers supply the real wiring.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+import { buildAuthUrl } from './pkce';
+import { parseRedirect } from './parseRedirect';
+import type { TokenSet } from './tokenStore';
+
+/**
+ * The OAuth client identity a platform wrapper needs to run a flow. The redirect
+ * URI is *not* here because it is platform-derived from the client id (the
+ * reverse-DNS scheme).
+ */
+export interface OAuthClientConfig {
+ /** OAuth client ID registered for this app in Google Cloud. */
+ clientId: string;
+ /** Space-delimited OAuth scopes to request (e.g. the Drive app-file scope). */
+ scope: string;
+}
+
+/**
+ * Platform mechanics the flow needs, injected so the orchestration stays pure.
+ * The real desktop/mobile wirings provide these from Tauri plugins; tests pass
+ * fakes. Each dependency is typed precisely to what the flow calls.
+ */
+export interface OAuthFlowDeps {
+ /** Mint a fresh PKCE verifier/challenge pair (see `pkce.ts`'s `createPkcePair`). */
+ createPkcePair: () => Promise<{ verifier: string; challenge: string }>;
+ /** Mint a fresh opaque anti-CSRF `state` value (e.g. `crypto.randomUUID`). */
+ newState: () => string;
+ /** OAuth client ID registered for this app in Google Cloud. */
+ clientId: string;
+ /** Open the authorization URL where the user consents (browser / deep link). */
+ openUrl: (url: string) => Promise;
+ /** Resolve with the full redirect URL once the provider bounces back. */
+ awaitRedirect: (redirectUri: string) => Promise;
+ /** Reverse-DNS redirect URI for this client; must match the auth request. */
+ redirectUri: string;
+ /** Exchange the authorization `code` (+ PKCE verifier) for tokens. */
+ exchange: (args: { code: string; verifier: string; redirectUri: string }) => Promise;
+}
+
+/**
+ * Run the OAuth authorization-code + PKCE flow and return the resulting tokens.
+ *
+ * @param scope - space-delimited OAuth scopes to request (passed in, never
+ * hardcoded, so this stays provider/feature-agnostic).
+ * @param deps - injected platform mechanics; see {@link OAuthFlowDeps}.
+ * @returns the {@link TokenSet} from the token exchange.
+ * @throws if the redirect fails the target/CSRF check, carries a provider error,
+ * or lacks a code (via `parseRedirect`), or if the exchange itself fails.
+ */
+export const runOAuthFlow = async (scope: string, deps: OAuthFlowDeps): Promise => {
+ const { challenge, verifier } = await deps.createPkcePair();
+ // `state` is minted here and verified by `parseRedirect` below; this is the
+ // CSRF guard proving the redirect answers *our* request, so it must be a fresh
+ // unguessable value per attempt and never reused across calls.
+ const state = deps.newState();
+
+ const authUrl = buildAuthUrl({
+ clientId: deps.clientId,
+ redirectUri: deps.redirectUri,
+ scope,
+ challenge,
+ state,
+ });
+
+ // Begin awaiting the redirect BEFORE opening the consent URL: opening can race
+ // ahead (the user may consent and the provider may redirect before a listener
+ // attached afterwards is ready), so the capture must already be armed.
+ const redirectPromise = deps.awaitRedirect(deps.redirectUri);
+ await deps.openUrl(authUrl);
+ const redirectUrl = await redirectPromise;
+
+ // Verifies the redirect target + state (CSRF) and extracts the code, throwing
+ // with a specific reason on error/mismatch/missing-code.
+ const { code } = parseRedirect(redirectUrl, state, deps.redirectUri);
+
+ // PKCE binds this code to us via the verifier whose challenge we sent above;
+ // redirectUri must match the one in the auth request exactly, so reuse it.
+ return deps.exchange({ code, verifier, redirectUri: deps.redirectUri });
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/parseRedirect.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/parseRedirect.ts
new file mode 100644
index 00000000..d78ae997
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/parseRedirect.ts
@@ -0,0 +1,95 @@
+/**
+ * Parse the OAuth redirect Google sends back after the consent screen and pull
+ * out the authorization `code`, with redirect-target and CSRF protection.
+ *
+ * The authorization-code flow returns its result as query parameters on a
+ * custom-scheme deep link
+ * (`com.googleusercontent.apps.:/oauthredirect?code=...&state=...`). Every
+ * platform funnels that URL through this single pure helper — no network, no
+ * platform APIs — so the security-critical checks live in exactly one place.
+ *
+ * The PKCE `state` value minted in `pkce.ts`'s `buildAuthUrl` is echoed back here
+ * unchanged; comparing it to the value we generated is the CSRF guard that proves
+ * this redirect answers *our* authorization request and was not injected by an
+ * attacker.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+
+/** Redirect query-parameter names Google returns. */
+const REDIRECT_PARAM = {
+ code: 'code',
+ state: 'state',
+ error: 'error',
+} as const;
+
+/** The authorization `code` extracted from a successful redirect. */
+export interface RedirectResult {
+ /** Short-lived authorization code to exchange for tokens at the token endpoint. */
+ code: string;
+}
+
+/**
+ * Extract the authorization `code` from an OAuth redirect URL, verifying the URL
+ * targets our exact redirect URI and that the returned `state` matches the one
+ * we sent.
+ *
+ * @param redirectUrl - the full redirect URL captured from the deep-link intent.
+ * @param expectedState - the `state` we generated for this authorization attempt
+ * (see `buildAuthUrl`); the redirect's `state` must equal it.
+ * @param expectedRedirectUri - the exact reverse-DNS redirect URI we requested;
+ * the redirect's scheme + path must match it. Defense-in-depth on top of the
+ * ingress scheme filter so a scheme-prefix-but-wrong-path URL cannot slip in.
+ * @returns the authorization `code` on success.
+ * @throws if the URL is not our redirect target, the provider reported an error,
+ * the `state` fails the CSRF check, or no `code` is present — each with a
+ * message naming which check failed.
+ */
+export const parseRedirect = (
+ redirectUrl: string,
+ expectedState: string,
+ expectedRedirectUri: string,
+): RedirectResult => {
+ const url = new URL(redirectUrl);
+ const expected = new URL(expectedRedirectUri);
+
+ // Target guard: the redirect must be aimed at the exact scheme + path we
+ // registered. A custom-scheme URL parses with `protocol` = the scheme (incl.
+ // trailing ':') and `pathname` = the part after it. Anything else is not our
+ // redirect, so reject before reading any of its params.
+ if (url.protocol !== expected.protocol || url.pathname !== expected.pathname) {
+ throw new Error(
+ `OAuth redirect target mismatch: expected "${expected.protocol}${expected.pathname}", ` +
+ `got "${url.protocol}${url.pathname}"`,
+ );
+ }
+
+ const params = url.searchParams;
+
+ // Error-first: when the user denies consent (or Google aborts), the redirect
+ // carries an `error` param and no `code`. Surfacing the provider's reason is
+ // the most actionable signal, so it must win over the CSRF/missing-code checks.
+ const error = params.get(REDIRECT_PARAM.error);
+ if (error) {
+ throw new Error(`OAuth redirect returned an error: ${error}`);
+ }
+
+ // CSRF guard: the redirect must echo back the exact `state` we generated for
+ // this attempt; a mismatch means it does not answer our request.
+ const state = params.get(REDIRECT_PARAM.state);
+ if (state !== expectedState) {
+ throw new Error(
+ `OAuth redirect state mismatch (CSRF guard): expected "${expectedState}", got "${state}"`,
+ );
+ }
+
+ // A redirect with no `code` is unusable — there is nothing to exchange for
+ // tokens — so fail loudly instead of returning an empty code.
+ const code = params.get(REDIRECT_PARAM.code);
+ if (!code) {
+ throw new Error('OAuth redirect is missing the authorization code');
+ }
+
+ return { code };
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/pkce.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/pkce.ts
new file mode 100644
index 00000000..8badf2aa
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/pkce.ts
@@ -0,0 +1,149 @@
+/**
+ * PKCE (Proof Key for Code Exchange, RFC 7636) helpers for Google's OAuth 2.0
+ * authorization-code flow, plus the matching authorization-URL builder.
+ *
+ * Readest ships as a distributed native app (desktop + mobile). Such public
+ * clients cannot keep a client secret confidential, so the flow uses PKCE
+ * instead: each authorization attempt mints a fresh random `verifier`, sends
+ * only its SHA-256 `challenge` to the authorization endpoint, and later proves
+ * possession of the original `verifier` at the token endpoint. This binds the
+ * authorization code to this client and defeats code-interception attacks.
+ *
+ * Pure functions only — no network, no platform APIs beyond Web Crypto.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+
+/**
+ * Number of random bytes drawn for the code verifier. RFC 7636 §4.1 requires the
+ * verifier to be 43-128 characters of the unreserved set; 64 bytes base64url-
+ * encode to 86 characters, comfortably inside the window at high entropy.
+ */
+const VERIFIER_RANDOM_BYTES = 64;
+
+/** Digest algorithm for the PKCE challenge; the only secure option per RFC 7636. */
+const CHALLENGE_DIGEST_ALGORITHM = 'SHA-256';
+
+/** Google's OAuth 2.0 authorization endpoint (the page where the user consents). */
+const GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
+
+/** OAuth query-parameter names sent to {@link GOOGLE_AUTH_ENDPOINT}. */
+const AUTH_PARAM = {
+ clientId: 'client_id',
+ redirectUri: 'redirect_uri',
+ responseType: 'response_type',
+ scope: 'scope',
+ codeChallenge: 'code_challenge',
+ codeChallengeMethod: 'code_challenge_method',
+ state: 'state',
+ accessType: 'access_type',
+ prompt: 'prompt',
+} as const;
+
+/** We run the authorization-code flow, so the endpoint must return a `code`. */
+const RESPONSE_TYPE_CODE = 'code';
+
+/** Tells Google the challenge is the SHA-256 (S256) transform, not plaintext. */
+const CODE_CHALLENGE_METHOD_S256 = 'S256';
+
+/**
+ * `offline` makes Google issue a refresh token alongside the access token, so
+ * the app can keep syncing after the short-lived access token expires without
+ * sending the user back through consent.
+ */
+const ACCESS_TYPE_OFFLINE = 'offline';
+
+/**
+ * `consent` forces the consent screen every time. Google only returns a refresh
+ * token on the *first* consent for a given client unless re-consent is forced,
+ * so this guarantees `access_type=offline` actually yields a refresh token.
+ */
+const PROMPT_CONSENT = 'consent';
+
+/** A cryptographically random PKCE verifier and its derived S256 challenge. */
+export interface PkcePair {
+ /** High-entropy secret kept by the client and replayed at the token endpoint. */
+ verifier: string;
+ /** Base64url(SHA-256(verifier)), the public value sent to the auth endpoint. */
+ challenge: string;
+}
+
+/** Inputs needed to assemble the Google authorization URL. */
+export interface AuthUrlParams {
+ /** OAuth client ID registered for this app in Google Cloud. */
+ clientId: string;
+ /** Where Google redirects after consent (the reverse-DNS scheme). */
+ redirectUri: string;
+ /** Space-delimited OAuth scopes (e.g. the Drive app-file scope). */
+ scope: string;
+ /** The PKCE `code_challenge` produced by {@link createPkcePair}. */
+ challenge: string;
+ /** Opaque anti-CSRF value echoed back by Google for the caller to verify. */
+ state: string;
+}
+
+/**
+ * Encode raw bytes as base64url **without padding**, per RFC 7636 §A. PKCE
+ * values travel in URLs, so `+`→`-`, `/`→`_`, and trailing `=` removed.
+ */
+const base64UrlEncode = (bytes: Uint8Array): string => {
+ let binary = '';
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+};
+
+/**
+ * Derive the PKCE `code_challenge` from a verifier (RFC 7636 §4.6: the S256
+ * transform is `base64url(SHA-256(ASCII(verifier)))`).
+ *
+ * Exported so the exact transform is testable against the spec's known-answer
+ * vector — the most common PKCE bug is hashing the raw verifier bytes instead
+ * of the ASCII octets of the verifier string, which a random-input test cannot
+ * catch.
+ */
+export const computeChallenge = async (verifier: string): Promise => {
+ const verifierBytes = new TextEncoder().encode(verifier);
+ const digest = await crypto.subtle.digest(CHALLENGE_DIGEST_ALGORITHM, verifierBytes);
+ return base64UrlEncode(new Uint8Array(digest));
+};
+
+/**
+ * Create a fresh PKCE verifier/challenge pair. The verifier is base64url-encoded
+ * random bytes, which keeps it inside the RFC 7636 unreserved-character set and
+ * length window by construction.
+ */
+export const createPkcePair = async (): Promise => {
+ const randomBytes = crypto.getRandomValues(new Uint8Array(VERIFIER_RANDOM_BYTES));
+ const verifier = base64UrlEncode(randomBytes);
+ const challenge = await computeChallenge(verifier);
+ return { verifier, challenge };
+};
+
+/**
+ * Build the Google authorization URL the user is sent to in order to grant
+ * access. The caller opens this URL and later exchanges the returned `code` plus
+ * the PKCE `verifier` for tokens.
+ */
+export const buildAuthUrl = ({
+ clientId,
+ redirectUri,
+ scope,
+ challenge,
+ state,
+}: AuthUrlParams): string => {
+ const url = new URL(GOOGLE_AUTH_ENDPOINT);
+ const params = url.searchParams;
+ params.set(AUTH_PARAM.clientId, clientId);
+ params.set(AUTH_PARAM.redirectUri, redirectUri);
+ params.set(AUTH_PARAM.responseType, RESPONSE_TYPE_CODE);
+ params.set(AUTH_PARAM.scope, scope);
+ params.set(AUTH_PARAM.codeChallenge, challenge);
+ params.set(AUTH_PARAM.codeChallengeMethod, CODE_CHALLENGE_METHOD_S256);
+ params.set(AUTH_PARAM.state, state);
+ params.set(AUTH_PARAM.accessType, ACCESS_TYPE_OFFLINE);
+ params.set(AUTH_PARAM.prompt, PROMPT_CONSENT);
+ return url.toString();
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/reverseDnsRedirect.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/reverseDnsRedirect.ts
new file mode 100644
index 00000000..c315102f
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/reverseDnsRedirect.ts
@@ -0,0 +1,78 @@
+/**
+ * The reverse-DNS OAuth redirect shared by every platform's runner.
+ *
+ * Readest uses ONE iOS-type Google client (no secret, no SHA-1) on every
+ * platform, whose only Google-accepted redirect is the reverse-DNS "iOS URL
+ * scheme" — {@link GOOGLE_OAUTH_REDIRECT_SCHEME_PREFIX} followed by the client
+ * id's identifier part. Both the desktop deep-link runner and the Android
+ * Custom-Tab runner derive the exact same redirect from the client id here, so
+ * the auth request, the token exchange, and the registered intent-filter /
+ * desktop scheme stay byte-for-byte in agreement.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+
+/**
+ * Scheme prefix of Google's reverse-DNS "iOS URL scheme". The redirect scheme is
+ * this followed by the client id's identifier part. Exported so the deep-link
+ * ingress filter detects and skips OAuth redirect URLs without restating the
+ * literal.
+ */
+export const GOOGLE_OAUTH_REDIRECT_SCHEME_PREFIX = 'com.googleusercontent.apps.';
+
+/** Suffix every Google OAuth client id ends with; stripped to form the scheme. */
+const GOOGLE_CLIENT_ID_SUFFIX = '.apps.googleusercontent.com';
+
+/** Path the redirect targets after the reverse-DNS scheme (a SINGLE slash). */
+const OAUTH_REDIRECT_PATH = ':/oauthredirect';
+
+/**
+ * Derive the reverse-DNS redirect SCHEME from a Google OAuth client id.
+ *
+ * Google issues an iOS-type client the scheme
+ * {@link GOOGLE_OAUTH_REDIRECT_SCHEME_PREFIX}``, where `` is the client id
+ * minus its `.apps.googleusercontent.com` suffix. This is the bare scheme (no
+ * path) — what an OS intent-filter / registry key registers. Lower-cased
+ * because OS scheme matching is case-sensitive and lowercase.
+ */
+export const deriveReverseDnsRedirectScheme = (clientId: string): string => {
+ const identifier = clientId.endsWith(GOOGLE_CLIENT_ID_SUFFIX)
+ ? clientId.slice(0, -GOOGLE_CLIENT_ID_SUFFIX.length)
+ : clientId;
+ return `${GOOGLE_OAUTH_REDIRECT_SCHEME_PREFIX}${identifier.toLowerCase()}`;
+};
+
+/**
+ * Derive the full reverse-DNS redirect URI from a Google OAuth client id: the
+ * {@link deriveReverseDnsRedirectScheme} scheme followed by `:/oauthredirect`
+ * (a SINGLE slash). Deriving it — rather than hardcoding one builder's value —
+ * keeps the auth request, the token exchange, and the registered redirect in
+ * byte-for-byte agreement.
+ */
+export const deriveReverseDnsRedirectUri = (clientId: string): string =>
+ `${deriveReverseDnsRedirectScheme(clientId)}${OAUTH_REDIRECT_PATH}`;
+
+/**
+ * Whether an OS-delivered URL is *this* client's reverse-DNS OAuth redirect.
+ *
+ * Used by the desktop deep-link runner and the deep-link ingress filter to pick
+ * our redirect out of the stream of URLs the OS can hand the app (book files,
+ * other deep links). Matched case-insensitively because Windows can relaunch the
+ * app with a differently-cased scheme in argv than was registered; the trailing
+ * `:` is required so a scheme that is a prefix of a longer one cannot false-match.
+ */
+export const matchesReverseDnsRedirect = (url: string, scheme: string): boolean =>
+ url.toLowerCase().startsWith(`${scheme.toLowerCase()}:`);
+
+/**
+ * Whether a URL is *any* Google reverse-DNS OAuth redirect, regardless of which
+ * client id it targets. Used by the deep-link ingress to drop OAuth redirects
+ * from the `app-incoming-url` broadcast so no consumer (e.g. the book-import
+ * path) mistakes `com.googleusercontent.apps.:/oauthredirect?...` for a file.
+ * The OAuth runner's own `single-instance` / `onOpenUrl` listeners still receive
+ * it directly. Matching the scheme prefix (not a specific client id) keeps this
+ * robust and independent of the env-baked client.
+ */
+export const isGoogleOAuthRedirectUrl = (url: string): boolean =>
+ url.toLowerCase().startsWith(GOOGLE_OAUTH_REDIRECT_SCHEME_PREFIX.toLowerCase());
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/tokenStore.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/tokenStore.ts
new file mode 100644
index 00000000..ec60d9b5
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/tokenStore.ts
@@ -0,0 +1,184 @@
+/**
+ * Google OAuth 2.0 token endpoint operations for the PKCE authorization-code
+ * flow: exchanging an authorization `code` for tokens, and later refreshing the
+ * short-lived access token.
+ *
+ * Readest's official Google client is the iOS application type, which has NO
+ * client secret — neither request sends one. The authorization code is instead
+ * bound to this client by replaying the PKCE `code_verifier` (see `pkce.ts`).
+ *
+ * The request/parse logic is pure given an injected `fetch`, which keeps it
+ * testable without a network and platform-agnostic.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission.
+ */
+
+/** Google's OAuth 2.0 token endpoint (exchanges/refreshes tokens). */
+export const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+
+/**
+ * Renew the access token this many seconds *before* its real expiry. Google
+ * reports the lifetime via `expires_in`; trimming the usable lifetime by a small
+ * margin guarantees the token is comfortably valid for the duration of any
+ * single request we make with it, despite client/Google clock skew.
+ */
+export const TOKEN_EXPIRY_SAFETY_MARGIN_SEC = 60;
+
+/** Milliseconds per second — `expires_in` is in seconds, `expiresAt` in ms. */
+const MS_PER_SEC = 1000;
+
+/** Token-request form field names. */
+const TOKEN_PARAM = {
+ grantType: 'grant_type',
+ code: 'code',
+ codeVerifier: 'code_verifier',
+ clientId: 'client_id',
+ redirectUri: 'redirect_uri',
+ refreshToken: 'refresh_token',
+} as const;
+
+/** OAuth grant types this module uses at the token endpoint. */
+const GRANT_TYPE = {
+ authorizationCode: 'authorization_code',
+ refreshToken: 'refresh_token',
+} as const;
+
+/** The token endpoint expects a URL-encoded form body, not JSON. */
+const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded';
+const CONTENT_TYPE_HEADER = 'Content-Type';
+const HTTP_POST = 'POST';
+
+/**
+ * Injected fetch implementation. Typed narrowly to exactly what this module
+ * needs so callers can pass the platform's `fetch` (or a stub in tests).
+ */
+export type FetchFn = (input: string, init: RequestInit) => Promise;
+
+/** A set of tokens with the access token's expiry resolved to absolute time. */
+export interface TokenSet {
+ /** Short-lived bearer token used to authorize Google API calls. */
+ accessToken: string;
+ /** Long-lived token used to mint new access tokens; absent on refresh. */
+ refreshToken?: string;
+ /** Absolute expiry as epoch milliseconds, already adjusted for the margin. */
+ expiresAt: number;
+}
+
+/** Inputs for {@link exchangeCode}. */
+export interface ExchangeCodeParams {
+ /** Authorization code Google returned to the redirect URI. */
+ code: string;
+ /** PKCE verifier whose challenge was sent to the authorization endpoint. */
+ verifier: string;
+ /** OAuth client ID registered for this app in Google Cloud. */
+ clientId: string;
+ /** Redirect URI used in the authorization request; must match exactly. */
+ redirectUri: string;
+}
+
+/** Inputs for {@link refreshAccessToken}. */
+export interface RefreshTokenParams {
+ /** Refresh token obtained from a prior {@link exchangeCode}. */
+ refreshToken: string;
+ /** OAuth client ID registered for this app in Google Cloud. */
+ clientId: string;
+}
+
+/**
+ * Raw JSON shape Google's token endpoint returns on success. Modeled explicitly
+ * (rather than `any`) so the mapping into {@link TokenSet} is type-checked.
+ */
+interface TokenEndpointResponse {
+ access_token: string;
+ refresh_token?: string;
+ /** Access-token lifetime in seconds from the moment of issue. */
+ expires_in: number;
+}
+
+/** Which token-endpoint call is being made; used only for error labelling. */
+type TokenOperation = 'exchange' | 'refresh';
+
+/**
+ * Best-effort read of Google's error payload so a thrown error can name the
+ * cause. The token endpoint returns `{ error, error_description }` on failure
+ * (e.g. `invalid_grant`, `redirect_uri_mismatch`) — the most useful signal when
+ * debugging the live flow. Reading the body can itself fail, so any problem
+ * collapses to no detail rather than masking the original HTTP error.
+ */
+const readErrorDetail = async (res: Response): Promise => {
+ try {
+ const body = (await res.json()) as { error?: string; error_description?: string };
+ const parts = [body.error, body.error_description].filter(Boolean);
+ return parts.length > 0 ? `: ${parts.join(' — ')}` : '';
+ } catch {
+ return '';
+ }
+};
+
+/**
+ * POST a form body to the token endpoint, parse the JSON, and map it into a
+ * {@link TokenSet}. Shared by both operations so the request/parse/error logic
+ * lives in exactly one place.
+ */
+const requestTokens = async (
+ params: URLSearchParams,
+ operation: TokenOperation,
+ fetchFn: FetchFn,
+): Promise => {
+ const res = await fetchFn(GOOGLE_TOKEN_ENDPOINT, {
+ method: HTTP_POST,
+ headers: { [CONTENT_TYPE_HEADER]: FORM_CONTENT_TYPE },
+ body: params.toString(),
+ });
+
+ if (!res.ok) {
+ const detail = await readErrorDetail(res);
+ throw new Error(`Google token ${operation} failed with HTTP ${res.status}${detail}`);
+ }
+
+ const data = (await res.json()) as TokenEndpointResponse;
+ // Clamp so an unusually short-lived token (expires_in <= margin) is never
+ // assigned an expiry in the past, which would mark a just-issued token as
+ // already expired and trigger a needless immediate refresh.
+ const usableLifetimeSec = Math.max(0, data.expires_in - TOKEN_EXPIRY_SAFETY_MARGIN_SEC);
+ return {
+ accessToken: data.access_token,
+ refreshToken: data.refresh_token,
+ expiresAt: Date.now() + usableLifetimeSec * MS_PER_SEC,
+ };
+};
+
+/**
+ * Exchange an authorization `code` (plus the PKCE `verifier`) for an access and
+ * refresh token. PKCE binds the code to this client; no client secret is sent
+ * (Readest's client is the iOS application type, which has none).
+ */
+export const exchangeCode = (
+ { code, verifier, clientId, redirectUri }: ExchangeCodeParams,
+ fetchFn: FetchFn,
+): Promise => {
+ const params = new URLSearchParams();
+ params.set(TOKEN_PARAM.grantType, GRANT_TYPE.authorizationCode);
+ params.set(TOKEN_PARAM.code, code);
+ params.set(TOKEN_PARAM.codeVerifier, verifier);
+ params.set(TOKEN_PARAM.clientId, clientId);
+ params.set(TOKEN_PARAM.redirectUri, redirectUri);
+ return requestTokens(params, 'exchange', fetchFn);
+};
+
+/**
+ * Trade a refresh token for a fresh access token once the previous one nears
+ * expiry. Google does not return a new refresh token here, so the caller keeps
+ * the existing one.
+ */
+export const refreshAccessToken = (
+ { refreshToken, clientId }: RefreshTokenParams,
+ fetchFn: FetchFn,
+): Promise => {
+ const params = new URLSearchParams();
+ params.set(TOKEN_PARAM.grantType, GRANT_TYPE.refreshToken);
+ params.set(TOKEN_PARAM.refreshToken, refreshToken);
+ params.set(TOKEN_PARAM.clientId, clientId);
+ return requestTokens(params, 'refresh', fetchFn);
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/buildGoogleDriveProvider.ts b/apps/readest-app/src/services/sync/providers/gdrive/buildGoogleDriveProvider.ts
new file mode 100644
index 00000000..b01eacf6
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/buildGoogleDriveProvider.ts
@@ -0,0 +1,50 @@
+/**
+ * Assemble a ready-to-use Google Drive {@link FileSyncProvider} from the pieces
+ * built in this folder: the env-baked OAuth client id, a CSP-bypassing native
+ * `fetch`, the keychain token store, and the single-flight {@link PersistedDriveAuth}.
+ *
+ * Returns `null` when Drive cannot run here — no client id baked into the build,
+ * or no secure token storage (web, or a Tauri keychain that failed to probe).
+ * Callers treat `null` as "this backend is unavailable" rather than surfacing a
+ * half-built provider that would fail on first use.
+ */
+import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
+import { isTauriAppPlatform } from '@/services/environment';
+import type { FileSyncProvider } from '@/services/sync/file/provider';
+import { createGoogleDriveProvider, type FetchFn } from './GoogleDriveProvider';
+import { PersistedDriveAuth } from './PersistedDriveAuth';
+import { createDriveTokenPersistence } from './driveTokenStore';
+
+/**
+ * The official Readest Google OAuth client id (iOS application type, no secret),
+ * baked into the build so Drive sync works for every user out of the box. The
+ * only runtime client — there is no BYO, because the redirect scheme is derived
+ * from this id and registered in the platform manifests at build time (the
+ * `com.googleusercontent.apps.` schemes in `tauri.conf.json`). A forker
+ * overrides it via `NEXT_PUBLIC_GOOGLE_CLIENT_ID` at build (and must regenerate
+ * the manifest schemes to match). The client id is NOT a secret — it ships
+ * inside the app binary.
+ */
+const OFFICIAL_GOOGLE_CLIENT_ID =
+ '209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq.apps.googleusercontent.com';
+
+export const getGoogleClientId = (): string | undefined =>
+ process.env['NEXT_PUBLIC_GOOGLE_CLIENT_ID'] || OFFICIAL_GOOGLE_CLIENT_ID;
+
+/** Native `fetch` bypasses the WebView CSP for the googleapis.com hosts. */
+const resolveFetch = (): FetchFn =>
+ (isTauriAppPlatform() ? tauriFetch : globalThis.fetch) as unknown as FetchFn;
+
+export const buildGoogleDriveProvider = async (): Promise => {
+ const clientId = getGoogleClientId();
+ if (!clientId) return null;
+
+ // No ephemeral fallback for the refresh token: if secure storage is missing,
+ // Drive is simply not available here.
+ const persistence = await createDriveTokenPersistence();
+ if (!persistence) return null;
+
+ const fetchFn = resolveFetch();
+ const auth = new PersistedDriveAuth({ clientId, fetchFn, persistence });
+ return createGoogleDriveProvider(auth, fetchFn);
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/connectGoogleDrive.ts b/apps/readest-app/src/services/sync/providers/gdrive/connectGoogleDrive.ts
new file mode 100644
index 00000000..dfb87113
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/connectGoogleDrive.ts
@@ -0,0 +1,70 @@
+/**
+ * Orchestrates connecting / disconnecting Google Drive: run the platform OAuth
+ * flow, persist the token, and resolve the account label. Kept free of platform
+ * specifics (the OAuth runner is injected) so it is unit-testable and shared by
+ * every platform's connect button.
+ */
+import { PersistedDriveAuth } from './PersistedDriveAuth';
+import type { FetchFn } from './GoogleDriveProvider';
+import type { TokenPersistence } from './driveTokenStore';
+import type { OAuthClientConfig } from './auth/oauthFlow';
+import type { TokenSet } from './auth/tokenStore';
+
+/** The Drive scope: the app sees only the files it created (a private namespace). */
+export const DRIVE_FILE_SCOPE = 'https://www.googleapis.com/auth/drive.file';
+
+export interface ConnectGoogleDriveDeps {
+ /** The env-baked official OAuth client id. */
+ clientId: string;
+ /** Platform `fetch` for the token exchange + `about.get`. */
+ fetchFn: FetchFn;
+ /** Where the token set is saved (keychain). */
+ persistence: TokenPersistence;
+ /** Platform OAuth runner (desktop deep-link / Android Custom Tab / iOS Safari). */
+ runOAuth: (config: OAuthClientConfig, fetchFn: FetchFn) => Promise;
+}
+
+export interface ConnectGoogleDriveResult {
+ /** Connected account's email/display name, or null when it could not be read. */
+ accountLabel: string | null;
+}
+
+/**
+ * Run the platform OAuth flow, persist the resulting token set, and resolve the
+ * connected account's label.
+ *
+ * The token is saved BEFORE success is reported, and a save failure THROWS — the
+ * caller must not mark Drive enabled if the refresh token did not persist, since
+ * a "connected" account that vanishes on the next launch is worse than a failed
+ * connect. The account label is best-effort (null when `about.get` fails).
+ */
+export const connectGoogleDrive = async (
+ deps: ConnectGoogleDriveDeps,
+): Promise => {
+ const tokens = await deps.runOAuth(
+ { clientId: deps.clientId, scope: DRIVE_FILE_SCOPE },
+ deps.fetchFn,
+ );
+ // Fail-loud: if the keychain rejects the token, surface it so the UI does not
+ // enable Drive against a token that won't survive a restart.
+ await deps.persistence.save(tokens);
+
+ const auth = new PersistedDriveAuth({
+ clientId: deps.clientId,
+ fetchFn: deps.fetchFn,
+ persistence: deps.persistence,
+ initialTokens: tokens,
+ });
+ let accountLabel: string | null = null;
+ try {
+ accountLabel = await auth.accountLabel();
+ } catch (e) {
+ console.warn('[gdrive] account label fetch failed', e);
+ }
+ return { accountLabel };
+};
+
+/** Forget the stored Drive credentials (the settings flag is cleared by the caller). */
+export const disconnectGoogleDrive = async (persistence: TokenPersistence): Promise => {
+ await persistence.clear();
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/driveRest.ts b/apps/readest-app/src/services/sync/providers/gdrive/driveRest.ts
new file mode 100644
index 00000000..42e30a0b
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/driveRest.ts
@@ -0,0 +1,148 @@
+/**
+ * Pure request builders for the Google Drive v3 REST API.
+ *
+ * {@link GoogleDriveProvider} drives all of its file operations through the Drive
+ * REST endpoints. Drive is *ID-addressed*: there is no path-based lookup, so the
+ * provider resolves a logical path segment-by-segment with `files.list` search
+ * queries, then acts on the resolved file id with download/upload/metadata
+ * endpoints. The query strings and endpoint URLs those calls need are assembled
+ * here as small pure functions, kept apart from the provider so the exact wire
+ * format (quote escaping, query parameters, `fields` selectors, pagination) is
+ * unit-testable without a network and reads as a single source of truth.
+ *
+ * No `fetch`, no auth, no state — every export is a deterministic string builder.
+ *
+ * Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
+ * used with the author's explicit permission. Pagination + `about.get` added for
+ * Readest.
+ */
+
+/** Drive REST collection endpoint for file *metadata* operations (list/get/patch/delete). */
+export const FILES_ENDPOINT = 'https://www.googleapis.com/drive/v3/files';
+
+/**
+ * Drive REST *upload* endpoint (a different host path than {@link FILES_ENDPOINT}).
+ * Drive splits metadata operations from media transfer onto the `/upload/...`
+ * path, so the byte-carrying POST/PATCH requests target this base URL.
+ */
+export const UPLOAD_ENDPOINT = 'https://www.googleapis.com/upload/drive/v3/files';
+
+/** Drive REST endpoint for account info (`about.get`) — used to label the account. */
+export const ABOUT_ENDPOINT = 'https://www.googleapis.com/drive/v3/about';
+
+/** MIME type Drive assigns to folders; the marker we use to tell folders from blobs. */
+export const FOLDER_MIME = 'application/vnd.google-apps.folder';
+
+/**
+ * Max children returned per `files.list` page. Drive caps `pageSize` at 1000;
+ * requesting the max minimises round-trips for large hash directories while the
+ * provider still loops on `nextPageToken` to drain every page.
+ */
+const LIST_PAGE_SIZE = 1000;
+
+/**
+ * Escape a string literal for embedding inside a Drive search query. Drive query
+ * literals are wrapped in single quotes, so the backslash escape character and
+ * the single quote both have to be escaped, or a value like a file named
+ * `O'Brien` (or one ending in a backslash) breaks out of the literal and
+ * malforms the query. Backslashes are escaped FIRST so the backslashes added
+ * for the quotes are not doubled.
+ */
+export const escapeDriveLiteral = (s: string): string =>
+ s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+
+/**
+ * Build the `files.list` `q` to find a *named* child directly under a parent —
+ * the per-segment lookup that powers path resolution. `trashed = false` keeps
+ * tombstoned files (still listable in Drive) from masking a live file of the
+ * same name.
+ */
+export const listQuery = (name: string, parentId: string): string =>
+ `name = '${escapeDriveLiteral(name)}' and '${parentId}' in parents and trashed = false`;
+
+/**
+ * Build the `files.list` `q` to enumerate *all* live children of a parent — the
+ * query behind {@link GoogleDriveProvider.list}.
+ */
+export const childrenQuery = (parentId: string): string =>
+ `'${parentId}' in parents and trashed = false`;
+
+/**
+ * URL to download a file's raw bytes. `alt=media` switches the metadata `get`
+ * endpoint into returning the file *body* instead of its JSON metadata.
+ */
+export const mediaDownloadUrl = (fileId: string): string => `${FILES_ENDPOINT}/${fileId}?alt=media`;
+
+/**
+ * URL for a simple (single-request) media upload that creates a new file.
+ * `uploadType=media` means the request body *is* the file bytes (no multipart
+ * metadata envelope). `fields` narrows the JSON response to the new `id` plus
+ * the `md5Checksum`/`size`.
+ */
+export const simpleUploadUrl = (): string =>
+ `${UPLOAD_ENDPOINT}?uploadType=media&fields=id,md5Checksum,size`;
+
+/**
+ * URL to overwrite an *existing* file's bytes via a media PATCH. Same simple
+ * media transfer as {@link simpleUploadUrl}, targeted at a known file id.
+ */
+export const mediaUpdateUrl = (fileId: string): string =>
+ `${UPLOAD_ENDPOINT}/${fileId}?uploadType=media&fields=id,md5Checksum,size`;
+
+/**
+ * URL to fetch a single file's metadata, restricted to the {@link FileEntry}
+ * fields the provider exposes.
+ */
+export const metadataUrl = (fileId: string): string =>
+ `${FILES_ENDPOINT}/${fileId}?fields=${FILE_FIELDS}`;
+
+/** URL to delete a file by id. */
+export const deleteUrl = (fileId: string): string => `${FILES_ENDPOINT}/${fileId}`;
+
+/**
+ * URL for the metadata PATCH that renames a freshly-uploaded file and moves it
+ * from the upload's default root location into its target folder. `addParents`
+ * attaches the file to the destination folder and `removeParents` detaches it
+ * from `root`. Both are query parameters; only the new `name` rides in the body.
+ */
+export const reparentUrl = (
+ fileId: string,
+ addParentId: string,
+ removeParentId: string,
+): string => {
+ const url = new URL(`${FILES_ENDPOINT}/${fileId}`);
+ url.searchParams.set('addParents', addParentId);
+ url.searchParams.set('removeParents', removeParentId);
+ return url.toString();
+};
+
+/**
+ * URL for the `about.get` call that returns the signed-in user's identity. The
+ * `fields` selector is mandatory for `about`; we request only the display
+ * name + email so the settings UI can render "Connected as ".
+ */
+export const aboutUrl = (): string => `${ABOUT_ENDPOINT}?fields=user(displayName,emailAddress)`;
+
+/**
+ * The metadata `fields` selector requested for individual files and list rows.
+ * Drive omits unrequested fields entirely, so this is the contract for what the
+ * provider can read back: enough to build a {@link FileEntry}.
+ */
+const FILE_FIELDS = 'id,name,mimeType,size,modifiedTime,md5Checksum';
+
+/**
+ * Build a `files.list` request URL for the given query, asking for the
+ * id-resolution/metadata fields the provider uses from each row plus the
+ * `nextPageToken` that drives pagination. Pass `pageToken` to fetch a
+ * subsequent page; omit it for the first page.
+ */
+export const listUrl = (query: string, pageToken?: string): string => {
+ const url = new URL(FILES_ENDPOINT);
+ url.searchParams.set('q', query);
+ // `nextPageToken` rides alongside the per-row fields so a large directory is
+ // drained page by page rather than silently truncated at the first page.
+ url.searchParams.set('fields', `nextPageToken,files(${FILE_FIELDS})`);
+ url.searchParams.set('pageSize', String(LIST_PAGE_SIZE));
+ if (pageToken) url.searchParams.set('pageToken', pageToken);
+ return url.toString();
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/driveTokenStore.ts b/apps/readest-app/src/services/sync/providers/gdrive/driveTokenStore.ts
new file mode 100644
index 00000000..b9e5fb19
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/driveTokenStore.ts
@@ -0,0 +1,87 @@
+/**
+ * Persistence for the Google Drive OAuth token set, over the OS keychain.
+ *
+ * Unlike the sync passphrase — which falls back to an in-memory store on web —
+ * the Drive refresh token has NO ephemeral fallback: a refresh token is a
+ * long-lived credential, and "connected" UI that silently forgets the account on
+ * the next launch is worse than refusing to connect. So Drive connect requires
+ * real secure storage (Tauri keychain); {@link createDriveTokenPersistence}
+ * returns `null` when none is available, and the connect flow fails on `null`.
+ *
+ * The keychain is reached through the generic keyed secure-KV bridge commands
+ * (`set/get/clear_secure_item`), keyed by {@link DRIVE_TOKEN_KEY}, so the same
+ * native store the sync passphrase uses also holds the token set without a
+ * Drive-specific native command.
+ */
+import { isTauriAppPlatform } from '@/services/environment';
+import {
+ clearSecureItem,
+ getSecureItem,
+ isSyncKeychainAvailable,
+ setSecureItem,
+} from '@/utils/bridge';
+import { FileSyncError } from '@/services/sync/file/provider';
+import type { TokenSet } from './auth/tokenStore';
+
+/** Keychain key under which the serialised {@link TokenSet} is stored. */
+export const DRIVE_TOKEN_KEY = 'gdrive_token_set';
+
+/**
+ * Load / save / clear the Drive {@link TokenSet}. `load` is fail-soft (a keychain
+ * error reads as "not connected"); `save` is fail-loud (the connect flow must
+ * know the token did not persist, so it can refuse to mark Drive connected).
+ */
+export interface TokenPersistence {
+ load(): Promise;
+ save(tokens: TokenSet): Promise;
+ clear(): Promise;
+}
+
+/** OS-keychain backed {@link TokenPersistence} via the keyed secure-KV bridge. */
+export class KeychainTokenPersistence implements TokenPersistence {
+ async load(): Promise {
+ try {
+ const res = await getSecureItem({ key: DRIVE_TOKEN_KEY });
+ if (res.error || !res.value) return null;
+ return JSON.parse(res.value) as TokenSet;
+ } catch (err) {
+ console.warn('[gdrive] token load failed', err);
+ return null;
+ }
+ }
+
+ async save(tokens: TokenSet): Promise {
+ const res = await setSecureItem({ key: DRIVE_TOKEN_KEY, value: JSON.stringify(tokens) });
+ if (!res.success) {
+ throw new FileSyncError(
+ `OS keychain rejected the Drive token: ${res.error ?? 'unknown error'}`,
+ 'AUTH_FAILED',
+ );
+ }
+ }
+
+ async clear(): Promise {
+ try {
+ await clearSecureItem({ key: DRIVE_TOKEN_KEY });
+ } catch (err) {
+ console.warn('[gdrive] token clear failed', err);
+ }
+ }
+}
+
+/**
+ * Resolve the Drive token store, or `null` when secure persistence is
+ * unavailable (web, or a Tauri build whose keychain probe fails). Callers treat
+ * `null` as "Drive cannot be connected here" — there is deliberately no
+ * in-memory fallback for the refresh token.
+ */
+export const createDriveTokenPersistence = async (): Promise => {
+ if (!isTauriAppPlatform()) return null;
+ try {
+ const res = await isSyncKeychainAvailable();
+ if (res.available) return new KeychainTokenPersistence();
+ } catch (err) {
+ console.warn('[gdrive] keychain probe threw', err);
+ }
+ return null;
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts b/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts
new file mode 100644
index 00000000..90948fb0
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts
@@ -0,0 +1,44 @@
+/**
+ * Wire the platform OAuth runner + env client id + keychain into the
+ * {@link connectGoogleDrive} orchestration, so the settings UI's Connect button
+ * is a single call. Desktop only for now — Android / iOS runners land in later
+ * phases; the Drive row is hidden off-desktop.
+ */
+import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
+import { isTauriAppPlatform } from '@/services/environment';
+import { getGoogleClientId } from './buildGoogleDriveProvider';
+import { createDriveTokenPersistence } from './driveTokenStore';
+import { runDesktopDeepLinkOAuth } from './auth/oauthDesktop';
+import {
+ connectGoogleDrive,
+ disconnectGoogleDrive,
+ type ConnectGoogleDriveResult,
+} from './connectGoogleDrive';
+import type { FetchFn } from './GoogleDriveProvider';
+
+const resolveFetch = (): FetchFn =>
+ (isTauriAppPlatform() ? tauriFetch : globalThis.fetch) as unknown as FetchFn;
+
+/** Run the desktop Drive sign-in and return the connected account label. */
+export const runGoogleDriveConnect = async (): Promise => {
+ const clientId = getGoogleClientId();
+ if (!clientId) {
+ throw new Error('Google Drive is not configured in this build');
+ }
+ const persistence = await createDriveTokenPersistence();
+ if (!persistence) {
+ throw new Error('Google Drive requires the desktop app with secure storage');
+ }
+ return connectGoogleDrive({
+ clientId,
+ fetchFn: resolveFetch(),
+ persistence,
+ runOAuth: runDesktopDeepLinkOAuth,
+ });
+};
+
+/** Forget the stored Drive token (the settings flag is cleared by the caller). */
+export const runGoogleDriveDisconnect = async (): Promise => {
+ const persistence = await createDriveTokenPersistence();
+ if (persistence) await disconnectGoogleDrive(persistence);
+};
diff --git a/apps/readest-app/src/store/fileSyncStore.ts b/apps/readest-app/src/store/fileSyncStore.ts
new file mode 100644
index 00000000..d82929bf
--- /dev/null
+++ b/apps/readest-app/src/store/fileSyncStore.ts
@@ -0,0 +1,108 @@
+import { create } from 'zustand';
+import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
+
+/**
+ * Shared in-flight state for the library-wide file-sync "Sync now" run,
+ * generalised across backends (WebDAV, Google Drive, ...). Was `webdavSyncStore`.
+ *
+ * Lives outside React component state so the sync survives navigation inside the
+ * Settings dialog (drilling out to the Integrations list, or closing the dialog
+ * and reopening it) — a component `useState` would be destroyed on unmount,
+ * leaving a re-enabled "Sync now" button while the original `syncLibrary`
+ * promise was still running, with no progress affordance.
+ *
+ * Two responsibilities:
+ * - **Per-backend progress.** Each backend has its own progress entry under
+ * {@link byKind} so its Integrations row + form can render independently.
+ * - **A global library-sync mutex.** Every backend's `syncLibrary` mutates the
+ * SAME local library (adds downloaded books, reconciles metadata), so two
+ * backends must not run a manual Sync now at once. {@link beginSync} acquires
+ * the lock and returns `false` when another backend already holds it.
+ *
+ * Scope is deliberately narrow: only the manual library Sync now path uses this
+ * (the per-book reader hook tracks its own refs and surfaces no button), and it
+ * is process-local — never persisted, so an app killed mid-sync starts fresh.
+ */
+export interface ProviderSyncProgress {
+ /** True while this backend's library-wide Sync now is running. */
+ isSyncing: boolean;
+ /** Localised status line (e.g. "Uploading 3 / 12"), or null when idle. */
+ progressLabel: string | null;
+ /** Secondary line — the current book's title — or null. */
+ progressDetail: string | null;
+ /** Wall-clock millis when this backend's run kicked off, or null. */
+ startedAt: number | null;
+}
+
+/** Stable idle snapshot, so absent-backend selectors keep a constant identity. */
+const IDLE: ProviderSyncProgress = Object.freeze({
+ isSyncing: false,
+ progressLabel: null,
+ progressDetail: null,
+ startedAt: null,
+});
+
+interface FileSyncState {
+ /** Per-backend progress; absent entries are idle (see {@link IDLE}). */
+ byKind: Partial>;
+ /** The backend currently holding the library-sync lock, or null when free. */
+ activeKind: FileSyncBackendKind | null;
+
+ /**
+ * Acquire the library-sync mutex for `kind` and mark it syncing. Returns
+ * `true` on success; `false` (leaving state untouched) when another backend
+ * already holds the lock. Callers MUST honour a `false` return and not sync.
+ */
+ beginSync: (kind: FileSyncBackendKind, initialLabel: string) => boolean;
+ updateProgress: (kind: FileSyncBackendKind, label: string, detail?: string | null) => void;
+ endSync: (kind: FileSyncBackendKind) => void;
+}
+
+export const useFileSyncStore = create((set, get) => ({
+ byKind: {},
+ activeKind: null,
+
+ beginSync: (kind, initialLabel) => {
+ // Global mutex: only one backend's library sync at a time, since they all
+ // mutate the same local library.
+ if (get().activeKind !== null) return false;
+ set((s) => ({
+ activeKind: kind,
+ byKind: {
+ ...s.byKind,
+ [kind]: {
+ isSyncing: true,
+ progressLabel: initialLabel,
+ progressDetail: null,
+ startedAt: Date.now(),
+ },
+ },
+ }));
+ return true;
+ },
+
+ updateProgress: (kind, label, detail = null) =>
+ set((s) => ({
+ byKind: {
+ ...s.byKind,
+ [kind]: {
+ ...(s.byKind[kind] ?? IDLE),
+ isSyncing: true,
+ progressLabel: label,
+ progressDetail: detail,
+ },
+ },
+ })),
+
+ endSync: (kind) =>
+ set((s) => ({
+ activeKind: s.activeKind === kind ? null : s.activeKind,
+ byKind: { ...s.byKind, [kind]: IDLE },
+ })),
+}));
+
+/** Per-backend progress, idle when the backend has never started a run. */
+export const selectProviderSyncProgress =
+ (kind: FileSyncBackendKind) =>
+ (state: FileSyncState): ProviderSyncProgress =>
+ state.byKind[kind] ?? IDLE;
diff --git a/apps/readest-app/src/store/webdavSyncStore.ts b/apps/readest-app/src/store/webdavSyncStore.ts
deleted file mode 100644
index ee0c1d03..00000000
--- a/apps/readest-app/src/store/webdavSyncStore.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { create } from 'zustand';
-
-/**
- * Shared in-flight state for the library-wide WebDAV "Sync now" run.
- *
- * Lives outside React component state so the sync survives navigation
- * inside the Settings dialog (drilling out to the Integrations list, or
- * closing the dialog entirely and reopening it later) — `WebDAVForm`'s
- * old `useState` was destroyed on unmount, leaving the user with a
- * re-enabled "Sync now" button while the original `syncLibrary`
- * promise was still running off-thread, with no progress affordance
- * and the door open to spawning a second concurrent run.
- *
- * Scope is deliberately narrow:
- * - Only the manual library Sync now path uses this. The per-book
- * reader hook (`useWebDAVSync`) tracks its own state via refs and
- * doesn't surface a button.
- * - Not persisted to settings.json — process-local only. If the app
- * is killed mid-sync, the in-memory promise dies with the renderer
- * and this store starts fresh on next launch (which is the
- * correct semantic: an aborted run should not look like it's
- * still going).
- * - We don't track structured progress (counters, per-book status
- * etc.) — `syncLibrary.onProgress` already builds a localised
- * label, and we keep only a second `progressDetail` string (the
- * current book's title) so the form can render the status and the
- * book on separate lines. Formatting still lives in the callback.
- *
- * Re-entrancy: callers MUST gate on `isSyncing` *before* flipping it.
- * The `beginSync` action does not itself enforce mutual exclusion —
- * we keep the store dumb and let the handler decide because the
- * handler also has to do auth/library pre-flight checks that should
- * run after the gate.
- */
-interface WebDAVSyncState {
- /** True while a library-wide Sync now is currently running. */
- isSyncing: boolean;
- /**
- * Localised progress string (the status line, e.g. "Uploading 3 / 12").
- * Set by `syncLibrary.onProgress` via `updateProgress`. Null when no
- * run is active.
- */
- progressLabel: string | null;
- /**
- * Secondary line under the status — the current book's title. Null
- * before the first per-book callback (e.g. right after `beginSync`)
- * and when no run is active.
- */
- progressDetail: string | null;
- /** Wall-clock millis when the current run kicked off, or null. */
- startedAt: number | null;
-
- beginSync: (initialLabel: string) => void;
- updateProgress: (label: string, detail?: string | null) => void;
- endSync: () => void;
-}
-
-export const useWebDAVSyncStore = create((set) => ({
- isSyncing: false,
- progressLabel: null,
- progressDetail: null,
- startedAt: null,
-
- beginSync: (initialLabel) =>
- set({
- isSyncing: true,
- progressLabel: initialLabel,
- progressDetail: null,
- startedAt: Date.now(),
- }),
- updateProgress: (label, detail = null) => set({ progressLabel: label, progressDetail: detail }),
- endSync: () =>
- set({ isSyncing: false, progressLabel: null, progressDetail: null, startedAt: null }),
-}));
diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts
index 8333e81e..f77abe6e 100644
--- a/apps/readest-app/src/types/settings.ts
+++ b/apps/readest-app/src/types/settings.ts
@@ -144,6 +144,27 @@ export interface WebDAVSettings {
lastSyncedAt?: number;
}
+/**
+ * Google Drive file-sync settings. A second file-sync backend alongside
+ * {@link WebDAVSettings}, sharing the same engine, sub-toggles, and strategy
+ * vocabulary. Drive has no URL / credentials / root path (it is OAuth + a
+ * fixed `/Readest` namespace under the `drive.file` scope), and no BYO client.
+ * The OAuth token is NOT stored here — it lives in the OS keychain. `deviceId`
+ * and `lastSyncedAt` are device-local (excluded from cross-device restore).
+ */
+export interface GoogleDriveSettings {
+ enabled: boolean;
+ /** Connected account's email (or display name), shown in the settings UI. */
+ accountLabel?: string;
+ syncProgress?: boolean;
+ syncNotes?: boolean;
+ syncBooks?: boolean;
+ fullSync?: boolean;
+ strategy?: KOSyncStrategy;
+ deviceId?: string;
+ lastSyncedAt?: number;
+}
+
/**
* User-facing sync categories. 'progress' gates the existing book-config
* (reading progress) sync, 'note' gates annotations, 'book' gates book
@@ -296,6 +317,7 @@ export interface SystemSettings {
readwise: ReadwiseSettings;
hardcover: HardcoverSettings;
webdav: WebDAVSettings;
+ googleDrive: GoogleDriveSettings;
aiSettings: AISettings;
/**
diff --git a/apps/readest-app/src/utils/access.ts b/apps/readest-app/src/utils/access.ts
index 3bc13471..31566155 100644
--- a/apps/readest-app/src/utils/access.ts
+++ b/apps/readest-app/src/utils/access.ts
@@ -45,6 +45,17 @@ export const EMAIL_IN_PLANS: readonly UserPlan[] = ['plus', 'pro', 'purchase'];
export const isEmailInPlan = (plan: UserPlan): boolean =>
(EMAIL_IN_PLANS as readonly UserPlan[]).includes(plan);
+/**
+ * Plans that include third-party cloud sync (WebDAV / Google Drive): any paid
+ * plan — Plus, Pro, and Lifetime (`purchase`). Free users see an upgrade prompt
+ * in Settings and the reader's auto-sync stays off, so syncing to a personal
+ * cloud is a premium feature.
+ */
+export const CLOUD_SYNC_PLANS: readonly UserPlan[] = ['plus', 'pro', 'purchase'];
+
+export const isCloudSyncInPlan = (plan: UserPlan): boolean =>
+ (CLOUD_SYNC_PLANS as readonly UserPlan[]).includes(plan);
+
export const STORAGE_QUOTA_GRACE_BYTES = 10 * 1024 * 1024; // 10 MB grace
export const getStoragePlanData = (token: string) => {
diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts
index 714b6889..8e05d7b0 100644
--- a/apps/readest-app/src/utils/bridge.ts
+++ b/apps/readest-app/src/utils/bridge.ts
@@ -285,6 +285,47 @@ export async function isSyncKeychainAvailable(): Promise('plugin:native-bridge|is_sync_keychain_available');
}
+// ── Keyed secure key-value store ─────────────────────────────────────────
+// Tauri-only. A generic, keyed secret store over the same OS keychain backends
+// as the sync passphrase above, so secrets that aren't the single sync
+// passphrase (the Google Drive OAuth token set, and any future cloud
+// provider's refresh token) get the same XSS-free cross-launch persistence
+// without each needing its own native command. Availability is the same probe
+// as `is_sync_keychain_available`.
+
+export interface SetSecureItemRequest {
+ key: string;
+ value: string;
+}
+
+export interface GetSecureItemRequest {
+ key: string;
+}
+
+export interface SecureItemResponse {
+ success: boolean;
+ error?: string;
+}
+
+export interface GetSecureItemResponse {
+ value?: string;
+ error?: string;
+}
+
+export async function setSecureItem(request: SetSecureItemRequest): Promise {
+ return invoke('plugin:native-bridge|set_secure_item', { payload: request });
+}
+
+export async function getSecureItem(request: GetSecureItemRequest): Promise {
+ return invoke('plugin:native-bridge|get_secure_item', {
+ payload: request,
+ });
+}
+
+export async function clearSecureItem(request: GetSecureItemRequest): Promise {
+ return invoke('plugin:native-bridge|clear_secure_item', { 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