Files
readest/apps/readest-app/src/services/appService.ts
T
Huang Xin acf2b165f3 fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720) (#4730)
* fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720)

resolveFilePath joined `${prefix}/${path}` unconditionally. For base 'None'
(in-place / external books, whose filePath lives outside Books/<hash>/) the
prefix is empty, so an already-absolute source path became `/C:\Users\...`
on Windows (and `//Users/...` on POSIX). The native upload guard added in
#4639 (transfer_file.rs `ensure_path_allowed`) then rejected that malformed
path as "permission denied: path not in filesystem scope", so uploading a
folder-imported book failed on Windows.

Return the path verbatim when the prefix is empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(library): use btn-contrast for the Import-from-folder confirm button (#4720)

Aligns the dialog's confirm CTA with the sibling import dialogs
(ImportFromUrlDialog, FailedImportsDialog), which already use the
theme-neutral, e-ink-correct btn-contrast instead of the colored
btn-primary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:59 +02:00

440 lines
13 KiB
TypeScript

import { SystemSettings } from '@/types/settings';
import {
AppPlatform,
AppService,
BaseDir,
DeleteAction,
DistChannel,
FileItem,
FileSystem,
OsPlatform,
ResolvedPath,
SaveLibraryBooksOptions,
SelectDirectoryMode,
} from '@/types/system';
import { DatabaseOpts, DatabaseService } from '@/types/database';
import { SchemaType } from '@/services/database/migrate';
import { Book, BookConfig, BookContent, ImportBookOptions, ViewSettings } from '@/types/book';
import type { BookNav } from '@/services/nav';
import { getLibraryFilename, getLibraryBackupFilename } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { ProgressHandler } from '@/utils/transfer';
import { CustomTextureInfo } from '@/styles/textures';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import type { ImportedDictionary } from './dictionaries/types';
import type { SelectedFile } from '@/hooks/useFileSelector';
import * as BookSvc from './bookService';
import * as CloudSvc from './cloudService';
import * as DictSvc from './dictionaries/dictionaryService';
import * as FontSvc from './fontService';
import * as ImageSvc from './imageService';
import * as LibrarySvc from './libraryService';
import * as Settings from './settingsService';
export abstract class BaseAppService implements AppService {
osPlatform: OsPlatform = getOSPlatform();
appPlatform: AppPlatform = 'tauri';
localBooksDir = '';
isMobile = false;
isMacOSApp = false;
isLinuxApp = false;
isAppDataSandbox = false;
isAndroidApp = false;
isIOSApp = false;
isWindowsApp = false;
isMobileApp = false;
isPortableApp = false;
isDesktopApp = false;
isAppImage = false;
isEink = false;
hasTrafficLight = false;
hasWindow = false;
hasWindowBar = false;
hasContextMenu = false;
hasRoundedWindow = false;
hasSafeAreaInset = false;
hasHaptics = false;
hasUpdater = false;
hasOrientationLock = false;
hasScreenBrightness = false;
hasIAP = false;
canCustomizeRootDir = false;
canReadExternalDir = false;
supportsCanvasContext2DFilter = true;
distChannel = 'readest' as DistChannel;
storefrontRegionCode: string | null = null;
isOnlineCatalogsAccessible = true;
protected CURRENT_MIGRATION_VERSION = 20251124;
protected abstract fs: FileSystem;
protected abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
abstract init(): Promise<void>;
abstract setCustomRootDir(customRootDir: string): Promise<void>;
abstract selectDirectory(mode: SelectDirectoryMode): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
abstract 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>;
abstract saveImageToGallery(
filename: string,
content: ArrayBuffer,
mimeType: string,
): Promise<boolean>;
abstract ask(message: string): Promise<boolean>;
abstract openDatabase(
schema: SchemaType,
path: string,
base: BaseDir,
opts?: DatabaseOpts,
): Promise<DatabaseService>;
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
if (lastMigrationVersion < 20251124) {
try {
await this.migrate20251124();
} catch (error) {
console.error('Error migrating to version 20251124:', error);
}
}
}
private async migrate20251124(): Promise<void> {
console.log('Running migration for version 20251124 to rename the backup library file...');
const oldBackupFilename = getLibraryBackupFilename();
const newBackupFilename = `${getLibraryFilename()}.bak`;
if (await this.fs.exists(oldBackupFilename, 'Books')) {
try {
const content = await this.fs.readFile(oldBackupFilename, 'Books', 'text');
await this.fs.writeFile(newBackupFilename, 'Books', content);
await this.fs.removeFile(oldBackupFilename, 'Books');
console.log('Migration to rename backup library file completed successfully.');
} catch (error) {
console.error('Error during migration to rename backup library file:', error);
}
}
}
async prepareBooksDir() {
this.localBooksDir = await this.fs.getPrefix('Books');
}
async openFile(path: string, base: BaseDir): Promise<File> {
return await this.fs.openFile(path, base);
}
async copyFile(
srcPath: string,
srcBase: BaseDir,
dstPath: string,
dstBase: BaseDir,
): Promise<void> {
return await this.fs.copyFile(srcPath, srcBase, dstPath, dstBase);
}
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
return await this.fs.readFile(path, base, mode);
}
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) {
return await this.fs.writeFile(path, base, content);
}
async createDir(path: string, base: BaseDir, recursive: boolean = true): Promise<void> {
return await this.fs.createDir(path, base, recursive);
}
async deleteFile(path: string, base: BaseDir): Promise<void> {
return await this.fs.removeFile(path, base);
}
async deleteDir(path: string, base: BaseDir, recursive: boolean = true): Promise<void> {
return await this.fs.removeDir(path, base, recursive);
}
async resolveFilePath(path: string, base: BaseDir): Promise<string> {
const prefix = await this.fs.getPrefix(base);
if (!path) return prefix;
// `base: 'None'` carries an already-absolute source path (in-place /
// external books point `book.filePath` outside Books/<hash>/) and its
// prefix is empty. Joining unconditionally turned `C:\Users\…` into
// `/C:\Users\…` (and `/Users/…` into `//Users/…`), which the native
// upload guard rejects as outside the fs scope — issue #4720.
return prefix ? `${prefix}/${path}` : path;
}
async readDirectory(path: string, base: BaseDir): Promise<FileItem[]> {
return await this.fs.readDir(path, base);
}
async exists(path: string, base: BaseDir): Promise<boolean> {
return await this.fs.exists(path, base);
}
async isDirectory(path: string, base: BaseDir): Promise<boolean> {
try {
const info = await this.fs.stats(path, base);
return info.isDirectory;
} catch {
return false;
}
}
async getImageURL(path: string): Promise<string> {
return await this.fs.getImageURL(path);
}
private get settingsCtx(): Settings.Context {
return {
fs: this.fs,
isMobile: this.isMobile,
isEink: this.isEink,
isAppDataSandbox: this.isAppDataSandbox,
};
}
private get coverCtx(): BookSvc.CoverContext {
return { fs: this.fs, appPlatform: this.appPlatform, localBooksDir: this.localBooksDir };
}
getDefaultViewSettings(): ViewSettings {
return Settings.getDefaultViewSettings(this.settingsCtx);
}
async loadSettings(): Promise<SystemSettings> {
const settings = await Settings.loadSettings(this.settingsCtx);
this.localBooksDir = settings.localBooksDir;
return settings;
}
async saveSettings(settings: SystemSettings): Promise<void> {
await Settings.saveSettings(this.fs, settings);
}
getCoverImageUrl = (book: Book): string => BookSvc.getCoverImageUrl(this.coverCtx, book);
getCoverImageBlobUrl = async (book: Book): Promise<string> =>
BookSvc.getCoverImageBlobUrl(this.coverCtx, book);
async getCachedImageUrl(pathOrUrl: string): Promise<string> {
return BookSvc.getCachedImageUrl(this.coverCtx, pathOrUrl);
}
async generateCoverImageUrl(book: Book): Promise<string> {
return BookSvc.generateCoverImageUrl(this.coverCtx, book);
}
async updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise<void> {
return BookSvc.updateCoverImage(this.coverCtx, book, imageUrl, imageFile);
}
async importFont(file?: string | File): Promise<CustomFontInfo | null> {
return FontSvc.importFont(this.fs, file);
}
async deleteFont(font: CustomFont): Promise<void> {
return FontSvc.deleteFont(this.fs, font);
}
async importImage(file?: string | File): Promise<CustomTextureInfo | null> {
return ImageSvc.importImage(this.fs, file);
}
async deleteImage(texture: CustomTextureInfo): Promise<void> {
return ImageSvc.deleteImage(this.fs, texture);
}
async importDictionaries(
files: SelectedFile[],
existingDictionaries: ImportedDictionary[] = [],
): Promise<DictSvc.ImportDictionariesResult> {
return DictSvc.importDictionaries(this.fs, files, existingDictionaries);
}
async deleteDictionary(dict: ImportedDictionary): Promise<void> {
return DictSvc.deleteDictionary(this.fs, dict);
}
async importBook(
file: string | File,
books: Book[],
options: ImportBookOptions = {},
): Promise<Book | null> {
return BookSvc.importBook(this.fs, file, books, {
saveBookConfig: this.saveBookConfig.bind(this),
generateCoverImageUrl: this.generateCoverImageUrl.bind(this),
// Pass the host platform through so the in-place fast path and the
// lookup index can normalize source paths consistently on
// case-insensitive filesystems (macOS / iOS / Windows).
osPlatform: this.osPlatform,
...options,
});
}
async deleteBook(book: Book, deleteAction: DeleteAction): Promise<void> {
return CloudSvc.deleteBook(this.fs, book, deleteAction);
}
async uploadFileToCloud(
lfp: string,
cfp: string,
base: BaseDir,
handleProgress: ProgressHandler,
hash: string,
temp: boolean = false,
) {
return CloudSvc.uploadFileToCloud(
this.fs,
this.resolveFilePath.bind(this),
lfp,
cfp,
base,
handleProgress,
hash,
temp,
);
}
async uploadReplicaFile(
kind: string,
replicaId: string,
filename: string,
lfp: string,
base: BaseDir,
onProgress: ProgressHandler,
) {
return CloudSvc.uploadReplicaFileToCloud(this.fs, this.resolveFilePath.bind(this), {
kind,
replicaId,
filename,
lfp,
base,
onProgress,
});
}
async downloadReplicaFile(
kind: string,
replicaId: string,
filename: string,
lfp: string,
base: BaseDir,
onProgress?: ProgressHandler,
) {
// Resolve the relative `<bundleDir>/<filename>` lfp against the
// replica's base dir before downloading. Mirrors how upload uses
// `resolveFilePath(opts.lfp, opts.base)`. Without this, the writer
// lands the bytes at the literal lfp (no base prefix) so subsequent
// openFile(lfp, base) calls fail with "File not found".
const dst = await this.resolveFilePath(lfp, base);
return CloudSvc.downloadReplicaFileFromCloud(this, {
kind,
replicaId,
filename,
dst,
onProgress,
});
}
async deleteReplicaBundle(kind: string, replicaId: string, filenames: string[]) {
return CloudSvc.deleteReplicaBundleFromCloud(kind, replicaId, filenames);
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
return CloudSvc.uploadBook(this.fs, this.resolveFilePath.bind(this), book, onProgress);
}
async downloadCloudFile(lfp: string, cfp: string, onProgress: ProgressHandler) {
return CloudSvc.downloadCloudFile(this, this.localBooksDir, lfp, cfp, onProgress);
}
async downloadBookCovers(books: Book[]): Promise<void> {
return CloudSvc.downloadBookCovers(this, this.fs, this.localBooksDir, books);
}
async downloadBook(
book: Book,
onlyCover = false,
redownload = false,
onProgress?: ProgressHandler,
): Promise<void> {
return CloudSvc.downloadBook(
this,
this.fs,
this.localBooksDir,
book,
onlyCover,
redownload,
onProgress,
);
}
async exportBook(book: Book): Promise<boolean> {
return BookSvc.exportBook(
this.fs,
book,
this.resolveFilePath.bind(this),
this.copyFile.bind(this),
this.saveFile.bind(this),
);
}
async refreshBookMetadata(book: Book): Promise<boolean> {
return BookSvc.refreshBookMetadata(this.fs, book);
}
async isBookAvailable(book: Book): Promise<boolean> {
return BookSvc.isBookAvailable(this.fs, book);
}
async getBookFileSize(book: Book): Promise<number | null> {
return BookSvc.getBookFileSize(this.fs, book);
}
async loadBookContent(book: Book): Promise<BookContent> {
return BookSvc.loadBookContent(this.fs, book);
}
async resolveNativeBookFilePath(book: Book): Promise<string | null> {
return BookSvc.resolveNativeBookFilePath(this.fs, this.resolveFilePath.bind(this), book);
}
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
return BookSvc.loadBookConfig(this.fs, book, settings);
}
async fetchBookDetails(book: Book) {
return BookSvc.fetchBookDetails(this.fs, book, this.downloadBook.bind(this));
}
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
return BookSvc.saveBookConfig(this.fs, book, config, settings);
}
async loadBookNav(book: Book) {
return BookSvc.loadBookNav(this.fs, book);
}
async saveBookNav(book: Book, nav: BookNav) {
return BookSvc.saveBookNav(this.fs, book, nav);
}
async loadLibraryBooks(): Promise<Book[]> {
return LibrarySvc.loadLibraryBooks(this.fs, this.generateCoverImageUrl.bind(this));
}
async saveLibraryBooks(books: Book[], options?: SaveLibraryBooksOptions): Promise<void> {
return LibrarySvc.saveLibraryBooks(this.fs, books, options);
}
}