From 288abc678cda63a75246767a083e2d67f56c1f49 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 1 Aug 2025 22:51:17 +0800 Subject: [PATCH] refactor: unified path resolver on all platforms (#1730) --- .../src-tauri/capabilities/default.json | 13 +++- apps/readest-app/src-tauri/tauri.conf.json | 1 + apps/readest-app/src/app/updater/page.tsx | 2 +- apps/readest-app/src/components/BookCover.tsx | 1 + .../src/components/UpdaterWindow.tsx | 3 +- .../components/metadata/BookDetailEdit.tsx | 6 +- apps/readest-app/src/services/appService.ts | 29 ++++---- .../src/services/nativeAppService.ts | 69 +++++++++++-------- .../readest-app/src/services/webAppService.ts | 29 ++++---- apps/readest-app/src/types/system.ts | 2 +- 10 files changed, 87 insertions(+), 68 deletions(-) diff --git a/apps/readest-app/src-tauri/capabilities/default.json b/apps/readest-app/src-tauri/capabilities/default.json index 0cbd894f..86131566 100644 --- a/apps/readest-app/src-tauri/capabilities/default.json +++ b/apps/readest-app/src-tauri/capabilities/default.json @@ -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/**/*" diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index c72cffdc..941d0e0b 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -28,6 +28,7 @@ "allow": [ "$RESOURCE/**", "$APPDATA/**/*", + "$APPCACHE/**/*", "$TEMP/**/*", "/private/var/mobile/Containers/Data/Application/**/*" ], diff --git a/apps/readest-app/src/app/updater/page.tsx b/apps/readest-app/src/app/updater/page.tsx index 31a868d3..043c96c9 100644 --- a/apps/readest-app/src/app/updater/page.tsx +++ b/apps/readest-app/src/app/updater/page.tsx @@ -16,7 +16,7 @@ const UpdaterPage = () => { } > -
+
diff --git a/apps/readest-app/src/components/BookCover.tsx b/apps/readest-app/src/components/BookCover.tsx index 8a9eee45..1339c4e5 100644 --- a/apps/readest-app/src/components/BookCover.tsx +++ b/apps/readest-app/src/components/BookCover.tsx @@ -143,6 +143,7 @@ const BookCover: React.FC = memo( 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 && diff --git a/apps/readest-app/src/components/UpdaterWindow.tsx b/apps/readest-app/src/components/UpdaterWindow.tsx index 636c8e52..9c796967 100644 --- a/apps/readest-app/src/components/UpdaterWindow.tsx +++ b/apps/readest-app/src/components/UpdaterWindow.tsx @@ -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, diff --git a/apps/readest-app/src/components/metadata/BookDetailEdit.tsx b/apps/readest-app/src/components/metadata/BookDetailEdit.tsx index 2dec4a8e..de1653b4 100644 --- a/apps/readest-app/src/components/metadata/BookDetailEdit.tsx +++ b/apps/readest-app/src/components/metadata/BookDetailEdit.tsx @@ -180,8 +180,8 @@ const BookDetailEdit: React.FC = ({ 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 = ({ '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')} > diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 53daf8ef..654644ca 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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; + 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; - abstract getInitBooksDir(): Promise; - abstract getCacheDir(): Promise; abstract selectDirectory(): Promise; abstract selectFiles(name: string, extensions: string[]): Promise; @@ -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) { diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 0fa61d44..324a36c2 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -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 { - return join(await appDataDir(), LOCAL_BOOKS_SUBDIR); - } - - async getCacheDir(): Promise { - return await appCacheDir(); - } - async selectDirectory(): Promise { const selected = await openDialog({ directory: true, diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index f7c750f4..812f34c4 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -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 { - return LOCAL_BOOKS_SUBDIR; - } - - async getCacheDir(): Promise { - return 'Cache'; - } - async selectDirectory(): Promise { throw new Error('selectDirectory is not supported in browser'); } diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 759ad754..993cac8e 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -19,7 +19,7 @@ export interface FileSystem { createDir(path: string, base: BaseDir, recursive?: boolean): Promise; removeDir(path: string, base: BaseDir, recursive?: boolean): Promise; exists(path: string, base: BaseDir): Promise; - getPrefix(base: BaseDir): string | null; + getPrefix(base: BaseDir): Promise; } export interface AppService {