import { exists, mkdir, open as openFile, readTextFile, readFile, writeTextFile, writeFile, readDir, remove, copyFile, BaseDirectory, WriteFileOptions, } from '@tauri-apps/plugin-fs'; import { convertFileSrc } from '@tauri-apps/api/core'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { join, basename, appDataDir, appCacheDir } from '@tauri-apps/api/path'; import { type as osType } from '@tauri-apps/plugin-os'; import { Book } from '@/types/book'; import { FileSystem, BaseDir, AppPlatform } from '@/types/system'; import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc'; import { getCoverFilename, getFilename } from '@/utils/book'; import { copyURIToPath } from '@/utils/bridge'; import { NativeFile, RemoteFile } from '@/utils/file'; import { BaseAppService } from './appService'; import { LOCAL_BOOKS_SUBDIR } from './constants'; declare global { interface Window { IS_ROUNDED?: boolean; __READEST_UPDATER_DISABLED?: boolean; } } const OS_TYPE = osType(); const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => { switch (base) { case 'Settings': return { baseDir: BaseDirectory.AppConfig, fp, base }; case 'Data': return { baseDir: BaseDirectory.AppData, fp, base }; case 'Cache': return { baseDir: BaseDirectory.AppCache, fp, base }; case 'Log': return { baseDir: BaseDirectory.AppLog, fp, base }; case 'Books': return { baseDir: BaseDirectory.AppData, fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, base, }; case 'None': return { baseDir: 0, fp, base, }; default: return { baseDir: BaseDirectory.Temp, fp, base, }; } }; export const nativeFileSystem: FileSystem = { 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 openFile(path: string, base: BaseDir, name?: string) { const { fp, baseDir } = resolvePath(path, base); let fname = name || getFilename(fp); if (isValidURL(path)) { return await new RemoteFile(path, fname).open(); } else if (isContentURI(path)) { fname = 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, base ? baseDir : null).open(); } else { // Otherwise, for content:// URIs (e.g. from MediaStore, Drive, or third-party apps), // we cannot access the file directly — so we copy it to a temporary cache location. const prefix = this.getPrefix('Cache'); const dst = `${prefix}/${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, base ? baseDir : null).open(); } } else { const prefix = this.getPrefix(base); const absolutePath = path.startsWith('/') ? path : prefix ? await join(prefix, path) : null; if (absolutePath && OS_TYPE !== 'android') { // 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. // RemoteFile is not usable on Android due to unknown issues of range fetch with Android WebView. return await new RemoteFile(this.getURL(absolutePath), fname).open(); } else { return await new NativeFile(fp, fname, base ? baseDir : null).open(); } } }, async copyFile(srcPath: string, dstPath: string, base: BaseDir) { if (isContentURI(srcPath)) { const prefix = this.getPrefix(base); if (!prefix) { throw new Error('Invalid base directory'); } const res = await copyURIToPath({ uri: srcPath, dst: `${prefix}/${dstPath}`, }); if (!res.success) { console.error('Failed to copy file:', res); throw new Error('Failed to copy file'); } } else { const { fp, baseDir } = resolvePath(dstPath, base); await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir }); } }, async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') { const { fp, baseDir } = resolvePath(path, base); return mode === 'text' ? (readTextFile(fp, base && { baseDir }) as Promise) : ((await readFile(fp, base && { baseDir })).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 } = resolvePath(path, base); if (typeof content === 'string') { return writeTextFile(fp, content, base && { baseDir }); } else if (content instanceof File) { const writeOptions = { write: true, create: true, baseDir } as WriteFileOptions; // TODO: use writeFile directly when @tauri-apps/plugin-fs@2.2.1 is released // return writeFile(fp, content.stream(), base && writeOptions); const file = await openFile(fp, base && writeOptions); const reader = content.stream().getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; await file.write(value); } } finally { reader.releaseLock(); await file.close(); } } else { return writeFile(fp, new Uint8Array(content), base && { baseDir }); } }, async removeFile(path: string, base: BaseDir) { const { fp, baseDir } = resolvePath(path, base); return remove(fp, base && { baseDir }); }, async createDir(path: string, base: BaseDir, recursive = false) { const { fp, baseDir } = resolvePath(path, base); await mkdir(fp, base && { baseDir, recursive }); }, async removeDir(path: string, base: BaseDir, recursive = false) { const { fp, baseDir } = resolvePath(path, base); await remove(fp, base && { baseDir, recursive }); }, async readDir(path: string, base: BaseDir) { const { fp, baseDir } = resolvePath(path, base); const list = await readDir(fp, base && { baseDir }); return list.map((entity) => { return { path: entity.name, isDir: entity.isDirectory, }; }); }, async exists(path: string, base: BaseDir) { const { fp, baseDir } = resolvePath(path, base); try { const res = await exists(fp, base && { baseDir }); return res; } catch { return false; } }, getPrefix() { return null; }, }; 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 isMobileApp = ['android', 'ios'].includes(OS_TYPE); 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'); override hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android') && !!window.IS_ROUNDED; 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 distChannel = process.env['NEXT_PUBLIC_DIST_CHANNEL'] || 'readest'; override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } { return resolvePath(fp, base); } async getInitBooksDir(): Promise { return join(await appDataDir(), LOCAL_BOOKS_SUBDIR); } async getCacheDir(): Promise { return await appCacheDir(); } async selectDirectory(): Promise { const selected = await openDialog({ directory: true, multiple: false, }); return selected as string; } async selectFiles(name: string, extensions: string[]): Promise { const selected = await openDialog({ multiple: true, filters: [{ name, extensions }], }); return Array.isArray(selected) ? selected : selected ? [selected] : []; } getCoverImageUrl = (book: Book): string => { return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`); }; getCoverImageBlobUrl = async (book: Book): Promise => { return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None'); }; }