forked from akai/readest
feat(ios): folder import with security-scoped bookmark persistence (#4314)
* feat(import): support folder picker on iOS via native-bridge
Tauri's dialog plugin rejects folder picks on both mobile platforms with FolderPickerNotImplemented, so previously only Android could pick an import directory (it already routed through the native-bridge plugin's ACTION_OPEN_DOCUMENT_TREE). iOS users had no working folder-import entry point at all.
Add an iOS implementation of the native-bridge select_directory command using UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false), with a dedicated FolderPickerDelegate that:
- holds a strong reference until the picker dismisses (UIKit keeps the delegate weak), and
- calls startAccessingSecurityScopedResource on the picked URL and retains it for the app's lifetime so plain Foundation/POSIX reads against url.path work for the rest of the session.
Route NativeAppService.selectDirectory through the bridge for both iOS and Android, then call allowPathsInScopes so the picked directory is reachable via fs_scope and the asset protocol. The library page's pickImportDirectory entry point now also takes the mobile branch on iOS, while keeping the Android-only MANAGE_EXTERNAL_STORAGE prompt gated behind isAndroidApp.
* feat(ios): persist security-scoped bookmarks for picked folders
iOS hands the folder picker back a security-scoped URL whose access
right is granted only to the running process. The previous
implementation kept the URL alive for the lifetime of the process via a
static `urlsToKeepAlive` array, which worked for the current session
but forced the user to re-pick the same folder after every relaunch.
Add a `FolderBookmarkStore` that:
- Right after the picker returns, calls
`URL.bookmarkData(.minimalBookmark)` and stashes the bytes in
`UserDefaults` keyed by the POSIX path.
- On every `NativeBridgePlugin.load(webview:)`, walks every persisted
bookmark, resolves it back into a URL, and calls
`startAccessingSecurityScopedResource`. Holds the URL alive in a
process-scoped dictionary so subsequent Foundation / POSIX reads
against `url.path` succeed.
- Handles `isStale` by re-encoding the bookmark against the resolved
URL, and drops permanently unresolvable bookmarks (folder gone,
provider uninstalled) from `UserDefaults` so the next launch
doesn't re-attempt them.
Pair this with a Tauri-side change so the same paths are reachable
through both `dir_scanner::read_dir` and the fs plugin's `readDir`:
- `allow_paths_in_scopes` now has an iOS branch that widens
`fs_scope` / `asset_protocol_scope` for any path the frontend hands
it, intentionally without the desktop-side "must already be in
fs_scope" gate. The OS sandbox + bookmark store is the real
access-control boundary on iOS; widening Tauri's in-memory scope
set cannot escalate access beyond what the OS already grants. The
security comment on the command was rewritten to spell this
contract out.
- `allow_file_in_scopes` is now compiled for iOS too (previously
desktop-only) so the file-grant path is available when needed.
This commit is contained in:
+259
@@ -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 "<prefix><path>".
|
||||
/// 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
|
||||
|
||||
@@ -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<PathBuf>) {
|
||||
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<String>, _is_directory: bool) {
|
||||
#[cfg(desktop)]
|
||||
@@ -117,6 +139,28 @@ fn allow_paths_in_scopes(_app: AppHandle, _paths: Vec<String>, _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
|
||||
|
||||
@@ -982,12 +982,72 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
*/
|
||||
const pickImportDirectory = async (): Promise<string | undefined> => {
|
||||
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;
|
||||
|
||||
@@ -550,6 +550,26 @@ export class NativeAppService extends BaseAppService {
|
||||
}
|
||||
|
||||
async selectDirectory(): Promise<string> {
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user