import { exists, mkdir, readTextFile, readFile, writeTextFile, writeFile, readDir, remove, copyFile, stat, BaseDirectory, WriteFileOptions, DirEntry, } from '@tauri-apps/plugin-fs'; import { invoke, convertFileSrc } from '@tauri-apps/api/core'; import { open as openDialog, save as saveDialog, ask } from '@tauri-apps/plugin-dialog'; import { join, basename, appDataDir, appConfigDir, appCacheDir, appLogDir, tempDir, } from '@tauri-apps/api/path'; import { type as osType } from '@tauri-apps/plugin-os'; import { shareFile } from '@choochmeque/tauri-plugin-sharekit-api'; import { FileSystem, BaseDir, AppPlatform, ResolvedPath, FileItem, DistChannel, } from '@/types/system'; import { getOSPlatform, isContentURI, isFileURI, isValidURL } from '@/utils/misc'; import { getDirPath, getFilename } from '@/utils/path'; import { NativeFile, RemoteFile } from '@/utils/file'; import { copyURIToPath, getStorefrontRegionCode, saveImageToGallery } from '@/utils/bridge'; import { copyFiles } from '@/utils/files'; import { BaseAppService } from './appService'; import { DatabaseOpts, DatabaseService } from '@/types/database'; import { SchemaType } from '@/services/database/migrate'; import { DATA_SUBDIR, LOCAL_BOOKS_SUBDIR, LOCAL_DICTIONARIES_SUBDIR, LOCAL_FONTS_SUBDIR, LOCAL_IMAGES_SUBDIR, SETTINGS_FILENAME, } from './constants'; declare global { interface Window { __READEST_IS_EINK?: boolean; __READEST_IS_APPIMAGE?: boolean; __READEST_UPDATER_DISABLED?: boolean; } } const OS_TYPE = osType(); const safeDecodePath = (input: string) => { try { return decodeURI(input); } catch { return input; } }; /** * In-process cache of directories we've already verified (or just created) * in this app session. * * Why: `writeFile` / `copyFile` defensively call `fs.exists(dir)` before * every write to make sure the parent directory exists. On the hot path * for `saveBookConfig` (fires roughly once every 1.5s while the user is * reading via `useProgressAutoSave`) the parent is always the same book * directory that's been there since the book was opened — so the * `exists` IPC is pure overhead. * * The cost shows up clearly in Chrome DevTools' Bottom-Up profile of a * release Android build: the `A` (= `plugin:fs|exists`) branch following * each `sendIpcMessage` accounts for ~50% of the IPC time during a * reading session, doubling the per-save IPC cost. * * Cache semantics: * - Key = `${base}:${dirPath}` (BaseDir + normalized directory path). * - Membership = "this process has at some point seen the directory * exist". We do NOT track deletions performed outside the app, but * the directories that matter for the read-path (per-book config * dirs, library dir) are created by the app and only deleted via * `removeDir` / book removal, both of which clear the relevant * entries below. * - On a fresh app start the cache is empty, so the first write to a * directory still does the original `exists`+`createDir` dance — * this is a perf cache, not a correctness shortcut. */ const knownExistingDirs = new Set(); const dirCacheKey = (base: BaseDir, dir: string) => `${base}:${dir}`; const markDirKnown = (base: BaseDir, dir: string) => { // Empty string is a valid path: it represents the BaseDir itself, which // is the parent dir for root-level files like `settings.json`. We must // cache it too, otherwise every root-level write would re-probe the // BaseDir via `exists()`. knownExistingDirs.add(dirCacheKey(base, dir)); }; const forgetDirKnown = (base: BaseDir, dir: string) => { knownExistingDirs.delete(dirCacheKey(base, dir)); }; /** * Helper used by `writeFile` / `copyFile`. If we already know this * directory exists, returns immediately. Otherwise performs the normal * `exists` IPC + `createDir` fallback and records the result in the * cache so subsequent writes skip both round-trips. * * `dir` may be the empty string, which represents the BaseDir itself * (i.e. when writing a root-level file like `settings.json` whose * `getDirPath` is ""). On a fresh install the BaseDir may not exist * yet — the underlying Tauri `exists("", base)` / `createDir("", base, * recursive)` calls handle the empty path correctly by operating on * the BaseDir itself, so we must NOT short-circuit on empty string. */ async function ensureDirExists( self: { exists: (path: string, base: BaseDir) => Promise; createDir: (path: string, base: BaseDir, recursive?: boolean) => Promise; }, dir: string, base: BaseDir, ): Promise { const key = dirCacheKey(base, dir); if (knownExistingDirs.has(key)) return; if (!(await self.exists(dir, base))) { await self.createDir(dir, base, true); } knownExistingDirs.add(key); } // Helper function to create a path resolver based on custom root directory and portable mode // 0. If no custom root dir and not portable mode, use default Tauri BaseDirectory // 1. If custom root dir is set, use it as base dir (baseDir = 0) // 2. If portable mode is detected (Settings.json in executable dir), use executable dir as base dir (baseDir = 0) // 3. If both custom root dir and portable mode are set, use custom root dir as base dir (baseDir = 0) // Path Resolver Usage: // - appService.resolvePath and use returned baseDir + fp, when baseDir is 0, fp will be absolute path // - fileSystem.getPrefix and use prefix + path const getPathResolver = ({ customRootDir, isPortable, execDir, }: { customRootDir?: string; isPortable?: boolean; execDir?: string; } = {}) => { const customBaseDir = customRootDir ? 0 : undefined; const isCustomBaseDir = Boolean(customRootDir); const getCustomBasePrefixSync = isCustomBaseDir ? (baseDir: BaseDir) => { return () => { const dataDirs = ['Settings', 'Data', 'Books', 'Fonts', 'Images', 'Dictionaries']; const leafDir = dataDirs.includes(baseDir) ? '' : baseDir; return leafDir ? `${customRootDir}/${leafDir}` : customRootDir!; }; } : undefined; const getCustomBasePrefix = getCustomBasePrefixSync ? (baseDir: BaseDir) => async () => getCustomBasePrefixSync(baseDir)() : undefined; return (path: string, base: BaseDir): ResolvedPath => { const customBasePrefixSync = getCustomBasePrefixSync?.(base); const customBasePrefix = getCustomBasePrefix?.(base); switch (base) { case 'Settings': return { baseDir: isPortable ? 0 : BaseDirectory.AppConfig, basePrefix: isPortable && execDir ? async () => execDir : appConfigDir, fp: isPortable && execDir ? `${execDir}${path ? `/${path}` : ''}` : path, base, }; case 'Cache': return { baseDir: BaseDirectory.AppCache, basePrefix: appCacheDir, fp: path, base, }; case 'Log': return { baseDir: isCustomBaseDir ? 0 : BaseDirectory.AppLog, basePrefix: customBasePrefix ?? appLogDir, fp: customBasePrefixSync ? `${customBasePrefixSync()}${path ? `/${path}` : ''}` : path, base, }; case 'Data': return { baseDir: customBaseDir ?? BaseDirectory.AppData, basePrefix: customBasePrefix ?? appDataDir, fp: customBasePrefixSync ? `${customBasePrefixSync()}/${DATA_SUBDIR}${path ? `/${path}` : ''}` : `${DATA_SUBDIR}${path ? `/${path}` : ''}`, base, }; case 'Books': return { baseDir: customBaseDir ?? BaseDirectory.AppData, basePrefix: customBasePrefix || appDataDir, fp: customBasePrefixSync ? `${customBasePrefixSync()}/${LOCAL_BOOKS_SUBDIR}${path ? `/${path}` : ''}` : `${LOCAL_BOOKS_SUBDIR}${path ? `/${path}` : ''}`, base, }; case 'Fonts': return { baseDir: customBaseDir ?? BaseDirectory.AppData, basePrefix: customBasePrefix || appDataDir, fp: customBasePrefixSync ? `${customBasePrefixSync()}/${LOCAL_FONTS_SUBDIR}${path ? `/${path}` : ''}` : `${LOCAL_FONTS_SUBDIR}${path ? `/${path}` : ''}`, base, }; case 'Images': return { baseDir: customBaseDir ?? BaseDirectory.AppData, basePrefix: customBasePrefix || appDataDir, fp: customBasePrefixSync ? `${customBasePrefixSync()}/${LOCAL_IMAGES_SUBDIR}${path ? `/${path}` : ''}` : `${LOCAL_IMAGES_SUBDIR}${path ? `/${path}` : ''}`, base, }; case 'Dictionaries': return { baseDir: customBaseDir ?? BaseDirectory.AppData, basePrefix: customBasePrefix || appDataDir, fp: customBasePrefixSync ? `${customBasePrefixSync()}/${LOCAL_DICTIONARIES_SUBDIR}${path ? `/${path}` : ''}` : `${LOCAL_DICTIONARIES_SUBDIR}${path ? `/${path}` : ''}`, base, }; case 'None': return { baseDir: 0, basePrefix: async () => '', fp: path, base, }; case 'Temp': default: return { baseDir: BaseDirectory.Temp, basePrefix: tempDir, fp: path, base, }; } }; }; export const nativeFileSystem: FileSystem = { resolvePath: getPathResolver(), async getPrefix(base: BaseDir) { const { basePrefix, fp, baseDir } = this.resolvePath('', base); let basePath = await basePrefix(); basePath = basePath.replace(/\/+$/, ''); return fp ? (baseDir === 0 ? fp : await join(basePath, fp)) : basePath; }, getURL(path: string) { return isValidURL(path) ? path : convertFileSrc(path); }, async getBlobURL(path: string, base: BaseDir) { const content = await this.readFile(path, base, 'binary'); return URL.createObjectURL(new Blob([content])); }, async getImageURL(path: string) { return this.getURL(path); }, async openFile(path: string, base: BaseDir, name?: string) { const normalizedPath = OS_TYPE === 'ios' ? safeDecodePath(path) : path; const { fp, baseDir } = this.resolvePath(normalizedPath, base); let fname = safeDecodePath(name || getFilename(fp)); if (isValidURL(path)) { return await new RemoteFile(path, fname).open(); } else if (isContentURI(path) || (isFileURI(path) && OS_TYPE === 'ios')) { fname = safeDecodePath(await basename(path)); if (path.includes('com.android.externalstorage')) { // If the URI is from shared internal storage (like /storage/emulated/0), // we can access it directly using the path — no need to copy. return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); } else { // Otherwise, for content:// URIs (e.g. from MediaStore, Drive, or third-party apps), // or file:// URIs is security scoped resource in iOS (e.g. from Files app), // we cannot access the file directly — so we copy it to a temporary cache location. const prefix = await this.getPrefix('Cache'); const dst = await join(prefix, decodeURIComponent(fname)); const res = await copyURIToPath({ uri: path, dst }); if (!res.success) { console.error('Failed to open file:', res); throw new Error('Failed to open file'); } return await new NativeFile(dst, fname, baseDir ? baseDir : null).open(); } } else if (isFileURI(path)) { return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); } else if (OS_TYPE === 'android') { // Android can't use the asset protocol for ranged reads — its WebView // re-applies a `Range` header's offset to intercepted bodies and corrupts // non-zero-start reads (Chromium 40739128). Instead route reads through // the `rangefile` custom scheme, which carries the range in the URL query // (no `Range` header) so the WebView delivers the bytes verbatim, still // over the network stack rather than the slow Tauri IPC bridge. // Falls back to NativeFile if the path is outside the asset scope. try { const prefix = await this.getPrefix(base); const absolutePath = prefix ? await join(prefix, path) : path; return await RemoteFile.fromNativePath(absolutePath, fname).open(); } catch { return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); } } else if (OS_TYPE === 'ios') { // On iOS, importing picker Inbox files should use NativeFile to avoid // fetch/HEAD issues. return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); } else { // NOTE: RemoteFile currently performs about 2× faster than NativeFile // due to an unresolved performance issue in Tauri (see tauri-apps/tauri#9190). // Once the bug is resolved, we should switch back to using NativeFile. try { const prefix = await this.getPrefix(base); const absolutePath = prefix ? await join(prefix, path) : path; return await new RemoteFile(this.getURL(absolutePath), fname).open(); } catch { return await new NativeFile(fp, fname, baseDir ? baseDir : null).open(); } } }, async copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) { try { // Uses the in-process dir cache (see `knownExistingDirs` above) so a // burst of copies into the same destination dir doesn't fire an // `exists` IPC per file. await ensureDirExists(this, getDirPath(dstPath), dstBase); } catch (error) { console.log('Failed to create directory for copying file:', error); } if (isContentURI(srcPath)) { const prefix = await this.getPrefix(dstBase); if (!prefix) { throw new Error('Invalid base directory'); } const res = await copyURIToPath({ uri: srcPath, dst: await join(prefix, dstPath), }); if (!res.success) { console.error('Failed to copy file:', res); throw new Error('Failed to copy file'); } } else { const { fp: srcFp, baseDir: srcBaseDir } = this.resolvePath(srcPath, srcBase); const { fp: dstFp, baseDir: dstBaseDir } = this.resolvePath(dstPath, dstBase); const opts: { fromPathBaseDir?: number; toPathBaseDir?: number } = {}; if (srcBaseDir) opts.fromPathBaseDir = srcBaseDir; if (dstBaseDir) opts.toPathBaseDir = dstBaseDir; await copyFile(srcFp, dstFp, Object.keys(opts).length > 0 ? opts : undefined); } }, async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') { const { fp, baseDir } = this.resolvePath(path, base); return mode === 'text' ? (readTextFile(fp, baseDir ? { baseDir } : undefined) as Promise) : ((await readFile(fp, baseDir ? { baseDir } : undefined)).buffer as ArrayBuffer); }, async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) { // NOTE: this could be very slow for large files and might block the UI thread // so do not use this for large files const { fp, baseDir } = this.resolvePath(path, base); // Skip the redundant `exists` IPC after the first write to this dir // in the current session — `useProgressAutoSave` writes to the same // per-book directory once every ~1.5s while the user is reading, so // checking each time roughly doubles the IPC cost of saveBookConfig. // See `knownExistingDirs` near the top of this file for the cache // invariants. await ensureDirExists(this, getDirPath(path), base); if (typeof content === 'string') { return writeTextFile(fp, content, baseDir ? { baseDir } : undefined); } else if (content instanceof File) { // Fast path for NativeFile inputs (e.g. user-picked source on import): // do a native filesystem copy at the Rust side rather than pumping // ~1 MB chunks through the Tauri IPC bridge. On Android the stream // path costs ~400 ms per IPC round-trip, which puts a 250 MB file // at ~100 s; the native copy is bound by disk throughput instead. try { if (content instanceof NativeFile) { const src = content.getNativeLocation(); const opts: { fromPathBaseDir?: number; toPathBaseDir?: number } = {}; if (src.baseDir != null) opts.fromPathBaseDir = src.baseDir; if (baseDir) opts.toPathBaseDir = baseDir; return await copyFile(src.path, fp, Object.keys(opts).length > 0 ? opts : undefined); } } catch (error) { console.warn('Native copy failed, falling back to stream copy:', error); } const writeOptions = { write: true, create: true, baseDir: baseDir ? baseDir : undefined, } as WriteFileOptions; return await writeFile(fp, content.stream(), writeOptions); } else { return await writeFile(fp, new Uint8Array(content), baseDir ? { baseDir } : undefined); } }, async removeFile(path: string, base: BaseDir) { const { fp, baseDir } = this.resolvePath(path, base); await remove(fp, baseDir ? { baseDir } : undefined); }, async createDir(path: string, base: BaseDir, recursive = false) { const { fp, baseDir } = this.resolvePath(path, base); await mkdir(fp, { baseDir: baseDir ? baseDir : undefined, recursive }); // Now that the dir is on disk, record it so subsequent writes can // skip the `exists` probe. markDirKnown(base, path); }, async removeDir(path: string, base: BaseDir, recursive = false) { const { fp, baseDir } = this.resolvePath(path, base); await remove(fp, { baseDir: baseDir ? baseDir : undefined, recursive }); // The cached entry for this dir is now stale, and a recursive remove // also tears down everything beneath it. Drop every cached entry that // points at this dir or any of its descendants so the next write // here goes through the slow `exists`+`createDir` path again. const prefix = dirCacheKey(base, path); forgetDirKnown(base, path); if (recursive) { // Iterate a snapshot — Set forbids mutation during iteration. for (const key of Array.from(knownExistingDirs)) { if (key === prefix || key.startsWith(prefix + '/')) { knownExistingDirs.delete(key); } } } }, async readDir(path: string, base: BaseDir) { const { fp, baseDir } = this.resolvePath(path, base); const getRelativePath = (filePath: string, basePath: string): string => { let relativePath = filePath; if (filePath.toLowerCase().startsWith(basePath.toLowerCase())) { relativePath = filePath.substring(basePath.length); } if (relativePath.startsWith('\\') || relativePath.startsWith('/')) { relativePath = relativePath.substring(1); } return relativePath; }; // Use Rust WalkDir for massive performance gain on absolute paths if (!baseDir || baseDir === 0) { try { const files = await invoke<{ path: string; size: number }[]>('read_dir', { path: fp, recursive: true, extensions: ['*'], }); return files.map((file) => ({ path: getRelativePath(file.path, fp), size: file.size, })); } catch (e) { console.error('Rust read_dir failed, falling back to JS recursion', e); } } // Fallback to readDir for non-absolute paths or on error const entries = await readDir(fp, baseDir ? { baseDir } : undefined); const fileList: FileItem[] = []; const readDirRecursively = async ( parent: string, relative: string, entries: DirEntry[], fileList: FileItem[], ) => { for (const entry of entries) { if (entry.isDirectory) { const dir = await join(parent, entry.name); const relativeDir = relative ? await join(relative, entry.name) : entry.name; try { const entries = await readDir(dir, baseDir ? { baseDir } : undefined); await readDirRecursively(dir, relativeDir, entries, fileList); } catch { console.warn(`Skipping unreadable dir: ${dir}`); } } else { const filePath = await join(parent, entry.name); const relativePath = relative ? await join(relative, entry.name) : entry.name; const opts = baseDir ? { baseDir } : undefined; const fileSize = await stat(filePath, opts) .then((info) => info.size) .catch(() => 0); fileList.push({ path: relativePath, size: fileSize, }); } } }; await readDirRecursively(fp, '', entries, fileList); return fileList; }, async exists(path: string, base: BaseDir) { const { fp, baseDir } = this.resolvePath(path, base); try { const res = await exists(fp, baseDir ? { baseDir } : undefined); return res; } catch { return false; } }, async stats(path: string, base: BaseDir) { const { fp, baseDir } = this.resolvePath(path, base); return await stat(fp, baseDir ? { baseDir } : undefined); }, }; const DIST_CHANNEL = (process.env['NEXT_PUBLIC_DIST_CHANNEL'] || 'readest') as DistChannel; export class NativeAppService extends BaseAppService { fs = nativeFileSystem; override appPlatform = 'tauri' as AppPlatform; override isAppDataSandbox = ['android', 'ios'].includes(OS_TYPE); override isMobile = ['android', 'ios'].includes(OS_TYPE); override isAndroidApp = OS_TYPE === 'android'; override isIOSApp = OS_TYPE === 'ios'; override isMacOSApp = OS_TYPE === 'macos'; override isLinuxApp = OS_TYPE === 'linux'; override isWindowsApp = OS_TYPE === 'windows'; override isMobileApp = ['android', 'ios'].includes(OS_TYPE); override isDesktopApp = ['macos', 'windows', 'linux'].includes(OS_TYPE); override isAppImage = Boolean(window.__READEST_IS_APPIMAGE); override isEink = Boolean(window.__READEST_IS_EINK); override hasTrafficLight = OS_TYPE === 'macos'; override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); override hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); override hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); // No desktop platform draws a rounded, transparent window anymore: the Linux // window is opaque with square corners to avoid the WebKitGTK "turns // invisible while busy" bug (#3682). override hasRoundedWindow = false; override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android'; override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android'; override hasUpdater = OS_TYPE !== 'ios' && !process.env['NEXT_PUBLIC_DISABLE_UPDATER'] && !window.__READEST_UPDATER_DISABLED; // orientation lock is not supported on iPad override hasOrientationLock = (OS_TYPE === 'ios' && getOSPlatform() === 'ios') || OS_TYPE === 'android'; override hasScreenBrightness = OS_TYPE === 'ios' || OS_TYPE === 'android'; override hasIAP = OS_TYPE === 'ios' || (OS_TYPE === 'android' && DIST_CHANNEL === 'playstore'); // CustomizeRootDir has a blocker on macOS App Store builds due to Security Scoped Resource restrictions. // See: https://github.com/tauri-apps/tauri/issues/3716 override canCustomizeRootDir = DIST_CHANNEL !== 'appstore'; override canReadExternalDir = DIST_CHANNEL !== 'appstore' && DIST_CHANNEL !== 'playstore'; override supportsCanvasContext2DFilter = OS_TYPE !== 'ios' && OS_TYPE !== 'macos' && OS_TYPE !== 'linux'; override distChannel = DIST_CHANNEL; override storefrontRegionCode: string | null = null; override isOnlineCatalogsAccessible = true; private execDir?: string = undefined; private customRootDir?: string = undefined; constructor(customRootDir?: string) { super(); if (customRootDir) { this.customRootDir = customRootDir; } } override async init() { const execDir = await invoke('get_executable_dir'); this.execDir = execDir; // Report the WebView User-Agent so Sentry can tag crashes with the // engine/version (the injected browser SDK's UA context isn't forwarded). try { await invoke('set_webview_info', { userAgent: navigator.userAgent }); } catch (err) { console.warn('[nativeAppService] set_webview_info failed:', err); } // Ask Rust whether the in-app updater must stay hidden (READEST_DISABLE_UPDATER, // Flatpak, or a Linux deb/rpm/pacman install that Tauri can't self-update). The // command is the reliable source of truth; the `__READEST_UPDATER_DISABLED` // init-script global isn't dependable on every Linux/WebKitGTK setup (#4874). if (this.isDesktopApp) { try { const updaterDisabled = await invoke('is_updater_disabled'); this.hasUpdater = this.hasUpdater && !updaterDisabled; } catch (err) { console.warn('[nativeAppService] is_updater_disabled failed:', err); } } if ( process.env['NEXT_PUBLIC_PORTABLE_APP'] || (await this.fs.exists(`${execDir}/${SETTINGS_FILENAME}`, 'None')) ) { this.isPortableApp = true; this.fs.resolvePath = getPathResolver({ customRootDir: execDir, isPortable: this.isPortableApp, execDir, }); } const settings = await this.loadSettings(); if (this.customRootDir || settings.customRootDir) { this.fs.resolvePath = getPathResolver({ customRootDir: this.customRootDir || settings.customRootDir, isPortable: this.isPortableApp, execDir, }); } if (this.isIOSApp) { this.isOnlineCatalogsAccessible = this.distChannel !== 'appstore'; try { const res = await getStorefrontRegionCode(); if (res?.regionCode) { this.storefrontRegionCode = res.regionCode; } } catch (err) { // Storefront.current is nil on simulators without a signed-in // App Store account, and may also fail on real devices with no // StoreKit configuration. Treat as "unknown region" — we leave // storefrontRegionCode as null and let downstream features that // depend on region degrade gracefully. console.warn('[nativeAppService] getStorefrontRegionCode failed:', err); } } await this.prepareBooksDir(); await this.runMigrations(); } override async runMigrations() { try { const settings = await this.loadSettings(); const lastMigrationVersion = settings.migrationVersion || 0; await super.runMigrations(lastMigrationVersion); if (lastMigrationVersion < 20251029) { try { await this.migrate20251029(); } catch (error) { console.error('Error migrating to version 20251029:', error); } } if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) { await this.saveSettings({ ...settings, migrationVersion: this.CURRENT_MIGRATION_VERSION, }); } } catch (error) { console.error('Failed to run migrations:', error); } } override resolvePath(fp: string, base: BaseDir): ResolvedPath { return this.fs.resolvePath(fp, base); } async setCustomRootDir(customRootDir: string) { this.fs.resolvePath = getPathResolver({ customRootDir, isPortable: this.isPortableApp, execDir: this.execDir, }); await this.prepareBooksDir(); } 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, recursive: true, }); if (selected) { // Tauri's dialog plugin only auto-grants fs_scope; the asset // protocol scope still needs an explicit allow before // RemoteFile / convertFileSrc-based reads can succeed against // arbitrary user paths. Persisted-scope plugin makes this // sticky across restarts. await this.allowPathsInScopes([selected as string], true); } return selected as string; } async selectFiles(name: string, extensions: string[]): Promise { const selected = await openDialog({ multiple: true, filters: [{ name, extensions }], }); const files = Array.isArray(selected) ? selected : selected ? [selected] : []; const decoded = OS_TYPE === 'ios' ? files.map((f) => safeDecodePath(f)) : files; if (decoded.length > 0) { // See the note in selectDirectory above. await this.allowPathsInScopes(decoded, false); } return decoded; } /** * Best-effort: ask the Rust side to extend `fs_scope` and * `asset_protocol_scope` to cover the given paths. Errors are logged * and swallowed because the import path can still succeed via the * NativeFile fallback even when scope extension fails. */ async allowPathsInScopes(paths: string[], isDirectory: boolean): Promise { try { await invoke('allow_paths_in_scopes', { paths, isDirectory }); } catch (e) { console.warn('allow_paths_in_scopes failed:', e); } } async saveFile( filename: string, content: string | ArrayBuffer | null, options?: { filePath?: string; mimeType?: string; share?: boolean; sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' }; }, ): Promise { try { const ext = filename.split('.').pop() || ''; // Linux desktop has no system share sheet; Windows WebView2's native // share UI (via tauri-plugin-sharekit) blocks the main thread waiting // on complete/cancel callbacks that may never fire when the user // dismisses the picker, freezing the app (issue #4343). Both fall // through to saveDialog instead. const wantShare = !this.isLinuxApp && !this.isWindowsApp && (this.isIOSApp || options?.share); if (wantShare) { let shareablePath = options?.filePath; if (!shareablePath) { // Write into a Temp SUBDIRECTORY, never the Temp root. On Android the // sharekit plugin copies the shared file to `/` before // sharing, and Tauri's Temp dir IS `` — writing to the root // makes that a copy onto itself, whose output stream truncates the // source to 0 bytes (the shared image came out 0 KB). A subdirectory // gives the plugin's copy a distinct source path. (#4680) const shareDir = await this.resolveFilePath('shared', 'Temp'); await mkdir(shareDir, { recursive: true }); shareablePath = await this.resolveFilePath(`shared/${filename}`, 'Temp'); if (typeof content === 'string') { await writeTextFile(shareablePath, content); } else if (content) { await writeFile(shareablePath, new Uint8Array(content)); } } try { await shareFile(shareablePath, { mimeType: options?.mimeType || 'application/octet-stream', // Anchor the macOS NSSharingServicePicker / iPad popover to // the trigger button. Without this, the picker pops at the // WebView's top-left corner. ...(options?.sharePosition ? { position: options.sharePosition } : {}), }); } catch (error) { // The plugin throws on user cancellation (e.g. dismissing the // Android share sheet returns "Share cancelled"). That's not a // failure — the user explicitly chose not to share, so we must // NOT fall back to saveDialog and pop a "Save As..." prompt. // Same goes for any other share error: the caller asked for a // share sheet, fulfilled or not, the saveDialog flow is a // completely different user intent. console.warn('shareFile did not complete:', error); } return true; } const filePath = await saveDialog({ defaultPath: filename, filters: [{ name: ext.toUpperCase(), extensions: [ext] }], }); if (!filePath) return false; if (typeof content === 'string') { await writeTextFile(filePath, content); } else if (content) { await writeFile(filePath, new Uint8Array(content)); } return true; } catch (error) { console.error('Failed to save file:', error); return false; } } async saveImageToGallery( filename: string, content: ArrayBuffer, mimeType: string, ): Promise { // MediaStore is Android-only; other platforms keep the saveFile/share path. if (!this.isAndroidApp) return false; // Write the bytes to a Temp subdirectory (not the Temp root, mirroring the // share path), then hand the path to the native MediaStore insert. const shareDir = await this.resolveFilePath('shared', 'Temp'); await mkdir(shareDir, { recursive: true }); const srcPath = await this.resolveFilePath(`shared/${filename}`, 'Temp'); try { await writeFile(srcPath, new Uint8Array(content)); const res = await saveImageToGallery({ srcPath, fileName: filename, mimeType, albumName: 'Readest', }); return res.success; } catch (error) { console.error('Failed to save image to gallery:', error); return false; } finally { // Best-effort cleanup of the staged file. await remove(srcPath).catch(() => {}); } } async ask(message: string): Promise { return await ask(message); } async openDatabase( schema: SchemaType, path: string, base: BaseDir, opts?: DatabaseOpts, ): Promise { const fullPath = await this.resolveFilePath(path, base); const { NativeDatabaseService } = await import('./database/nativeDatabaseService'); const db = await NativeDatabaseService.open(`sqlite:${fullPath}`, opts); const { migrate } = await import('./database/migrate'); const { getMigrations } = await import('./database/migrations'); await migrate(db, getMigrations(schema)); return db; } async migrate20251029() { console.log('Running migration 20251029 to update paths in Images dir...'); const rootPath = await this.resolveFilePath('..', 'Data'); const newDir = await this.fs.getPrefix('Images'); const oldDir = await join(rootPath, 'Images', 'Readest', 'Images'); const dirToDelete = await join(rootPath, 'Images', 'Readest'); // Skip silently on fresh installs that never had the legacy layout. // copyFiles / deleteDir would otherwise throw `os error 2` when the // old directory does not exist, which is harmless but noisy. if (!(await this.fs.exists(oldDir, 'None'))) { console.log('Migration 20251029: legacy Images/Readest/Images not found, skipping.'); return; } await copyFiles(this, oldDir, newDir); if (await this.fs.exists(dirToDelete, 'None')) { await this.deleteDir(dirToDelete, 'None', true); } } }