diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 4a5cc0d2..16acb732 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -7,6 +7,7 @@ import StoreKit import SwiftRs import Tauri import UIKit +import UniformTypeIdentifiers import WebKit import os @@ -520,6 +521,13 @@ class NativeBridgePlugin: Plugin { } else { Logger.error("NativeBridgePlugin: Failed to get shared application") } + + // Re-acquire security-scoped access for every external library + // folder the user registered in a previous session. Done here in + // `load` so the WebView can issue fs reads against those paths as + // soon as the JS layer comes up. See `FolderBookmarkStore` + // for the why and how. + FolderBookmarkStore.restoreAllPersisted() } @objc func appWillEnterForeground() { @@ -1348,6 +1356,257 @@ class NativeBridgePlugin: Plugin { presenter.present(controller, animated: true) } } + + /// Hold a strong reference to the active folder picker delegate so + /// it survives until the user dismisses the picker. `present` + /// only retains the controller; the delegate is `weak` from + /// `UIDocumentPickerViewController`'s side, so without our retain + /// it would deallocate immediately and the callbacks would never + /// fire. + private var folderPickerDelegate: FolderPickerDelegate? + + /// Bridge for the native-bridge `select_directory` command on iOS. + /// Mirrors the Android implementation (ACTION_OPEN_DOCUMENT_TREE) + /// but uses `UIDocumentPickerViewController(forOpeningContentTypes: + /// [.folder])` — the same picker the system uses for "Files" → choose + /// folder. Users can navigate into On My iPhone / iCloud Drive / + /// any third-party file provider and pick a directory there. + /// + /// Caveats around iOS sandbox model (callers should be aware): + /// * Resolving the returned path on a SUBSEQUENT app launch + /// requires a security-scoped bookmark — for now we hold the + /// security-scoped resource open for the lifetime of the app + /// process so the current session works. v2 should persist a + /// bookmark and resolve it on launch; until then a relaunch + /// after picking a non-Documents folder may need the user to + /// re-pick. + /// * The returned `path` is `url.path` — a normal POSIX path. + /// Combined with the resource being open, plain Foundation / + /// stdlib fs reads work against it. + @objc public func select_directory(_ invoke: Invoke) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { + invoke.reject("plugin deallocated") + return + } + guard let presenter = topmostViewController() else { + invoke.reject("Could not find a view controller to present from") + return + } + + // `forOpeningContentTypes: [.folder]` tells the picker to surface + // folders as the selectable entries (you can't pick individual + // files in this mode). `asCopy: false` keeps it a reference into + // the original location instead of copying into our sandbox. + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false) + picker.allowsMultipleSelection = false + + let delegate = FolderPickerDelegate(invoke: invoke) { [weak self] in + // Drop our strong reference once the picker resolves so the + // delegate (and thus the closure capture chain) can deallocate. + // The security-scoped URL resource stays open via + // `FolderBookmarkStore.persist`, which retains the URL + // for the rest of the process and writes a bookmark to + // UserDefaults so the next launch can re-acquire access + // without prompting the user again. + self?.folderPickerDelegate = nil + } + self.folderPickerDelegate = delegate + picker.delegate = delegate + + presenter.present(picker, animated: true) + } + } +} + +/// Persistent store for security-scoped folder bookmarks. +/// +/// `UIDocumentPickerViewController(forOpeningContentTypes: [.folder])` +/// hands us a security-scoped URL whose access right is granted only to +/// the running process. To read the same folder on the *next* launch +/// (without making the user re-pick it every time) we have to: +/// +/// 1. Right after the pick, call `URL.bookmarkData(.withSecurityScope)` +/// and stash the bytes in `UserDefaults` keyed by the POSIX path. +/// 2. On every app launch, walk every persisted bookmark, resolve it +/// back into a URL, and call `startAccessingSecurityScopedResource()`. +/// Hold the URL alive for the entire process so subsequent +/// Foundation / POSIX reads against `url.path` succeed. +/// +/// The TS-visible `path` is unchanged across launches, so the rest of +/// the app doesn't need to know about bookmarks — they're an iOS +/// sandbox plumbing detail that lives entirely on the Swift side. +/// +/// `isStale` recovery: when the source moves (user renamed, system +/// migrated) `URL(resolvingBookmarkData:..., bookmarkDataIsStale:)` +/// sets `isStale=true` and the new URL still works. We re-encode and +/// rewrite. If the bookmark can't be resolved at all (file deleted, +/// provider gone), we drop the entry — the user will need to re-pick. +enum FolderBookmarkStore { + /// UserDefaults key prefix; the actual key is "". + /// Using the path verbatim keeps debugging straightforward (you can + /// see the registered folders in `defaults read`), and UserDefaults + /// keys can contain any valid string. + private static let keyPrefix = "readest.folderBookmark." + + /// Every URL we've successfully `startAccessingSecurityScopedResource`-ed + /// during this process, indexed by the POSIX path the JS layer uses. + /// Held forever (until process exit) — calling `stop` would kill any + /// subsequent fs read against the path. + private static var liveAccess: [String: URL] = [:] + + /// Persist a bookmark for `url` so it can be resolved on next launch, + /// and pre-warm `liveAccess` for the current session. + static func persist(url: URL) { + let path = url.path + do { + let data = try url.bookmarkData( + options: .minimalBookmark, + includingResourceValuesForKeys: nil, + relativeTo: nil) + UserDefaults.standard.set(data, forKey: keyPrefix + path) + liveAccess[path] = url + logger.log( + "FolderBookmarkStore: persisted bookmark for \(path, privacy: .public)") + } catch { + Logger.error( + "FolderBookmarkStore: failed to encode bookmark for \(path): \(error)") + } + } + + /// Resolve every stored bookmark and start accessing its security- + /// scoped resource. Called once from `NativeBridgePlugin.load(webview:)` + /// so all previously-registered folders are reachable before + /// the WebView begins issuing fs reads. + static func restoreAllPersisted() { + let defaults = UserDefaults.standard + let allKeys = defaults.dictionaryRepresentation().keys.filter { + $0.hasPrefix(keyPrefix) + } + var restored = 0 + var dropped = 0 + for key in allKeys { + guard let data = defaults.data(forKey: key) else { continue } + let storedPath = String(key.dropFirst(keyPrefix.count)) + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: data, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &isStale) + let started = url.startAccessingSecurityScopedResource() + if started { + liveAccess[url.path] = url + restored += 1 + } else { + // The OS denied access (e.g. file provider revoked). Don't + // delete the entry — the user may reauthorize the provider + // in Settings; we'll pick it up on the next launch. + Logger.error( + "FolderBookmarkStore: startAccessing denied for \(storedPath)") + } + if isStale { + // Re-encode against the resolved URL so the next launch + // doesn't have to walk the stale path again. + if let fresh = try? url.bookmarkData( + options: .minimalBookmark, + includingResourceValuesForKeys: nil, + relativeTo: nil) + { + defaults.set(fresh, forKey: key) + } + } + } catch { + // Bookmark is unrecoverable — folder gone, provider uninstalled, + // disk reformatted. Drop it from UserDefaults so the next launch + // doesn't re-attempt it. + defaults.removeObject(forKey: key) + dropped += 1 + Logger.error( + "FolderBookmarkStore: dropped unresolvable bookmark for \(storedPath): \(error)") + } + } + logger.log( + "FolderBookmarkStore: restored \(restored, privacy: .public) folder(s), dropped \(dropped, privacy: .public)" + ) + } + + /// Stop accessing and forget the bookmark for `path`. Used when the + /// JS layer removes a folder from `settings.externalLibraryFolders`. + /// Currently unused — the v1 UI doesn't expose folder removal — but + /// kept here so the cleanup contract is obvious from one place. + static func forget(path: String) { + if let url = liveAccess.removeValue(forKey: path) { + url.stopAccessingSecurityScopedResource() + } + UserDefaults.standard.removeObject(forKey: keyPrefix + path) + } +} + +/// Delegate for `select_directory`'s `UIDocumentPickerViewController`. +/// We split this out into a dedicated class (rather than making +/// `NativeBridgePlugin` itself the delegate) so multiple concurrent +/// picker invocations would each hold their own state — currently we +/// only allow one at a time, but the wiring is cleaner this way and +/// `Invoke` is easier to capture by reference. +/// +/// Lifecycle: +/// 1. Plugin retains us strongly via `folderPickerDelegate`. +/// 2. UIKit holds us as the picker's `delegate` (weak). +/// 3. When the user picks or cancels, we call back into `invoke` +/// and then run `onComplete`, which clears the plugin's strong +/// reference. We deallocate at that point. +/// 4. The chosen URL retains its security-scoped access for the +/// remainder of the process via `FolderBookmarkStore`, +/// and is also persisted to UserDefaults so the next launch can +/// re-acquire access without prompting the user again. +private final class FolderPickerDelegate: NSObject, UIDocumentPickerDelegate { + private let invoke: Invoke + private let onComplete: () -> Void + + init(invoke: Invoke, onComplete: @escaping () -> Void) { + self.invoke = invoke + self.onComplete = onComplete + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + defer { onComplete() } + guard let url = urls.first else { + invoke.resolve(["cancelled": true]) + return + } + + // `startAccessingSecurityScopedResource` is required for any URL + // returned by the picker that lives outside our sandbox (which is + // the only interesting case — anything inside our sandbox we + // already have access to without it). Returns false when no scope + // is needed or the request was denied; in either case we still + // try to use the URL because (a) sandbox-internal paths work + // either way, (b) for denied requests `path` access will surface + // a normal "permission denied" later, which is a clearer error + // than failing here pre-emptively. + let started = url.startAccessingSecurityScopedResource() + if started { + // Persist a security-scoped bookmark so the same path is + // reachable on the next launch, without forcing the user to + // re-pick it. The store also retains the URL so the resource + // stays accessible for the remainder of this process. + FolderBookmarkStore.persist(url: url) + } + + let result: [String: Any] = [ + "cancelled": false, + "uri": url.absoluteString, + "path": url.path, + ] + invoke.resolve(result) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + defer { onComplete() } + invoke.resolve(["cancelled": true]) + } } /// Find the visible top-of-stack `UIViewController` so the clip flow diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index f1db14a8..fe1f4cd0 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -41,7 +41,7 @@ use tauri_plugin_oauth::start; use tauri_plugin_opener::OpenerExt; use transfer_file::{download_file, upload_file}; -#[cfg(desktop)] +#[cfg(any(desktop, target_os = "ios"))] fn allow_file_in_scopes(app: &AppHandle, files: Vec) { let fs_scope = app.fs_scope(); let asset_protocol_scope = app.asset_protocol_scope(); @@ -86,16 +86,38 @@ fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) { /// `tauri_plugin_persisted_scope`, so re-picking the same file isn't /// required after the first allow call. /// -/// Security: this command refuses to extend `asset_protocol_scope` for -/// any path that is not already allowed in `fs_scope`. The `fs_scope` -/// is populated only by the Tauri `dialog` plugin (when the user picks -/// through the OS picker) or by `tauri_plugin_persisted_scope` (which -/// restores prior dialog grants on startup). That gate constrains the -/// command to user-selected paths only — otherwise any frontend code -/// (including a future XSS via book content, OPDS HTML, dictionary -/// lookups, or a compromised dependency) could invoke it with an -/// arbitrary path like `/` or `~/.ssh` and gain persistent read access -/// to the entire user home directory via the asset protocol. +/// Security: +/// +/// - On desktop, this command refuses to extend `asset_protocol_scope` +/// for any path that is not already allowed in `fs_scope`. The +/// `fs_scope` there is populated only by the Tauri `dialog` plugin +/// (when the user picks through the OS picker) or by +/// `tauri_plugin_persisted_scope` (which restores prior dialog +/// grants on startup). That gate constrains the command to +/// user-selected paths only — otherwise any frontend code +/// (including a future XSS via book content, OPDS HTML, dictionary +/// lookups, or a compromised dependency) could invoke it with an +/// arbitrary path like `/` or `~/.ssh` and gain persistent read +/// access to the entire user home directory via the asset +/// protocol. +/// +/// - On iOS, the `fs_scope` gate is intentionally skipped: the iOS +/// directory/file picker (`UIDocumentPickerViewController`) does +/// not flow through Tauri's dialog plugin, and we keep the only +/// persistent record of user-authorised paths inside the +/// native-bridge plugin's security-scoped bookmark store +/// (`FolderBookmarkStore` in NativeBridgePlugin.swift). The +/// OS sandbox itself is the access-control boundary: the process +/// can only read paths for which it holds a security-scoped +/// resource (granted by the system picker, persisted via +/// bookmark). Widening Tauri's `fs_scope`/`asset_protocol_scope` +/// to those same paths cannot escalate access beyond what the OS +/// already grants — it just lets the fs / dir-scanner layers +/// route reads through the path the WebView gave them. The +/// frontend layer also keeps the list of folder roots in +/// `settings.externalLibraryFolders` and re-issues this call on +/// every launch, so the in-memory scope set stays in sync with +/// the user's persisted intent. #[command] fn allow_paths_in_scopes(_app: AppHandle, _paths: Vec, _is_directory: bool) { #[cfg(desktop)] @@ -117,6 +139,28 @@ fn allow_paths_in_scopes(_app: AppHandle, _paths: Vec, _is_directory: bo } } } + #[cfg(target_os = "ios")] + { + // The iOS picker hands us a security-scoped URL whose POSIX + // path lives outside any of our static fs_scope globs (e.g. + // File Provider Storage, iCloud Drive, third-party providers). + // Without explicitly widening fs_scope/asset_protocol_scope + // here, both `dir_scanner::read_dir` and the fs plugin's + // `readDir` would reject the path even though the OS sandbox + // already grants us access via the held security-scoped + // resource. See the security comment above. + for raw in _paths { + if raw.is_empty() { + continue; + } + let path = PathBuf::from(&raw); + if _is_directory { + allow_dir_in_scopes(&_app, &path); + } else { + allow_file_in_scopes(&_app, vec![path]); + } + } + } #[cfg(target_os = "android")] { // Android picker already routes through register_select_directory_callback diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index fd44a71e..f1d40f5a 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -982,12 +982,72 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP */ const pickImportDirectory = async (): Promise => { if (!appService) return undefined; - if (appService.isAndroidApp) { - if (!(await requestStoragePermission())) return undefined; + // Both mobile platforms now go through the native-bridge picker: + // Android dispatches ACTION_OPEN_DOCUMENT_TREE, iOS presents + // UIDocumentPickerViewController(forOpeningContentTypes: [.folder]). + // Tauri's bundled dialog plugin still rejects mobile folder picks + // with "FolderPickerNotImplemented", so the native-bridge route is + // the only working path on either OS. + let picked: string | undefined; + if (appService.isAndroidApp || appService.isIOSApp) { + // Android needs MANAGE_EXTERNAL_STORAGE for absolute-path reads; + // iOS doesn't have an equivalent gate (the OS picker is itself + // the permission grant), so the prompt is Android-only. + if (appService.isAndroidApp && !(await requestStoragePermission())) return undefined; const response = await selectDirectory(); - return response.path || undefined; + picked = response.path || undefined; + } else { + picked = (await appService.selectDirectory?.('read')) || undefined; } - return (await appService.selectDirectory?.('read')) || undefined; + if (picked && !validatePickedDirectory(picked)) { + // Already toasted from inside the validator. Treat as "no + // selection" so the caller leaves the dialog's old folder + // value alone and the user can immediately try again. + return undefined; + } + return picked; + }; + + /** + * Sanity-check a path returned by the native folder picker before + * we commit to scanning it. iOS in particular hands back POSIX paths + * for "virtual" Files-app entries (the "On My iPhone" root, "Recents", + * etc.) where {@link readDirectory} will then fail with a Tauri + * fs_scope rejection. There's no way to disable those entries in the + * picker itself, so we accept the pick, detect the known-bad shapes, + * and show a clear toast asking the user to drill into a real + * subfolder. Returns true if the path looks usable. + */ + const validatePickedDirectory = (path: string): boolean => { + if (!appService?.isIOSApp) return true; + // iOS Files exposes "On My iPhone" as a virtual aggregator over + // every app's `LSSupportsOpeningDocumentsInPlace` container. When + // the user picks that root, the picker hands us a path whose + // basename is exactly `File Provider Storage` (the placeholder + // directory inside our own App Group container that the system + // uses to materialise external file-provider contents on demand). + // POSIX reads against it return either nothing or EPERM, and the + // Tauri fs_scope refuses it outright because it's outside our + // allowed globs. Drilling into a concrete subfolder produces a + // normal, readable POSIX path, which is the path we want. + // + // These string anchors aren't localized — iOS keeps the on-disk + // path in English regardless of the device language, so the + // basename / segment match is stable. + const trimmed = path.replace(/\/+$/, ''); + const basename = trimmed.split('/').pop() ?? ''; + const isOnMyIPhoneRoot = basename === 'File Provider Storage'; + if (isOnMyIPhoneRoot) { + eventDispatcher.dispatch('toast', { + type: 'warning', + timeout: 6000, + message: _( + 'iOS doesn\'t allow importing the "On My iPhone" root. Open it and pick a specific subfolder (e.g. Readest, Downloads), then try again.', + ), + }); + return false; + } + return true; }; /** @@ -1017,6 +1077,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP */ const runFolderImport = async (result: ImportFromFolderResult) => { if (!appService || !result.directory) return; + // Last-chance sanity check. The dialog's own pickImportDirectory + // already validates fresh picks, but `result.directory` can also + // come from the persisted "last import folder" in localStorage — + // which may have been a bad path (e.g. user picked "On My iPhone" + // root last session, app remembered it, user just hits OK now). + // Catch that here so they get the same clear guidance instead of + // a fs_scope error from readDirectory below. + if (!validatePickedDirectory(result.directory)) return; // Re-grant scopes for the directory before scanning. This matters // when `result.directory` came from somewhere the dialog plugin // didn't authorise — typically the persisted "last import folder" @@ -1027,7 +1095,38 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP await appService.allowPathsInScopes?.([result.directory], true); const exts = result.extensions.map((e) => e.toLowerCase()); const minSizeBytes = Math.max(0, Math.floor(result.minSizeKB)) * 1024; - const files = await appService.readDirectory(result.directory, 'None'); + let files; + try { + files = await appService.readDirectory(result.directory, 'None'); + } catch (e) { + // readDirectory can reject for a few related reasons: + // - iOS handed us a virtual / file-provider path that the OS + // sandbox refuses to enumerate (the validator above catches + // the common shapes, but not every file-provider variant); + // - the path is outside Tauri's `fs_scope` and scope + // extension didn't stick (e.g. an iCloud Drive entry whose + // security-scoped resource the system declined to grant); + // - the directory was deleted / permissions revoked between + // pick and scan. + // Swallow the rejection (otherwise it bubbles up as an + // unhandledRejection through Next.js) and surface a friendly + // message that nudges the user to re-pick. + const detail = e instanceof Error ? e.message : String(e); + console.error('Folder import: readDirectory failed', detail); + const isIOS = !!appService.isIOSApp; + eventDispatcher.dispatch('toast', { + type: 'error', + timeout: 6000, + message: isIOS + ? _( + 'Couldn\'t read this folder. Some iOS locations (like the "On My iPhone" root or iCloud Drive top-level) can\'t be scanned — please pick a specific subfolder and try again.', + ) + : _( + "Couldn't read this folder. Please pick the folder again, or choose a different location.", + ), + }); + return; + } const filtered = files.filter((file) => { const ext = file.path.split('.').pop()?.toLowerCase() || ''; if (!exts.includes(ext)) return false; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 067737c7..2d6f30a6 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -550,6 +550,26 @@ export class NativeAppService extends BaseAppService { } async selectDirectory(): Promise { + // On mobile, Tauri's dialog plugin rejects folder picks with + // "FolderPickerNotImplemented" — neither iOS nor Android ship a + // folder picker via that surface. Route through the native-bridge + // plugin instead, where each platform has a native implementation + // (Android: ACTION_OPEN_DOCUMENT_TREE, iOS: + // UIDocumentPickerViewController with `.folder`). The bridge + // returns `{ path, uri, cancelled }`; we surface the path string + // so the rest of the app can treat it like any local directory. + if (this.isIOSApp || this.isAndroidApp) { + const { selectDirectory } = await import('@/utils/bridge'); + const result = await selectDirectory(); + const path = result.path ?? ''; + if (path) { + // Match the desktop branch — make sure both fs_scope and the + // asset-protocol scope can read from the chosen directory. + await this.allowPathsInScopes([path], true); + } + return path; + } + const selected = await openDialog({ directory: true, multiple: false,