refactor: unified path resolver on all platforms (#1730)

This commit is contained in:
Huang Xin
2025-08-01 22:51:17 +08:00
committed by GitHub
parent 7e559c5b90
commit 288abc678c
10 changed files with 87 additions and 68 deletions
@@ -43,7 +43,18 @@
"identifier": "fs:allow-cache-read",
"allow": [
{
"path": "$CACHEDIR/**/*"
"path": "$APPCACHE/**/*"
},
{
"path": "/private/var/mobile/Containers/Data/Application/**/*"
}
]
},
{
"identifier": "fs:allow-cache-write",
"allow": [
{
"path": "$APPCACHE/**/*"
},
{
"path": "/private/var/mobile/Containers/Data/Application/**/*"
@@ -28,6 +28,7 @@
"allow": [
"$RESOURCE/**",
"$APPDATA/**/*",
"$APPCACHE/**/*",
"$TEMP/**/*",
"/private/var/mobile/Containers/Data/Application/**/*"
],
+1 -1
View File
@@ -16,7 +16,7 @@ const UpdaterPage = () => {
</div>
}
>
<div className='px-12 py-4'>
<div className='bg-base-100 px-12 py-4'>
<UpdaterContent />
</div>
</Suspense>
@@ -143,6 +143,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
return (
prevProps.book.coverImageUrl === nextProps.book.coverImageUrl &&
prevProps.book.metadata?.coverImageUrl === nextProps.book.metadata?.coverImageUrl &&
prevProps.book.updatedAt === nextProps.book.updatedAt &&
prevProps.mode === nextProps.mode &&
prevProps.coverFit === nextProps.coverFit &&
prevProps.isPreview === nextProps.isPreview &&
@@ -106,6 +106,7 @@ export const UpdaterContent = ({
}
};
const checkAndroidUpdate = async () => {
if (!appService) return;
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
@@ -114,7 +115,7 @@ export const UpdaterContent = ({
const platformKey = OS_ARCH === 'aarch64' ? 'android-arm64' : 'android-universal';
const arch = OS_ARCH === 'aarch64' ? 'arm64' : 'universal';
const downloadUrl = data.platforms[platformKey]?.url as string;
const cachePrefix = appService?.fs.getPrefix('Cache');
const cachePrefix = await appService.fs.getPrefix('Cache');
const apkFilePath = `${cachePrefix}/Readest_${data.version}_${arch}.apk`;
setUpdate({
currentVersion,
@@ -180,8 +180,8 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
files = (await selectImageFileTauri()) as string[];
if (appService && files.length > 0) {
metadata.coverImageFile = files[0]!;
const tempName = 'cover-temp.png';
const cachePrefix = appService.fs.getPrefix('Cache');
const tempName = `cover-${Date.now()}.png`;
const cachePrefix = await appService.fs.getPrefix('Cache');
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
setNewCoverImageUrl(metadata.coverImageUrl!);
@@ -224,7 +224,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
'border border-gray-300 bg-white hover:bg-gray-50',
'disabled:cursor-not-allowed disabled:opacity-50',
'text-sm sm:text-xs',
isCoverLocked && '!text-base-content !bg-base-200',
isCoverLocked ? '!text-base-content !bg-base-200' : '!text-gray-500',
)}
title={_('Change cover image')}
>
+14 -15
View File
@@ -46,6 +46,13 @@ import { ProgressHandler } from '@/utils/transfer';
import { TxtToEpubConverter } from '@/utils/txt';
import { BOOK_FILE_NOT_FOUND_ERROR } from './errors';
export type ResolvedPath = {
baseDir: number;
basePrefix: () => Promise<string>;
fp: string;
base: BaseDir;
};
export abstract class BaseAppService implements AppService {
osPlatform: OsPlatform = getOSPlatform();
appPlatform: AppPlatform = 'tauri';
@@ -70,11 +77,9 @@ export abstract class BaseAppService implements AppService {
abstract fs: FileSystem;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
abstract getCoverImageUrl(book: Book): string;
abstract getCoverImageBlobUrl(book: Book): Promise<string>;
abstract getInitBooksDir(): Promise<string>;
abstract getCacheDir(): Promise<string>;
abstract selectDirectory(): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
@@ -102,7 +107,7 @@ export abstract class BaseAppService implements AppService {
settings = JSON.parse(txt as string);
const version = settings.version ?? 0;
if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) {
settings.localBooksDir = await this.getInitBooksDir();
settings.localBooksDir = await this.fs.getPrefix('Books');
settings.version = SYSTEM_SETTINGS_VERSION;
}
settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings };
@@ -115,7 +120,7 @@ export abstract class BaseAppService implements AppService {
settings = {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.getInitBooksDir(),
localBooksDir: await this.fs.getPrefix('Books'),
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
@@ -129,15 +134,6 @@ export abstract class BaseAppService implements AppService {
}
this.localBooksDir = settings.localBooksDir;
const cacheDir = await this.getCacheDir();
this.fs.getPrefix = (baseDir: BaseDir) => {
if (baseDir === 'Books') {
return this.localBooksDir;
} else if (baseDir === 'Cache') {
return cacheDir;
}
return null;
};
return settings;
}
@@ -414,12 +410,15 @@ export abstract class BaseAppService implements AppService {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
bookCoverDownloaded = true;
}
} catch (error) {
// don't throw error here since some books may not have cover images at all
console.log(`Failed to download cover file for book: '${book.title}'`, error);
} finally {
if (needDownCover) {
completedFiles.count++;
}
}
if (needDownBook) {
@@ -14,17 +14,25 @@ import {
} 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 {
join,
basename,
appDataDir,
appConfigDir,
appCacheDir,
appLogDir,
tempDir,
} 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 { getOSPlatform, isContentURI, isFileURI, 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 { BaseAppService, ResolvedPath } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
declare global {
@@ -36,38 +44,49 @@ declare global {
const OS_TYPE = osType();
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
// Usage:
// 1. baseDir + fp
// 2. basePrefix + fp or the getPrefix util in FileSystem
const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
switch (base) {
case 'Settings':
return { baseDir: BaseDirectory.AppConfig, fp, base };
return { baseDir: BaseDirectory.AppConfig, basePrefix: appConfigDir, fp: path, base };
case 'Data':
return { baseDir: BaseDirectory.AppData, fp, base };
return { baseDir: BaseDirectory.AppData, basePrefix: appDataDir, fp: path, base };
case 'Cache':
return { baseDir: BaseDirectory.AppCache, fp, base };
return { baseDir: BaseDirectory.AppCache, basePrefix: appCacheDir, fp: path, base };
case 'Log':
return { baseDir: BaseDirectory.AppLog, fp, base };
return { baseDir: BaseDirectory.AppLog, basePrefix: appLogDir, fp: path, base };
case 'Books':
return {
baseDir: BaseDirectory.AppData,
fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`,
basePrefix: appDataDir,
fp: `${LOCAL_BOOKS_SUBDIR}/${path}`,
base,
};
case 'None':
return {
baseDir: 0,
fp,
basePrefix: async () => '',
fp: path,
base,
};
default:
return {
baseDir: BaseDirectory.Temp,
fp,
basePrefix: tempDir,
fp: path,
base,
};
}
};
export const nativeFileSystem: FileSystem = {
async getPrefix(base: BaseDir) {
const { basePrefix, fp } = resolvePath('', base);
const basePath = await basePrefix();
return fp ? await join(basePath, fp) : basePath;
},
getURL(path: string) {
return isValidURL(path) ? path : convertFileSrc(path);
},
@@ -89,8 +108,8 @@ export const nativeFileSystem: FileSystem = {
} 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 prefix = await this.getPrefix('Cache');
const dst = await join(prefix, fname);
const res = await copyURIToPath({ uri: path, dst });
if (!res.success) {
console.error('Failed to open file:', res);
@@ -99,8 +118,11 @@ export const nativeFileSystem: FileSystem = {
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;
const prefix = await this.getPrefix(base);
if (isFileURI(path)) {
path = path.replace(/^file:\/\//, '');
}
const absolutePath = path.startsWith('/') ? path : await join(prefix, path);
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).
@@ -114,13 +136,13 @@ export const nativeFileSystem: FileSystem = {
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
if (isContentURI(srcPath)) {
const prefix = this.getPrefix(base);
const prefix = await this.getPrefix(base);
if (!prefix) {
throw new Error('Invalid base directory');
}
const res = await copyURIToPath({
uri: srcPath,
dst: `${prefix}/${dstPath}`,
dst: await join(prefix, dstPath),
});
if (!res.success) {
console.error('Failed to copy file:', res);
@@ -201,9 +223,6 @@ export const nativeFileSystem: FileSystem = {
return false;
}
},
getPrefix() {
return null;
},
};
export class NativeAppService extends BaseAppService {
@@ -232,18 +251,10 @@ export class NativeAppService extends BaseAppService {
(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 } {
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
return resolvePath(fp, base);
}
async getInitBooksDir(): Promise<string> {
return join(await appDataDir(), LOCAL_BOOKS_SUBDIR);
}
async getCacheDir(): Promise<string> {
return await appCacheDir();
}
async selectDirectory(): Promise<string> {
const selected = await openDialog({
directory: true,
+12 -17
View File
@@ -3,19 +3,20 @@ import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { RemoteFile } from '@/utils/file';
import { isPWA } from './environment';
import { BaseAppService } from './appService';
import { BaseAppService, ResolvedPath } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
const basePrefix = async () => '';
const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
switch (base) {
case 'Books':
return { baseDir: 0, fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, base };
return { baseDir: 0, basePrefix, fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base };
case 'None':
return { baseDir: 0, fp, base };
return { baseDir: 0, basePrefix, fp: path, base };
default:
return { baseDir: 0, fp: `${base}/${fp}`, base };
return { baseDir: 0, basePrefix, fp: `${base}/${path}`, base };
}
};
@@ -188,8 +189,10 @@ const indexedDBFileSystem: FileSystem = {
request.onerror = () => reject(request.error);
});
},
getPrefix() {
return null;
async getPrefix(base: BaseDir) {
const { basePrefix, fp } = resolvePath('', base);
const basePath = await basePrefix();
return fp ? `${basePath}/${fp}` : basePath;
},
};
@@ -199,18 +202,10 @@ export class WebAppService extends BaseAppService {
override appPlatform = 'web' as AppPlatform;
override hasSafeAreaInset = isPWA();
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
return resolvePath(fp, base);
}
async getInitBooksDir(): Promise<string> {
return LOCAL_BOOKS_SUBDIR;
}
async getCacheDir(): Promise<string> {
return 'Cache';
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in browser');
}
+1 -1
View File
@@ -19,7 +19,7 @@ export interface FileSystem {
createDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
removeDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
exists(path: string, base: BaseDir): Promise<boolean>;
getPrefix(base: BaseDir): string | null;
getPrefix(base: BaseDir): Promise<string>;
}
export interface AppService {