forked from akai/readest
feat(sync): Google Drive cloud sync + premium Third-party Cloud Sync section (desktop) (#4821)
* feat(sync): add Google Drive file-sync provider core Second FileSyncProvider for the merged provider-agnostic file-sync engine, behind the provider seam. This is the CI-testable core only: no settings UI and no platform OAuth runners yet (those land in later phases). - GoogleDriveProvider over the Drive v3 REST API: id-addressed path resolution with a per-instance id cache, create-then-name uploads, real idempotent ensureDir, files.list pagination, Retry-After-aware 429/5xx backoff, per-path folder-creation locks with deterministic duplicate collapse, stale-id eviction, and FileSyncError mapping (403 split into rate-limit vs permission). - DI OAuth layer: pkce, parseRedirect (redirect-target + CSRF state), reverseDnsRedirect, tokenStore (iOS client, no secret), oauthFlow. - PersistedDriveAuth with single-flight token refresh; keychain-backed token store with no ephemeral fallback for the refresh token; account label via about.get. - providerRegistry (backend kind to provider) and buildGoogleDriveProvider assembly. - Shared transport-agnostic provider semantic contract, run against both WebDAV and Drive. - Keyed secure-KV bridge contract (set/get/clear_secure_item); the native keychain implementation lands with the desktop OAuth slice that first exercises it. Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's explicit permission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): multi-provider file-sync settings + sync-state foundation PR2 foundation for a second file-sync backend (Google Drive). The behaviour-sensitive reader-hook and Sync-now form generalization land in PR3 alongside OAuth, where Drive actually connects and the multi-provider paths can be exercised and live-verified (and the extracted form gets its second consumer, avoiding a single-use abstraction). - GoogleDriveSettings type (mirrors WebDAVSettings minus URL/credentials/ rootPath, plus accountLabel) wired into SystemSettings, with DEFAULT_GOOGLE_DRIVE_SETTINGS in the defaults. - googleDrive.deviceId + googleDrive.lastSyncedAt added to the backup blacklist so device-local sync identity / cursors never restore onto another device. Covered by the existing backup-settings test. - Generalize webdavSyncStore into fileSyncStore: per-backend progress keyed by provider kind, plus a global library-sync mutex (beginSync returns false when another backend already holds the lock) since every backend's syncLibrary mutates the same local library. Migrate WebDAVForm and IntegrationsPanel to the keyed API; WebDAV behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(native-bridge): add keyed secure key-value store commands A generic, keyed secret store over the same OS keychain backends as the sync passphrase (set/get/clear_secure_item), so secrets that aren't the single sync passphrase get the same XSS-free cross-launch persistence without each needing its own native command. The Google Drive OAuth token store (PR1's KeychainTokenPersistence) is the first consumer; a future cloud provider's refresh token reuses it. - Desktop (macOS/Windows/Linux): keyring-core, keyed by the item key as the entry account under the existing "Readest Safe Storage" service. - Android: EncryptedSharedPreferences (a dedicated readest_secure_items_v1 file, the item key as the pref key). - iOS: Security framework Keychain (kSecClassGenericPassword, dedicated service, the item key as kSecAttrAccount). Registered in the plugin invoke handler + build COMMANDS + default permission set (autogenerated permission files regenerated; the passphrase entries are preserved). The TS bridge wrappers shipped in PR1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): desktop Google Drive OAuth runner + connect flow The desktop half of Drive sign-in: open consent in the system browser, capture the reverse-DNS redirect the OS routes back, and exchange the code for tokens. - oauthDesktop.ts: runDesktopDeepLinkOAuth wires the DI OAuth flow to the desktop mechanics (open default browser, capture via single-instance / onOpenUrl, cold-browser fallback after a grace period, hard deadline). Fully headless-unit-tested via injected deps. - spawn_fresh_browser.rs (+ registration, Windows-only winreg dep): the cold browser the runner falls back to when the user's already-running browser snapshotted protocol associations before the scheme was registered (a Windows-specific failure). Resolves the default browser from the registry and spawns it cold with an isolated --user-data-dir; a no-op on macOS/Linux where the default-browser open already routes the redirect. Pure helpers unit-tested. - connectGoogleDrive.ts: run the platform OAuth runner, persist the token (fail-loud — Drive is not reported connected if the refresh token does not save), and resolve the account label via about.get (best-effort). OAuth runner adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's permission. Scheme registration + the ingress redirect filter + the Drive connect UI land in the following commits; live desktop verification follows once the official Google client id is provisioned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): filter Google OAuth redirects out of the deep-link ingress The reverse-DNS OAuth redirect (com.googleusercontent.apps.<id>:/oauthredirect) is delivered through the same single-instance / onOpenUrl channels as book-file deep links. Without a filter the book-import consumer would treat the redirect URL as a file path to open. Drop it at the ingress source (useAppUrlIngress) before the app-incoming-url broadcast, so no consumer ever sees it; the Drive sign-in runner still captures it via its own listeners. isGoogleOAuthRedirectUrl matches the scheme prefix (not a specific client id), so it stays correct regardless of which client is baked into the build. Note: registering the scheme in tauri.conf.json (so the OS routes it back to the app) needs the official Google client id, which is a provisioning prerequisite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): bake the official Google Drive OAuth client id + redirect scheme Provisioned the Readest Google Cloud OAuth client (iOS application type, no secret, drive.file scope). Bake the client id as the default in getGoogleClientId (overridable via NEXT_PUBLIC_GOOGLE_CLIENT_ID for forkers, who must also regenerate the manifest schemes) and register the derived reverse-DNS redirect scheme com.googleusercontent.apps.<id> in tauri.conf.json (desktop + mobile deep-link) so the OS routes the OAuth redirect back to the app. The client id is a public client identifier, not a secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): Google Drive connect UI + shared FileSyncForm Make Drive usable from Settings, and extract the now-two-consumer sync controls. - FileSyncForm: the provider-agnostic sync controls (sub-toggles, conflict strategy, manual "Sync now" with progress + result toast), parameterised by backend kind and building the provider through the registry. Extracted from WebDAVForm now that a second consumer exists. WebDAVForm keeps its URL/credentials connect panel + browse pane and renders FileSyncForm for the sync section; behaviour is unchanged (WebDAV "Sync now" goes through the same provider via the registry). - GoogleDriveForm: an OAuth connect panel (Connect -> runGoogleDriveConnect -> store token in keychain -> "Connected as <email>"; Disconnect) + FileSyncForm. - googleDriveConnect.ts: assemble the env client id + keychain + desktop runner into connectGoogleDrive/disconnectGoogleDrive for the UI. - IntegrationsPanel: a "Google Drive" row + sub-page, shown only on desktop (mobile OAuth runners land in later phases). Reader-side auto-sync (generalizing useWebDAVSync) is a follow-up; manual "Sync now" already exercises the full Drive stack. Full suite 6412 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): unified Third-party Cloud Sync section (exclusive provider) Group WebDAV + Google Drive into a new "Third-party Cloud Sync" section and make them mutually exclusive — only one cloud provider syncs the library at a time. - New unified "Cloud Sync" sub-page (CloudSyncForm): a provider picker (radio, the AIPanel mutually-exclusive pattern) on top, the shared FileSyncForm sync options below for whichever provider is active. Google Drive is offered only on desktop; on mobile the page is WebDAV only and the picker is hidden. - withActiveCloudProvider helper: enabling one provider disables the other in one save. Both panels' connect/activate paths use it. Unit-tested. - WebDAVForm / GoogleDriveForm refactored into embeddable panels (the unified page owns the header). Drive gains a "configured but inactive" state so switching back re-activates it without a fresh sign-in; explicit Disconnect clears the keychain token. - IntegrationsPanel: remove the two separate WebDAV / Google Drive rows from "Reading Sync" (now KOReader Sync / Readwise / Hardcover only); add the Third-party Cloud Sync section with one Cloud Sync row (status = active provider). Old webdav/gdrive deep-links route to the unified page. Also removes the temporary Drive concurrency probe (the upload already runs at the intended concurrency 4; the probe confirmed it). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): auto-sync the active cloud provider while reading Generalize the reader sync hook from useWebDAVSync to useFileSync so the active third-party cloud provider (WebDAV OR Google Drive) syncs per-book while reading — pull-on-open, debounced push on progress/booknote changes, cover/file upload — not just via the manual "Sync now" in settings. Since the providers are mutually exclusive, the hook drives exactly the one enabled backend, built through the provider registry. The build is async (the Google Drive provider probes the OS keychain), so the engine lives in state and the pull-on-open waits for it; switching providers mid-session resets the per-book locks. The engine is keyed on connection-relevant settings so a lastSyncedAt write doesn't re-probe the keychain. deviceId / lastSyncedAt now write the active provider's settings slice; the auth-failed toast is provider-neutral; the per-book events are renamed *-file-sync. WebDAV reader-sync behaviour is unchanged. Full suite 6416 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): surface cloud providers in the section with inline switch Show WebDAV + Google Drive as separate rows in the Third-party Cloud Sync section (instead of one "Cloud Sync" row), so both providers are visible and the active one can be switched right there. - CloudProviderRow: a trailing radio makes a provider the single active sync target inline (enabled only when it's already configured — WebDAV creds / a Drive token); the row body / chevron opens its config sub-page (connect, sync options, disconnect). Status reads Active / Configured / Not connected, with a Syncing… indicator. - Each provider drills into its own sub-page again (WebDAV / Google Drive), rendering the embeddable panel under a SubPageHeader; the brief unified CloudSyncForm picker page is removed (its old deep-link maps to Google Drive). - Switching stays exclusive via withActiveCloudProvider; an inline switch trusts the stored credentials/token (no re-validate / re-OAuth). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): gate third-party cloud sync behind a premium plan WebDAV + Google Drive sync is now a premium feature: available on any paid plan (Plus, Pro, or Lifetime), not on free. - isCloudSyncInPlan(plan) helper (mirrors isEmailInPlan; plus/pro/purchase). - IntegrationsPanel: free users see the Third-party Cloud Sync section with an upgrade row ("Available on Plus, Pro, or Lifetime") that opens the plans page instead of the provider rows; the cloud-sync deep-links are gated too (waiting for the plan to load before deciding). - useFileSync: the reader's auto-sync only runs on a paid plan, so a downgraded user's sync stops even if a provider's enabled flag lingers. Full suite 6418 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): escape backslashes in Drive query literals (CodeQL) escapeDriveLiteral escaped single quotes but not the backslash escape character, so a file name containing a backslash (or ending in one) could break out of the single-quoted Drive `files.list` query literal and malform the query. Escape backslashes first, then single quotes, so the backslashes added for the quotes are not doubled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+21
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+76
@@ -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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ const COMMANDS: &[&str] = &[
|
||||
"checkPermissions",
|
||||
"requestPermissions",
|
||||
"clip_url",
|
||||
"set_secure_item",
|
||||
"get_secure_item",
|
||||
"clear_secure_item",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
+82
@@ -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
|
||||
}
|
||||
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+81
@@ -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.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-clear-secure-item`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the clear_secure_item command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-clear-secure-item`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the clear_secure_item command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-clear-sync-passphrase`
|
||||
|
||||
</td>
|
||||
@@ -393,6 +422,32 @@ Denies the get_screen_brightness command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-get-secure-item`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_secure_item command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-get-secure-item`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_secure_item command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-get-status-bar-height`
|
||||
|
||||
</td>
|
||||
@@ -1017,6 +1072,32 @@ Denies the set_screen_brightness command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-secure-item`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the set_secure_item command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-set-secure-item`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the set_secure_item command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-sync-passphrase`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+38
-2
@@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -249,3 +249,27 @@ pub(crate) async fn is_sync_keychain_available<R: Runtime>(
|
||||
) -> Result<SyncKeychainAvailableResponse> {
|
||||
app.native_bridge().is_sync_keychain_available()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn set_secure_item<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: SetSecureItemRequest,
|
||||
) -> Result<SecureItemResponse> {
|
||||
app.native_bridge().set_secure_item(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn get_secure_item<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: GetSecureItemRequest,
|
||||
) -> Result<GetSecureItemResponse> {
|
||||
app.native_bridge().get_secure_item(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn clear_secure_item<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: GetSecureItemRequest,
|
||||
) -> Result<SecureItemResponse> {
|
||||
app.native_bridge().clear_secure_item(payload)
|
||||
}
|
||||
|
||||
@@ -285,6 +285,66 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
.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<SecureItemResponse> {
|
||||
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<GetSecureItemResponse> {
|
||||
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<SecureItemResponse> {
|
||||
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, keyring_core::Error> {
|
||||
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, keyring_core::Error> {
|
||||
keyring_core::Entry::new(KEYRING_SERVICE, key)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
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)]
|
||||
|
||||
@@ -299,6 +299,39 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn set_secure_item(
|
||||
&self,
|
||||
payload: SetSecureItemRequest,
|
||||
) -> crate::Result<SecureItemResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("set_secure_item", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn get_secure_item(
|
||||
&self,
|
||||
payload: GetSecureItemRequest,
|
||||
) -> crate::Result<GetSecureItemResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_secure_item", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn clear_secure_item(
|
||||
&self,
|
||||
payload: GetSecureItemRequest,
|
||||
) -> crate::Result<SecureItemResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("clear_secure_item", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
/// Open a full-screen `WKWebView` / `WebView` over the main app,
|
||||
/// navigate to `payload.url` with a real Chrome UA, wait for load
|
||||
|
||||
@@ -358,3 +358,40 @@ pub struct SyncKeychainAvailableResponse {
|
||||
pub available: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ── 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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,13 @@ function makeSettings(overrides: Partial<SystemSettings> = {}): 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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
};
|
||||
+191
@@ -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<typeof createGoogleDriveProvider>;
|
||||
fetchMock: ReturnType<typeof vi.fn>;
|
||||
sleep: ReturnType<typeof vi.fn>;
|
||||
/** 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<void>,
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
+131
@@ -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<typeof vi.fn> };
|
||||
};
|
||||
|
||||
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<Response>((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');
|
||||
});
|
||||
});
|
||||
+93
@@ -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>): 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/);
|
||||
});
|
||||
});
|
||||
@@ -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<OAuthFlowDeps>, 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);
|
||||
});
|
||||
});
|
||||
+38
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+52
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
+63
@@ -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('/');
|
||||
});
|
||||
});
|
||||
+81
@@ -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<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
} => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<hash>/<title>.<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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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>(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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)),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user