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//.<ext>` 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<FileSyncEngine | null>(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<boolean> => { + 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 - * `<rootPath>/Readest/books/<hash>/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<boolean> => { - 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/<hash>/) 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<SubPage>(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 ( <div className='my-4 w-full'> - <WebDAVForm onBack={() => setSubPage(null)} /> + <SubPageHeader + parentLabel={_('Integrations')} + currentLabel={_('WebDAV')} + description={_( + 'Sync your library, reading progress, and highlights with a WebDAV server.', + )} + onBack={() => setSubPage(null)} + /> + <WebDAVForm /> + </div> + ); + if (subPage === 'gdrive') + return ( + <div className='my-4 w-full'> + <SubPageHeader + parentLabel={_('Integrations')} + currentLabel={_('Google Drive')} + description={_( + 'Sync your library, reading progress, and highlights with your Google Drive.', + )} + onBack={() => setSubPage(null)} + /> + <GoogleDriveForm /> </div> ); 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')} /> - <IntegrationRow - icon={RiCloudLine} - title={_('WebDAV')} - status={webdavStatus} - onClick={() => setSubPage('webdav')} - /> <IntegrationRow icon={RiBookReadLine} title={_('Readwise')} @@ -205,6 +274,49 @@ const IntegrationsPanel: React.FC = () => { </div> </div> + <div className='w-full' data-setting-id='settings.integrations.cloudSync'> + <SectionTitle className='mb-2'>{_('Third-party Cloud Sync')}</SectionTitle> + <div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'> + <div className='divide-base-200 divide-y'> + {isCloudSyncPremium ? ( + <> + <CloudProviderRow + icon={RiCloudLine} + title={_('WebDAV')} + status={webdavStatus} + isActive={activeCloudKind === 'webdav'} + canActivate={webdavConfigured} + onActivate={() => activateCloudProvider('webdav')} + onOpen={() => setSubPage('webdav')} + activateLabel={_('Use WebDAV')} + /> + {appService?.isDesktopApp && ( + <CloudProviderRow + icon={RiGoogleLine} + title={_('Google Drive')} + status={gdriveStatus} + isActive={activeCloudKind === 'gdrive'} + canActivate={gdriveConfigured} + onActivate={() => 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. + <IntegrationRow + icon={RiCloudLine} + title={_('Cloud Sync')} + status={_('Available on Plus, Pro, or Lifetime')} + onClick={() => navigateToProfile(router)} + /> + )} + </div> + </div> + </div> + <div className='w-full' data-setting-id='settings.integrations.catalogs'> <SectionTitle className='mb-2'>{_('Content Sources')}</SectionTitle> <div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'> @@ -282,6 +394,86 @@ const IntegrationRow: React.FC<IntegrationRowProps> = ({ 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<CloudProviderRowProps> = ({ + icon: Icon, + title, + status, + isActive, + canActivate, + onActivate, + onOpen, + activateLabel, +}) => { + return ( + <div className='group flex w-full items-center gap-3 px-4 py-3'> + <button + type='button' + onClick={onOpen} + className={clsx( + 'flex min-w-0 flex-1 items-center gap-3 text-left', + 'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset', + )} + > + <span + className={clsx( + 'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full', + 'bg-base-200 text-base-content/70', + 'transition-colors duration-150', + 'group-hover:bg-base-300/70', + )} + > + <Icon className='h-5 w-5' /> + </span> + <div className='flex min-w-0 flex-1 flex-col gap-0.5'> + <SettingLabel>{title}</SettingLabel> + <span className='text-base-content/65 truncate text-[0.85em]'>{status}</span> + </div> + </button> + <input + type='radio' + name='cloud-sync-active' + className='radio radio-sm flex-shrink-0' + checked={isActive} + disabled={!canActivate} + onChange={onActivate} + aria-label={activateLabel} + title={activateLabel} + /> + <button + type='button' + onClick={onOpen} + aria-label={title} + className={clsx( + 'text-base-content/50 hover:text-base-content/80 flex-shrink-0 rounded', + 'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2', + )} + > + <MdChevronRight className='h-5 w-5' /> + </button> + </div> + ); +}; + 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<FileSyncFormSettings>) => Promise<void>; +} + +/** + * 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<FileSyncFormProps> = ({ 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<HTMLSelectElement>) => { + 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 ( + <BoxedList> + <SettingsSwitchRow + label={_('Upload Book Files')} + description={_('Uploads book files to your other devices.')} + checked={stored.syncBooks ?? false} + onChange={handleToggleSyncBooks} + /> + <SettingsSwitchRow + label={_('Full Sync')} + description={_('Re-check every book instead of only changed ones.')} + checked={stored.fullSync ?? false} + onChange={handleToggleFullSync} + /> + <SettingsRow label={_('Sync Strategy')}> + <SettingsSelect + value={stored.strategy ?? 'silent'} + onChange={handleStrategyChange} + ariaLabel={_('Sync Strategy')} + options={[ + { value: 'silent', label: _('Send and receive') }, + { value: 'send', label: _('Send changes only') }, + { value: 'receive', label: _('Receive changes only') }, + ]} + /> + </SettingsRow> + <SettingsRow + label={ + syncProgressLabel + ? syncProgressLabel + : stored.lastSyncedAt + ? _('Last synced {{when}}', { when: new Date(stored.lastSyncedAt).toLocaleString() }) + : _('Never synced') + } + description={ + syncProgressDetail ? ( + <span className='line-clamp-1'>{syncProgressDetail}</span> + ) : undefined + } + > + <button + type='button' + onClick={handleSyncNow} + disabled={isSyncing} + className={clsx('btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2', isSyncing && 'opacity-60')} + title={_('Sync now')} + aria-label={_('Sync now')} + > + {isSyncing ? ( + <span className='loading loading-spinner loading-xs' /> + ) : ( + <MdCloudSync className='h-4 w-4' /> + )} + {_('Sync now')} + </button> + </SettingsRow> + </BoxedList> + ); +}; + +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<typeof stored>) => { + 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 ( + <div className='space-y-5'> + <FileSyncForm kind='gdrive' stored={stored} persist={persistGDrive} /> + <div className='flex justify-end'> + <button type='button' onClick={handleDisconnect} className={disconnectButtonClass}> + {_('Disconnect')} + </button> + </div> + </div> + ); + } + + if (isConfigured) { + return ( + <div className='space-y-5'> + <Tips> + <li> + {_('Connected as {{account}}', { account: stored.accountLabel })} + {_('. Make Google Drive the active cloud provider.')} + </li> + </Tips> + <div className='flex justify-end gap-2'> + <button type='button' onClick={handleDisconnect} className={disconnectButtonClass}> + {_('Disconnect')} + </button> + <button type='button' onClick={handleActivate} className={primaryButtonClass}> + {_('Use Google Drive')} + </button> + </div> + </div> + ); + } + + return ( + <div className='space-y-5'> + <Tips> + <li>{_('Sign-in opens in your browser.')}</li> + <li>{_('Readest only accesses the files it creates in your Drive.')}</li> + </Tips> + <div className='flex justify-end pt-1'> + <button + type='button' + onClick={handleConnect} + disabled={isConnecting} + className={clsx(primaryButtonClass, isConnecting && 'opacity-60')} + > + {isConnecting ? ( + <> + <span className='loading loading-spinner loading-sm' /> + {_('Waiting for sign-in…')} + </> + ) : ( + _('Connect Google Drive') + )} + </button> + </div> + </div> + ); +}; + +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<WebDAVFormProps> = ({ 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<WebDAVFormProps> = ({ 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<typeof stored>) => { const latest = useSettingsStore.getState().settings; const next = { ...latest, webdav: { ...latest.webdav, ...patch } }; @@ -202,328 +111,138 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ 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<HTMLSelectElement>) => { - await persistWebdav({ strategy: e.target.value as typeof stored.strategy }); - }; + if (isActive) { + return ( + <div className='space-y-5'> + <FileSyncForm kind='webdav' stored={stored} persist={persistWebdav} /> - /** - * 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; + <WebDAVBrowsePane settings={stored} onUpdateSettings={persistWebdav} /> - // 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.'); + <div className='flex justify-end'> + <button + type='button' + onClick={handleDisconnect} + className={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', + )} + > + {_('Disconnect')} + </button> + </div> + </div> + ); + } return ( - <div className='w-full'> - <SubPageHeader - parentLabel={_('Integrations')} - currentLabel={_('WebDAV')} - description={description} - onBack={onBack} - /> + <form + className='space-y-4' + onSubmit={(e) => { + e.preventDefault(); + handleConnect(); + }} + > + <div className='space-y-1.5'> + <SectionTitle as='label' htmlFor='webdav-server-url' className='block'> + {_('Server URL')} + </SectionTitle> + <input + id='webdav-server-url' + type='text' + placeholder='https://dav.example.com' + className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none' + spellCheck='false' + value={url} + onChange={(e) => setUrl(e.target.value)} + /> + </div> - {isConfigured ? ( - <div className='space-y-5'> - {/* Sync controls — sub-category toggles, conflict strategy, - and a manual "Sync now" button. Mirrors the layout used - by KOSyncForm so users get a consistent surface. */} - <BoxedList> - <SettingsSwitchRow - label={_('Upload Book Files')} - description={_('Uploads book files to your other devices.')} - checked={stored.syncBooks ?? false} - onChange={handleToggleSyncBooks} - /> - <SettingsSwitchRow - label={_('Full Sync')} - description={_('Re-check every book instead of only changed ones.')} - checked={stored.fullSync ?? false} - onChange={handleToggleFullSync} - /> - <SettingsRow label={_('Sync Strategy')}> - <SettingsSelect - value={stored.strategy ?? 'silent'} - onChange={handleStrategyChange} - ariaLabel={_('Sync Strategy')} - options={[ - { value: 'silent', label: _('Send and receive') }, - { value: 'send', label: _('Send changes only') }, - { value: 'receive', label: _('Receive changes only') }, - ]} - /> - </SettingsRow> - <SettingsRow - label={ - syncProgressLabel - ? syncProgressLabel - : stored.lastSyncedAt - ? _('Last synced {{when}}', { - when: new Date(stored.lastSyncedAt).toLocaleString(), - }) - : _('Never synced') - } - description={ - syncProgressDetail ? ( - <span className='line-clamp-1'>{syncProgressDetail}</span> - ) : undefined - } - > - <button - type='button' - onClick={handleSyncNow} - disabled={isSyncing} - className={clsx( - 'btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2', - isSyncing && 'opacity-60', - )} - title={_('Sync now')} - aria-label={_('Sync now')} - > - {isSyncing ? ( - <span className='loading loading-spinner loading-xs' /> - ) : ( - <MdCloudSync className='h-4 w-4' /> - )} - {_('Sync now')} - </button> - </SettingsRow> - </BoxedList> + <div className='space-y-1.5'> + <SectionTitle as='label' htmlFor='webdav-username' className='block'> + {_('Username')} + </SectionTitle> + <input + id='webdav-username' + type='text' + placeholder={_('Your Username')} + className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none' + spellCheck='false' + value={username} + onChange={(e) => setUsername(e.target.value)} + autoComplete='username' + /> + </div> - <WebDAVBrowsePane settings={stored} onUpdateSettings={persistWebdav} /> - - <div className='flex justify-end'> - <button - type='button' - onClick={handleDisconnect} - className={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', - )} - > - {_('Disconnect')} - </button> - </div> - </div> - ) : ( - <div className='space-y-5'> - <form - className='space-y-4' - onSubmit={(e) => { - e.preventDefault(); - handleConnect(); - }} + <div className='space-y-1.5'> + <SectionTitle as='label' htmlFor='webdav-password' className='block'> + {_('Password')} + </SectionTitle> + <div className='relative'> + <input + id='webdav-password' + type={showPassword ? 'text' : 'password'} + placeholder={_('Your Password')} + className='input input-bordered eink-bordered h-11 w-full pe-11 text-sm focus:outline-none' + value={password} + onChange={(e) => setPassword(e.target.value)} + autoComplete='current-password' + /> + <button + type='button' + onClick={() => setShowPassword((v) => !v)} + className={clsx( + 'absolute end-2 top-1/2 -translate-y-1/2', + 'flex h-8 w-8 items-center justify-center rounded', + 'text-base-content/60 hover:text-base-content', + 'hover:bg-base-200/60 transition-colors duration-150', + 'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2', + )} + aria-label={showPassword ? _('Hide password') : _('Show password')} + title={showPassword ? _('Hide password') : _('Show password')} + tabIndex={-1} > - <div className='space-y-1.5'> - <SectionTitle as='label' htmlFor='webdav-server-url' className='block'> - {_('Server URL')} - </SectionTitle> - <input - id='webdav-server-url' - type='text' - placeholder='https://dav.example.com' - className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none' - spellCheck='false' - value={url} - onChange={(e) => setUrl(e.target.value)} - /> - </div> - - <div className='space-y-1.5'> - <SectionTitle as='label' htmlFor='webdav-username' className='block'> - {_('Username')} - </SectionTitle> - <input - id='webdav-username' - type='text' - placeholder={_('Your Username')} - className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none' - spellCheck='false' - value={username} - onChange={(e) => setUsername(e.target.value)} - autoComplete='username' - /> - </div> - - <div className='space-y-1.5'> - <SectionTitle as='label' htmlFor='webdav-password' className='block'> - {_('Password')} - </SectionTitle> - <div className='relative'> - <input - id='webdav-password' - type={showPassword ? 'text' : 'password'} - placeholder={_('Your Password')} - className='input input-bordered eink-bordered h-11 w-full pe-11 text-sm focus:outline-none' - value={password} - onChange={(e) => setPassword(e.target.value)} - autoComplete='current-password' - /> - <button - type='button' - onClick={() => setShowPassword((v) => !v)} - className={clsx( - 'absolute end-2 top-1/2 -translate-y-1/2', - 'flex h-8 w-8 items-center justify-center rounded', - 'text-base-content/60 hover:text-base-content', - 'hover:bg-base-200/60 transition-colors duration-150', - 'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2', - )} - aria-label={showPassword ? _('Hide password') : _('Show password')} - title={showPassword ? _('Hide password') : _('Show password')} - tabIndex={-1} - > - {showPassword ? ( - <MdVisibilityOff className='h-4 w-4' /> - ) : ( - <MdVisibility className='h-4 w-4' /> - )} - </button> - </div> - </div> - - <div className='space-y-1.5'> - <SectionTitle as='label' htmlFor='webdav-root' className='block'> - {_('Root Directory')} - </SectionTitle> - <input - id='webdav-root' - type='text' - placeholder='/' - className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none' - spellCheck='false' - value={rootPath} - onChange={(e) => setRootPath(e.target.value)} - /> - </div> - - <div className='flex justify-end pt-1'> - <button - type='submit' - disabled={isConnecting || !url || !username} - className={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', - isConnecting && 'opacity-60', - )} - > - {isConnecting ? ( - <span className='loading loading-spinner loading-sm' /> - ) : ( - _('Connect') - )} - </button> - </div> - </form> + {showPassword ? ( + <MdVisibilityOff className='h-4 w-4' /> + ) : ( + <MdVisibility className='h-4 w-4' /> + )} + </button> </div> - )} - </div> + </div> + + <div className='space-y-1.5'> + <SectionTitle as='label' htmlFor='webdav-root' className='block'> + {_('Root Directory')} + </SectionTitle> + <input + id='webdav-root' + type='text' + placeholder='/' + className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none' + spellCheck='false' + value={rootPath} + onChange={(e) => setRootPath(e.target.value)} + /> + </div> + + <div className='flex justify-end pt-1'> + <button + type='submit' + disabled={isConnecting || !url || !username} + className={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', + isConnecting && 'opacity-60', + )} + > + {isConnecting ? <span className='loading loading-spinner loading-sm' /> : _('Connect')} + </button> + </div> + </form> ); }; 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<SingleInstancePayload>( 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<SystemSettings> = { keepLogin: false, autoUpload: true, @@ -153,6 +164,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = { 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<FileSyncProvider | null> => { + 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/<hash>/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<string>; +} + +/** + * 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<Response>; + +/** Injected sleep so the retry backoff is instant under test. */ +export type SleepFn = (ms: number) => Promise<void>; + +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 <T>(fn: () => Promise<T>): Promise<T> => { + 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<string, string>(); + + /** + * 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<string, Promise<string>>(); + + constructor( + private readonly auth: DriveAuth, + private readonly fetchFn: FetchFn, + private readonly sleep: SleepFn = defaultSleep, + ) {} + + async readText(path: string): Promise<string | null> { + const res = await this.getMedia(path); + return res ? res.text() : null; + } + + async readBinary(path: string): Promise<ArrayBuffer | null> { + const res = await this.getMedia(path); + return res ? res.arrayBuffer() : null; + } + + async head(path: string): Promise<FileHead | null> { + 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<FileEntry[]> { + 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<void> { + 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<void> { + 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<void> { + // 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<void> { + 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<Response | null> { + 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<DriveFile | null> { + 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<string | null> { + 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<string | null> { + 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<string> { + 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<string> => { + 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<string | null> { + 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<string> { + 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<void> { + 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<DriveFile> { + 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<Response> { + return this.withBackoff(async () => { + const token = await this.auth.getAccessToken(); + const headers: Record<string, string> = { + Authorization: `Bearer ${token}`, + ...(init?.headers as Record<string, string> | 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<Response>): Promise<Response> { + 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<void> { + 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<string | undefined> => { + 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<TokenSet> | 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<string> { + const tokens = await this.ensureValidTokens(); + return tokens.accessToken; + } + + /** Human-readable account label (email, falling back to display name) or null. */ + async accountLabel(): Promise<string | null> { + 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<TokenSet> { + 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<TokenSet> { + // Single-flight: concurrent callers share one in-flight refresh. + if (this.refreshInFlight) return this.refreshInFlight; + + this.refreshInFlight = (async (): Promise<TokenSet> => { + 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.<id>:/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<void>; + /** Open the consent URL in a freshly-spawned cold browser process (fallback). */ + spawnFreshBrowser: (url: string) => Promise<void>; + /** + * 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<SingleInstancePayload>( + '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<void>(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<string>, + deps: DesktopDeepLinkDeps, +): Promise<string> => + new Promise<string>((resolve, reject) => { + let unlisten: (() => void) | undefined; + let settled = false; + const timers: ReturnType<typeof setTimeout>[] = []; + + 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<TokenSet> => { + 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<string>((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<void>; + /** Resolve with the full redirect URL once the provider bounces back. */ + awaitRedirect: (redirectUri: string) => Promise<string>; + /** 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<TokenSet>; +} + +/** + * 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<TokenSet> => { + 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.<id>:/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<string> => { + 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<PkcePair> => { + 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}`<X>`, where `<X>` 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.<id>:/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<Response>; + +/** 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<string> => { + 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<TokenSet> => { + 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<TokenSet> => { + 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<TokenSet> => { + 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.<id>` 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<FileSyncProvider | null> => { + 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<TokenSet>; +} + +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<ConnectGoogleDriveResult> => { + 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<void> => { + 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 <email>". + */ +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<TokenSet | null>; + save(tokens: TokenSet): Promise<void>; + clear(): Promise<void>; +} + +/** OS-keychain backed {@link TokenPersistence} via the keyed secure-KV bridge. */ +export class KeychainTokenPersistence implements TokenPersistence { + async load(): Promise<TokenSet | null> { + 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<void> { + 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<void> { + 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<TokenPersistence | null> => { + 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<ConnectGoogleDriveResult> => { + 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<void> => { + 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<Record<FileSyncBackendKind, ProviderSyncProgress>>; + /** 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<FileSyncState>((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<WebDAVSyncState>((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<SyncKeychainAvailableRe return invoke<SyncKeychainAvailableResponse>('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<SecureItemResponse> { + return invoke<SecureItemResponse>('plugin:native-bridge|set_secure_item', { payload: request }); +} + +export async function getSecureItem(request: GetSecureItemRequest): Promise<GetSecureItemResponse> { + return invoke<GetSecureItemResponse>('plugin:native-bridge|get_secure_item', { + payload: request, + }); +} + +export async function clearSecureItem(request: GetSecureItemRequest): Promise<SecureItemResponse> { + return invoke<SecureItemResponse>('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