Files
readest/apps/readest-app/src/services/nodeAppService.ts
T
Luis Cortes 9496de301b fix(node-app-service): ensure correct cross-platform path resolution in NodeAppService (#4819)
* refactor(node): use path.join() in path resolver

* fix(node): use native path separators in resolveFilePath.
2026-06-27 08:19:21 +02:00

440 lines
13 KiB
TypeScript

import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as nodePath from 'node:path';
import * as os from 'node:os';
import { pathToFileURL } from 'node:url';
import { FileSystem, BaseDir, OsPlatform, ResolvedPath, FileItem, FileInfo } from '@/types/system';
import { DatabaseOpts, DatabaseService } from '@/types/database';
import { SchemaType } from '@/services/database/migrate';
import { BaseAppService } from './appService';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
LOCAL_DICTIONARIES_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
} from './constants';
const APP_NAME = 'Readest';
// System directory getters matching Tauri's appDataDir, appConfigDir, etc.
function getAppDataDir(): string {
switch (process.platform) {
case 'darwin':
return nodePath.join(os.homedir(), 'Library', 'Application Support', APP_NAME);
case 'win32':
return nodePath.join(
process.env['APPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Roaming'),
APP_NAME,
);
default:
return nodePath.join(
process.env['XDG_DATA_HOME'] || nodePath.join(os.homedir(), '.local', 'share'),
APP_NAME,
);
}
}
function getAppConfigDir(): string {
switch (process.platform) {
case 'darwin':
return nodePath.join(os.homedir(), 'Library', 'Application Support', APP_NAME);
case 'win32':
return nodePath.join(
process.env['APPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Roaming'),
APP_NAME,
);
default:
return nodePath.join(
process.env['XDG_CONFIG_HOME'] || nodePath.join(os.homedir(), '.config'),
APP_NAME,
);
}
}
function getAppCacheDir(): string {
switch (process.platform) {
case 'darwin':
return nodePath.join(os.homedir(), 'Library', 'Caches', APP_NAME);
case 'win32':
return nodePath.join(
process.env['LOCALAPPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Local'),
APP_NAME,
'Cache',
);
default:
return nodePath.join(
process.env['XDG_CACHE_HOME'] || nodePath.join(os.homedir(), '.cache'),
APP_NAME,
);
}
}
function getAppLogDir(): string {
switch (process.platform) {
case 'darwin':
return nodePath.join(os.homedir(), 'Library', 'Logs', APP_NAME);
case 'win32':
return nodePath.join(
process.env['LOCALAPPDATA'] || nodePath.join(os.homedir(), 'AppData', 'Local'),
APP_NAME,
'Logs',
);
default:
return nodePath.join(
process.env['XDG_STATE_HOME'] || nodePath.join(os.homedir(), '.local', 'state'),
APP_NAME,
);
}
}
function getTempDir(): string {
return nodePath.join(os.tmpdir(), APP_NAME);
}
// Path resolver matching nativeAppService's getPathResolver pattern.
// When customRootDir is set, Settings/Data/Books/Fonts/Images all resolve under it.
// Otherwise they use standard system directories.
const getPathResolver = ({ customRootDir }: { customRootDir?: string } = {}) => {
const isCustomBaseDir = Boolean(customRootDir);
const getCustomBasePrefix = isCustomBaseDir
? (base: BaseDir) => {
const dataDirs: BaseDir[] = [
'Settings',
'Data',
'Books',
'Fonts',
'Images',
'Dictionaries',
];
const leafDir = dataDirs.includes(base) ? '' : base;
return leafDir ? nodePath.join(customRootDir!, leafDir) : customRootDir!;
}
: undefined;
return (fp: string, base: BaseDir): ResolvedPath => {
const custom = getCustomBasePrefix?.(base);
switch (base) {
case 'Settings':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppConfigDir(),
fp: custom ? nodePath.join(custom, fp) : fp,
base,
};
case 'Cache':
return {
baseDir: 0,
basePrefix: async () => getAppCacheDir(),
fp,
base,
};
case 'Log':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppLogDir(),
fp: custom ? nodePath.join(custom, fp) : fp,
base,
};
case 'Data':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppDataDir(),
fp: custom ? nodePath.join(custom, DATA_SUBDIR, fp) : nodePath.join(DATA_SUBDIR, fp),
base,
};
case 'Books':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppDataDir(),
fp: custom
? nodePath.join(custom, LOCAL_BOOKS_SUBDIR, fp)
: nodePath.join(LOCAL_BOOKS_SUBDIR, fp),
base,
};
case 'Fonts':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppDataDir(),
fp: custom
? nodePath.join(custom, LOCAL_FONTS_SUBDIR, fp)
: nodePath.join(LOCAL_FONTS_SUBDIR, fp),
base,
};
case 'Images':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppDataDir(),
fp: custom
? nodePath.join(custom, LOCAL_IMAGES_SUBDIR, fp)
: nodePath.join(LOCAL_IMAGES_SUBDIR, fp),
base,
};
case 'Dictionaries':
return {
baseDir: 0,
basePrefix: async () => custom ?? getAppDataDir(),
fp: custom
? nodePath.join(custom, LOCAL_DICTIONARIES_SUBDIR, fp)
: nodePath.join(LOCAL_DICTIONARIES_SUBDIR, fp),
base,
};
case 'None':
return {
baseDir: 0,
basePrefix: async () => '',
fp,
base,
};
case 'Temp':
default:
return {
baseDir: 0,
basePrefix: async () => getTempDir(),
fp,
base,
};
}
};
};
// Resolve an fp from resolvePath to an absolute path.
// When customRootDir is set, fp is already absolute; otherwise join with the base prefix.
async function toAbsolute(resolved: ResolvedPath): Promise<string> {
if (nodePath.isAbsolute(resolved.fp)) return resolved.fp;
const prefix = (await resolved.basePrefix()).replace(/\/+$/, '');
return resolved.fp ? nodePath.join(prefix, resolved.fp) : prefix;
}
export const nodeFileSystem: FileSystem = {
resolvePath: getPathResolver(),
async getPrefix(base: BaseDir) {
return toAbsolute(this.resolvePath('', base));
},
getURL(filePath: string) {
return pathToFileURL(filePath).href;
},
async getBlobURL(filePath: string, base: BaseDir) {
const content = await this.readFile(filePath, base, 'binary');
return `data:application/octet-stream;base64,${Buffer.from(content as ArrayBuffer).toString('base64')}`;
},
async getImageURL(filePath: string) {
return this.getURL(filePath);
},
async openFile(filePath: string, base: BaseDir, name?: string): Promise<File> {
const fullPath = await toAbsolute(this.resolvePath(filePath, base));
const buffer = await fsp.readFile(fullPath);
const fileName = name || nodePath.basename(fullPath);
return new File([buffer], fileName);
},
async copyFile(
srcPath: string,
srcBase: BaseDir,
dstPath: string,
dstBase: BaseDir,
): Promise<void> {
const fullSrc = await toAbsolute(this.resolvePath(srcPath, srcBase));
const fullDst = await toAbsolute(this.resolvePath(dstPath, dstBase));
await fsp.mkdir(nodePath.dirname(fullDst), { recursive: true });
await fsp.copyFile(fullSrc, fullDst);
},
async readFile(
filePath: string,
base: BaseDir,
mode: 'text' | 'binary',
): Promise<string | ArrayBuffer> {
const fullPath = await toAbsolute(this.resolvePath(filePath, base));
if (mode === 'text') {
return await fsp.readFile(fullPath, 'utf-8');
}
const buffer = await fsp.readFile(fullPath);
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
},
async writeFile(
filePath: string,
base: BaseDir,
content: string | ArrayBuffer | File,
): Promise<void> {
const fullPath = await toAbsolute(this.resolvePath(filePath, base));
await fsp.mkdir(nodePath.dirname(fullPath), { recursive: true });
if (typeof content === 'string') {
await fsp.writeFile(fullPath, content, 'utf-8');
} else if (content instanceof File) {
const buffer = Buffer.from(await content.arrayBuffer());
await fsp.writeFile(fullPath, buffer);
} else {
await fsp.writeFile(fullPath, Buffer.from(content));
}
},
async removeFile(filePath: string, base: BaseDir): Promise<void> {
const fullPath = await toAbsolute(this.resolvePath(filePath, base));
await fsp.unlink(fullPath);
},
async createDir(dirPath: string, base: BaseDir, recursive = false): Promise<void> {
const fullPath = await toAbsolute(this.resolvePath(dirPath, base));
await fsp.mkdir(fullPath, { recursive });
},
async removeDir(dirPath: string, base: BaseDir, recursive = false): Promise<void> {
const fullPath = await toAbsolute(this.resolvePath(dirPath, base));
await fsp.rm(fullPath, { recursive, force: recursive });
},
async readDir(dirPath: string, base: BaseDir): Promise<FileItem[]> {
const fullPath = await toAbsolute(this.resolvePath(dirPath, base));
const items: FileItem[] = [];
const walk = async (dir: string, relative: string) => {
let entries: fs.Dirent[];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryRelative = relative ? nodePath.join(relative, entry.name) : entry.name;
const entryFull = nodePath.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(entryFull, entryRelative);
} else if (entry.isFile()) {
const stat = await fsp.stat(entryFull).catch(() => null);
items.push({ path: entryRelative, size: stat?.size ?? 0 });
}
}
};
await walk(fullPath, '');
return items;
},
async exists(filePath: string, base: BaseDir): Promise<boolean> {
const fullPath = await toAbsolute(this.resolvePath(filePath, base));
try {
await fsp.access(fullPath);
return true;
} catch {
return false;
}
},
async stats(filePath: string, base: BaseDir): Promise<FileInfo> {
const fullPath = await toAbsolute(this.resolvePath(filePath, base));
const stat = await fsp.stat(fullPath);
return {
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
size: stat.size,
mtime: stat.mtime,
atime: stat.atime,
birthtime: stat.birthtime,
};
},
};
export class NodeAppService extends BaseAppService {
protected fs = nodeFileSystem;
override appPlatform = 'node' as const;
override osPlatform: OsPlatform =
process.platform === 'darwin'
? 'macos'
: process.platform === 'win32'
? 'windows'
: process.platform === 'linux'
? 'linux'
: 'unknown';
constructor(customRootDir?: string) {
super();
if (customRootDir) {
this.fs.resolvePath = getPathResolver({ customRootDir: nodePath.resolve(customRootDir) });
}
}
protected resolvePath(fp: string, base: BaseDir): ResolvedPath {
return this.fs.resolvePath(fp, base);
}
override async resolveFilePath(path: string, base: BaseDir): Promise<string> {
const prefix = await this.fs.getPrefix(base);
if (!path) return prefix;
return prefix ? nodePath.join(prefix, path) : path;
}
async init(): Promise<void> {
await this.prepareBooksDir();
}
async setCustomRootDir(customRootDir: string): Promise<void> {
this.fs.resolvePath = getPathResolver({ customRootDir: nodePath.resolve(customRootDir) });
await this.prepareBooksDir();
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in Node.js environment');
}
async selectFiles(): Promise<string[]> {
throw new Error('selectFiles is not supported in Node.js environment');
}
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 filepath = options?.filePath ?? '';
await fsp.mkdir(nodePath.dirname(filepath), { recursive: true });
if (typeof content === 'string') {
await fsp.writeFile(filepath, content, 'utf-8');
} else if (content) {
await fsp.writeFile(filepath, Buffer.from(content));
}
return true;
} catch (error) {
console.error('Failed to save file:', error);
return false;
}
}
// No system photo gallery on Node; callers fall back to the saveFile flow.
async saveImageToGallery(): Promise<boolean> {
return false;
}
async ask(): Promise<boolean> {
return false;
}
async openDatabase(
schema: SchemaType,
path: string,
base: BaseDir,
opts?: DatabaseOpts,
): Promise<DatabaseService> {
const fullPath = await this.resolveFilePath(path, base);
const { NodeDatabaseService } = await import('./database/nodeDatabaseService');
const db = await NodeDatabaseService.open(fullPath, opts);
const { migrate } = await import('./database/migrate');
const { getMigrations } = await import('./database/migrations');
await migrate(db, getMigrations(schema));
return db;
}
}