da00a94f66
Forwarded browser events carry os/rust/device context but no browser context, so crashes couldn't be correlated with the WebView version. The app now reports its User-Agent at startup via a set_webview_info command; the parsed engine (Chromium/WebKit) and major version are stored and attached as webview.engine and webview.version tags in before_send, covering both Rust panics and forwarded browser events. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
906 lines
35 KiB
TypeScript
906 lines
35 KiB
TypeScript
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<string>();
|
||
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<boolean>;
|
||
createDir: (path: string, base: BaseDir, recursive?: boolean) => Promise<void>;
|
||
},
|
||
dir: string,
|
||
base: BaseDir,
|
||
): Promise<void> {
|
||
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<string>)
|
||
: ((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<string>('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<boolean>('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<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,
|
||
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<string[]> {
|
||
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<void> {
|
||
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<boolean> {
|
||
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 `<cacheDir>/<name>` before
|
||
// sharing, and Tauri's Temp dir IS `<cacheDir>` — 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<boolean> {
|
||
// 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<boolean> {
|
||
return await ask(message);
|
||
}
|
||
|
||
async openDatabase(
|
||
schema: SchemaType,
|
||
path: string,
|
||
base: BaseDir,
|
||
opts?: DatabaseOpts,
|
||
): Promise<DatabaseService> {
|
||
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);
|
||
}
|
||
}
|
||
}
|