refactor: support custom root dir for readest file system (#2092)

This commit is contained in:
Huang Xin
2025-09-21 13:59:27 +08:00
committed by GitHub
parent 855f98722c
commit c9a1557674
16 changed files with 360 additions and 209 deletions
+31 -16
View File
@@ -42,6 +42,22 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
let fs_scope = app.fs_scope();
let asset_protocol_scope = app.asset_protocol_scope();
if let Err(e) = fs_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in fs_scope: {e}");
} else {
println!("Allowed directory in fs_scope: {dir:?}");
}
if let Err(e) = asset_protocol_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in asset_protocol_scope: {e}");
} else {
println!("Allowed directory in asset_protocol_scope: {dir:?}");
}
}
#[cfg(desktop)]
fn get_files_from_argv(argv: Vec<String>) -> Vec<PathBuf> {
let mut files = Vec::new();
@@ -87,15 +103,6 @@ fn set_window_open_with_files(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn set_rounded_window(app: &AppHandle, rounded: bool) {
let window = app.get_webview_window("main").unwrap();
let script = format!("window.IS_ROUNDED = {rounded};");
if let Err(e) = window.eval(&script) {
eprintln!("Failed to set IS_ROUNDED variable: {e}");
}
}
#[command]
async fn start_server(window: Window) -> Result<u16, String> {
start(move |url| {
@@ -111,6 +118,15 @@ fn get_environment_variable(name: &str) -> String {
std::env::var(String::from(name)).unwrap_or(String::from(""))
}
#[tauri::command]
fn get_executable_dir() -> String {
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(|p| p.to_path_buf()))
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
@@ -128,6 +144,7 @@ pub fn run() {
download_file,
upload_file,
get_environment_variable,
get_executable_dir,
#[cfg(target_os = "macos")]
macos::safari_auth::auth_with_safari,
#[cfg(target_os = "macos")]
@@ -193,6 +210,11 @@ pub fn run() {
}
}
#[cfg(desktop)]
{
allow_dir_in_scopes(app.handle(), &PathBuf::from(get_executable_dir()));
}
#[cfg(desktop)]
{
app.handle().plugin(tauri_plugin_cli::init())?;
@@ -204,13 +226,6 @@ pub fn run() {
.eval("window.__READEST_CLI_ACCESS = true;")
.expect("Failed to set cli access config");
set_rounded_window(&app_handle, true);
#[cfg(target_os = "windows")]
if tauri_plugin_os::version()
<= tauri_plugin_os::Version::from_string("10.0.19045")
{
set_rounded_window(&app_handle, false);
}
#[cfg(target_os = "linux")]
{
let is_appimage = std::env::var("APPIMAGE").is_ok()
@@ -50,6 +50,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
<div
className={clsx(
'sectioninfo absolute flex items-center overflow-hidden',
'text-neutral-content font-sans text-xs font-light',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0 h-[44px]',
isScrolled && !isVertical && 'bg-base-100',
)}
@@ -75,7 +76,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
<span
aria-hidden='true'
className={clsx(
'text-neutral-content text-center font-sans text-xs font-light',
'text-center',
isVertical ? '' : 'line-clamp-1',
!isVertical && hoveredBookKey == bookKey && 'hidden',
)}
@@ -8,8 +8,6 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useCustomFontStore } from '@/store/customFontStore';
import { useFileSelector } from '@/hooks/useFileSelector';
import { CustomFont, mountCustomFont } from '@/styles/fonts';
import { parseFontInfo } from '@/utils/font';
import { getFilename } from '@/utils/path';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
interface CustomFontsProps {
@@ -48,28 +46,11 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
const handleImportFont = () => {
selectFiles({ type: 'fonts', multiple: true }).then(async (result) => {
if (result.error || result.files.length === 0) return;
if (!(await appService!.fs.exists('', 'Fonts'))) {
await appService!.fs.createDir('', 'Fonts');
}
for (const selectedFile of result.files) {
let fontPath: string;
let fontFile: File;
if (selectedFile.path) {
const filePath = selectedFile.path;
const fileobj = await appService!.fs.openFile(filePath, 'None');
fontPath = fileobj.name || getFilename(filePath);
await appService!.fs.copyFile(filePath, fontPath, 'Fonts');
fontFile = await appService!.fs.openFile(fontPath, 'Fonts');
} else if (selectedFile.file) {
const file = selectedFile.file;
fontPath = getFilename(file.name);
await appService!.fs.writeFile(fontPath, 'Fonts', file);
fontFile = file;
} else {
continue;
}
const fontInfo = parseFontInfo(await fontFile.arrayBuffer(), fontPath);
const customFont = addFont(fontPath, {
const fontInfo = await appService?.importFont(selectedFile.path || selectedFile.file);
if (!fontInfo) continue;
const customFont = addFont(fontInfo.path, {
name: fontInfo.name,
family: fontInfo.family,
style: fontInfo.style,
@@ -90,7 +71,7 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
for (const font of family.fonts) {
if (font) {
if (removeFont(font.id)) {
appService!.fs.removeFile(font.path, 'Fonts');
appService?.deleteFont(font);
saveCustomFonts(envConfig);
if (getAvailableFonts().length === 0) {
setIsDeleteMode(false);
@@ -167,7 +167,7 @@ const FontPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
});
getAllFonts().forEach((font) => {
if (removeFont(font.id)) {
appService!.fs.removeFile(font.path, 'Fonts');
appService!.deleteFont(font);
}
});
saveCustomFonts(envConfig);
@@ -115,8 +115,10 @@ 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 = await appService.fs.getPrefix('Cache');
const apkFilePath = `${cachePrefix}/Readest_${data.version}_${arch}.apk`;
const apkFilePath = await appService.resolveFilePath(
`Readest_${data.version}_${arch}.apk`,
'Cache',
);
setUpdate({
currentVersion,
version: data.version,
@@ -157,10 +157,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
if (selectedFile.path && appService) {
const filePath = selectedFile.path;
metadata.coverImageFile = filePath;
const tempName = `cover-${Date.now()}.png`;
const cachePrefix = await appService.fs.getPrefix('Cache');
await appService.fs.copyFile(filePath, tempName, 'Cache');
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
metadata.coverImageUrl = await appService.getCachedImageUrl(filePath);
setNewCoverImageUrl(metadata.coverImageUrl!);
} else if (selectedFile.file) {
metadata.coverImageBlobUrl = URL.createObjectURL(selectedFile.file);
+86 -31
View File
@@ -1,6 +1,6 @@
import { v4 as uuidv4 } from 'uuid';
import { SystemSettings } from '@/types/settings';
import { AppPlatform, AppService, OsPlatform } from '@/types/system';
import { AppPlatform, AppService, OsPlatform, ResolvedPath } from '@/types/system';
import { FileSystem, BaseDir, DeleteAction } from '@/types/system';
import {
Book,
@@ -23,7 +23,7 @@ import {
getPrimaryLanguage,
getLibraryBackupFilename,
} from '@/utils/book';
import { partialMD5 } from '@/utils/md5';
import { md5, partialMD5 } from '@/utils/md5';
import { getBaseFilename, getFilename } from '@/utils/path';
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
import {
@@ -43,6 +43,7 @@ import {
DEFAULT_SCREEN_CONFIG,
DEFAULT_TRANSLATOR_CONFIG,
DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS,
SETTINGS_FILENAME,
} from './constants';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { getOSPlatform, getTargetLang, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
@@ -52,13 +53,8 @@ import { ClosableFile } from '@/utils/file';
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;
};
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import { parseFontInfo } from '@/utils/font';
export abstract class BaseAppService implements AppService {
osPlatform: OsPlatform = getOSPlatform();
@@ -71,6 +67,7 @@ export abstract class BaseAppService implements AppService {
isAndroidApp = false;
isIOSApp = false;
isMobileApp = false;
isPortableApp = false;
hasTrafficLight = false;
hasWindow = false;
hasWindowBar = false;
@@ -80,16 +77,49 @@ export abstract class BaseAppService implements AppService {
hasHaptics = false;
hasUpdater = false;
hasOrientationLock = false;
canCustomRootDir = false;
distChannel = 'readest';
abstract fs: FileSystem;
protected abstract fs: FileSystem;
protected abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
abstract getCoverImageUrl(book: Book): string;
abstract getCoverImageBlobUrl(book: Book): Promise<string>;
abstract selectDirectory(): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
async init() {
this.localBooksDir = await this.fs.getPrefix('Books');
}
async openFile(path: string, base: BaseDir): Promise<File> {
return await this.fs.openFile(path, base);
}
async resolveFilePath(path: string, base: BaseDir): Promise<string> {
const prefix = await this.fs.getPrefix(base);
return `${prefix}/${path}`;
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
};
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None');
};
async getCachedImageUrl(pathOrUrl: string): Promise<string> {
const cachedKey = `img_${md5(pathOrUrl)}`;
const cachePrefix = await this.fs.getPrefix('Cache');
const cachedPath = `${cachePrefix}/${cachedKey}`;
if (await this.fs.exists(cachedPath, 'None')) {
return this.fs.getURL(cachedPath);
} else {
const file = await this.fs.openFile(pathOrUrl, 'None');
await this.fs.writeFile(cachedKey, 'Cache', await file.arrayBuffer());
return this.fs.getURL(cachedPath);
}
}
getDefaultViewSettings(): ViewSettings {
return {
...DEFAULT_BOOK_LAYOUT,
@@ -106,15 +136,13 @@ export abstract class BaseAppService implements AppService {
async loadSettings(): Promise<SystemSettings> {
let settings: SystemSettings;
const { fp, base } = this.resolvePath('settings.json', 'Settings');
try {
await this.fs.exists(fp, base);
const txt = await this.fs.readFile(fp, base, 'text');
await this.fs.exists(SETTINGS_FILENAME, 'Settings');
const txt = await this.fs.readFile(SETTINGS_FILENAME, 'Settings', 'text');
settings = JSON.parse(txt as string);
const version = settings.version ?? 0;
if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) {
settings.localBooksDir = await this.fs.getPrefix('Books');
settings.version = SYSTEM_SETTINGS_VERSION;
}
settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings };
@@ -124,9 +152,10 @@ export abstract class BaseAppService implements AppService {
...settings.globalViewSettings,
};
settings.localBooksDir = await this.fs.getPrefix('Books');
if (!settings.kosync.deviceId) {
settings.kosync.deviceId = uuidv4();
await this.fs.writeFile(fp, base, JSON.stringify(settings));
await this.saveSettings(settings);
}
} catch {
settings = {
@@ -140,10 +169,7 @@ export abstract class BaseAppService implements AppService {
},
globalViewSettings: this.getDefaultViewSettings(),
} as SystemSettings;
await this.fs.createDir('', 'Books', true);
await this.fs.createDir('', base, true);
await this.fs.writeFile(fp, base, JSON.stringify(settings));
await this.saveSettings(settings);
}
this.localBooksDir = settings.localBooksDir;
@@ -151,9 +177,34 @@ export abstract class BaseAppService implements AppService {
}
async saveSettings(settings: SystemSettings): Promise<void> {
const { fp, base } = this.resolvePath('settings.json', 'Settings');
await this.fs.createDir('', base, true);
await this.fs.writeFile(fp, base, JSON.stringify(settings));
await this.fs.writeFile(SETTINGS_FILENAME, 'Settings', JSON.stringify(settings));
}
async importFont(file?: string | File): Promise<CustomFontInfo | null> {
let fontPath: string;
let fontFile: File;
if (typeof file === 'string') {
const filePath = file;
const fileobj = await this.fs.openFile(filePath, 'None');
fontPath = fileobj.name || getFilename(filePath);
await this.fs.copyFile(filePath, fontPath, 'Fonts');
fontFile = await this.fs.openFile(fontPath, 'Fonts');
} else if (file) {
fontPath = getFilename(file.name);
await this.fs.writeFile(fontPath, 'Fonts', file);
fontFile = file;
} else {
return null;
}
return {
path: fontPath,
...parseFontInfo(await fontFile.arrayBuffer(), fontPath),
};
}
async deleteFont(font: CustomFont): Promise<void> {
await this.fs.removeFile(font.path, 'Fonts');
}
async importBook(
@@ -445,7 +496,7 @@ export abstract class BaseAppService implements AppService {
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
const localFullpath = `${this.localBooksDir}/${lfp}`;
bookDownloaded = await this.fs.exists(localFullpath, 'Books');
bookDownloaded = await this.fs.exists(localFullpath, 'None');
completedFiles.count++;
}
// some books may not have cover image, so we need to check if the book is downloaded
@@ -563,10 +614,11 @@ export abstract class BaseAppService implements AppService {
}
private async loadJSONFile(
filename: string,
path: string,
base: BaseDir,
): Promise<{ success: boolean; data?: unknown; error?: unknown }> {
try {
const txt = await this.fs.readFile(filename, 'Books', 'text');
const txt = await this.fs.readFile(path, base, 'text');
if (!txt || typeof txt !== 'string' || txt.trim().length === 0) {
return { success: false, error: 'File is empty or invalid' };
}
@@ -587,16 +639,19 @@ export abstract class BaseAppService implements AppService {
const libraryFilename = getLibraryFilename();
const backupFilename = getLibraryBackupFilename();
const mainResult = await this.loadJSONFile(libraryFilename);
if (!(await this.fs.exists('', 'Books'))) {
await this.fs.createDir('', 'Books', true);
}
const mainResult = await this.loadJSONFile(libraryFilename, 'Books');
if (mainResult.success) {
books = mainResult.data as Book[];
} else {
const backupResult = await this.loadJSONFile(backupFilename);
const backupResult = await this.loadJSONFile(backupFilename, 'Books');
if (backupResult.success) {
books = backupResult.data as Book[];
console.warn('Loaded library from backup file:', backupFilename);
} else {
await this.fs.createDir('', 'Books', true);
await this.fs.writeFile(libraryFilename, 'Books', '[]');
await this.fs.writeFile(backupFilename, 'Books', '[]');
}
@@ -18,6 +18,9 @@ import { stubTranslation as _ } from '@/utils/misc';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
export const LOCAL_FONTS_SUBDIR = 'Readest/Fonts';
export const LOCAL_DATA_SUBDIR = 'Readest/Data';
export const SETTINGS_FILENAME = 'settings.json';
export const SUPPORTED_BOOK_EXTS = [
'epub',
+2 -2
View File
@@ -34,7 +34,7 @@ const getNativeAppService = async () => {
if (!nativeAppService) {
const { NativeAppService } = await import('@/services/nativeAppService');
nativeAppService = new NativeAppService();
await nativeAppService.loadSettings();
await nativeAppService.init();
}
return nativeAppService;
};
@@ -44,7 +44,7 @@ const getWebAppService = async () => {
if (!webAppService) {
const { WebAppService } = await import('@/services/webAppService');
webAppService = new WebAppService();
await webAppService.loadSettings();
await webAppService.init();
}
return webAppService;
};
+171 -101
View File
@@ -1,7 +1,6 @@
import {
exists,
mkdir,
open as openFile,
readTextFile,
readFile,
writeTextFile,
@@ -12,7 +11,7 @@ import {
BaseDirectory,
WriteFileOptions,
} from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core';
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import {
join,
@@ -25,76 +24,136 @@ import {
} 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 { FileSystem, BaseDir, AppPlatform, ResolvedPath } from '@/types/system';
import { getOSPlatform, isContentURI, isValidURL } from '@/utils/misc';
import { getCoverFilename } from '@/utils/book';
import { getFilename } from '@/utils/path';
import { copyURIToPath } from '@/utils/bridge';
import { getDirPath, getFilename } from '@/utils/path';
import { NativeFile, RemoteFile } from '@/utils/file';
import { copyURIToPath } from '@/utils/bridge';
import { BaseAppService, ResolvedPath } from './appService';
import { LOCAL_BOOKS_SUBDIR, LOCAL_FONTS_SUBDIR } from './constants';
import { BaseAppService } from './appService';
import {
LOCAL_BOOKS_SUBDIR,
LOCAL_DATA_SUBDIR,
LOCAL_FONTS_SUBDIR,
SETTINGS_FILENAME,
} from './constants';
declare global {
interface Window {
IS_ROUNDED?: boolean;
__READEST_UPDATER_DISABLED?: boolean;
}
}
const OS_TYPE = osType();
// 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, basePrefix: appConfigDir, fp: path, base };
case 'Data':
return { baseDir: BaseDirectory.AppData, basePrefix: appDataDir, fp: path, base };
case 'Cache':
return { baseDir: BaseDirectory.AppCache, basePrefix: appCacheDir, fp: path, base };
case 'Log':
return { baseDir: BaseDirectory.AppLog, basePrefix: appLogDir, fp: path, base };
case 'Books':
return {
baseDir: BaseDirectory.AppData,
basePrefix: appDataDir,
fp: `${LOCAL_BOOKS_SUBDIR}/${path}`,
base,
};
case 'Fonts':
return {
baseDir: BaseDirectory.AppData,
basePrefix: appDataDir,
fp: `${LOCAL_FONTS_SUBDIR}/${path}`,
base,
};
case 'None':
return {
baseDir: 0,
basePrefix: async () => '',
fp: path,
base,
};
case 'Temp':
default:
return {
baseDir: BaseDirectory.Temp,
basePrefix: tempDir,
fp: path,
base,
};
}
// 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 () =>
`${customRootDir}/${['Settings', 'Data', 'Books', 'Fonts'].includes(baseDir) ? '' : baseDir}`;
}
: 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,
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,
base,
};
case 'Data':
return {
baseDir: customBaseDir ?? BaseDirectory.AppData,
basePrefix: customBasePrefix ?? appDataDir,
fp: customBasePrefixSync
? `${customBasePrefixSync()}/${LOCAL_DATA_SUBDIR}/${path}`
: `${LOCAL_DATA_SUBDIR}/${path}`,
base,
};
case 'Books':
return {
baseDir: customBaseDir ?? BaseDirectory.AppData,
basePrefix: customBasePrefix || appDataDir,
fp: customBasePrefixSync
? `${customBasePrefixSync()}/${LOCAL_BOOKS_SUBDIR}/${path}`
: `${LOCAL_BOOKS_SUBDIR}/${path}`,
base,
};
case 'Fonts':
return {
baseDir: customBaseDir ?? BaseDirectory.AppData,
basePrefix: customBasePrefix || appDataDir,
fp: customBasePrefixSync
? `${customBasePrefixSync()}/${LOCAL_FONTS_SUBDIR}/${path}`
: `${LOCAL_FONTS_SUBDIR}/${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 } = resolvePath('', base);
const { basePrefix, fp, baseDir } = this.resolvePath('', base);
const basePath = await basePrefix();
return fp ? await join(basePath, fp) : basePath;
return fp ? (baseDir === 0 ? fp : await join(basePath, fp)) : basePath;
},
getURL(path: string) {
return isValidURL(path) ? path : convertFileSrc(path);
@@ -104,7 +163,7 @@ export const nativeFileSystem: FileSystem = {
return URL.createObjectURL(new Blob([content]));
},
async openFile(path: string, base: BaseDir, name?: string) {
const { fp, baseDir } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
let fname = name || getFilename(fp);
if (isValidURL(path)) {
return await new RemoteFile(path, fname).open();
@@ -113,7 +172,7 @@ export const nativeFileSystem: FileSystem = {
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, base ? baseDir : null).open();
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
} 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.
@@ -124,7 +183,7 @@ export const nativeFileSystem: FileSystem = {
console.error('Failed to open file:', res);
throw new Error('Failed to open file');
}
return await new NativeFile(dst, fname, base ? baseDir : null).open();
return await new NativeFile(dst, fname, baseDir ? baseDir : null).open();
}
} else {
const prefix = await this.getPrefix(base);
@@ -136,7 +195,7 @@ export const nativeFileSystem: FileSystem = {
// RemoteFile is not usable on Android due to unknown issues of range fetch with Android WebView.
return await new RemoteFile(this.getURL(absolutePath), fname).open();
} else {
return await new NativeFile(fp, fname, base ? baseDir : null).open();
return await new NativeFile(fp, fname, baseDir ? baseDir : null).open();
}
}
},
@@ -155,63 +214,57 @@ export const nativeFileSystem: FileSystem = {
throw new Error('Failed to copy file');
}
} else {
const { fp, baseDir } = resolvePath(dstPath, base);
await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir });
const { fp, baseDir } = this.resolvePath(dstPath, base);
await copyFile(srcPath, fp, baseDir ? { toPathBaseDir: baseDir } : undefined);
}
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp, baseDir } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
return mode === 'text'
? (readTextFile(fp, base && { baseDir }) as Promise<string>)
: ((await readFile(fp, base && { baseDir })).buffer as ArrayBuffer);
? (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 } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
if (!(await this.exists(getDirPath(path), base))) {
await this.createDir(getDirPath(path), base, true);
}
if (typeof content === 'string') {
return writeTextFile(fp, content, base && { baseDir });
return writeTextFile(fp, content, baseDir ? { baseDir } : undefined);
} else if (content instanceof File) {
const writeOptions = { write: true, create: true, baseDir } as WriteFileOptions;
// TODO: use writeFile directly when @tauri-apps/plugin-fs@2.2.1 is released
// return writeFile(fp, content.stream(), base && writeOptions);
const file = await openFile(fp, base && writeOptions);
const reader = content.stream().getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await file.write(value);
}
} finally {
reader.releaseLock();
await file.close();
}
const writeOptions = {
write: true,
create: true,
baseDir: baseDir ? baseDir : undefined,
} as WriteFileOptions;
return await writeFile(fp, content.stream(), writeOptions);
} else {
return writeFile(fp, new Uint8Array(content), base && { baseDir });
return await writeFile(fp, new Uint8Array(content), baseDir ? { baseDir } : undefined);
}
},
async removeFile(path: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
return remove(fp, base && { baseDir });
return remove(fp, baseDir ? { baseDir } : undefined);
},
async createDir(path: string, base: BaseDir, recursive = false) {
const { fp, baseDir } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
await mkdir(fp, base && { baseDir, recursive });
await mkdir(fp, { baseDir: baseDir ? baseDir : undefined, recursive });
},
async removeDir(path: string, base: BaseDir, recursive = false) {
const { fp, baseDir } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
await remove(fp, base && { baseDir, recursive });
await remove(fp, { baseDir: baseDir ? baseDir : undefined, recursive });
},
async readDir(path: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
const list = await readDir(fp, base && { baseDir });
const list = await readDir(fp, baseDir ? { baseDir } : undefined);
return list.map((entity) => {
return {
path: entity.name,
@@ -220,10 +273,10 @@ export const nativeFileSystem: FileSystem = {
});
},
async exists(path: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(path, base);
const { fp, baseDir } = this.resolvePath(path, base);
try {
const res = await exists(fp, base && { baseDir });
const res = await exists(fp, baseDir ? { baseDir } : undefined);
return res;
} catch {
return false;
@@ -255,10 +308,35 @@ export class NativeAppService extends BaseAppService {
// orientation lock is not supported on iPad
override hasOrientationLock =
(OS_TYPE === 'ios' && getOSPlatform() === 'ios') || OS_TYPE === 'android';
override canCustomRootDir = OS_TYPE !== 'ios';
override distChannel = process.env['NEXT_PUBLIC_DIST_CHANNEL'] || 'readest';
override async init() {
const execDir = await invoke<string>('get_executable_dir');
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 (settings.customRootDir) {
this.fs.resolvePath = getPathResolver({
customRootDir: settings.customRootDir,
isPortable: this.isPortableApp,
execDir,
});
}
await super.init();
}
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
return resolvePath(fp, base);
return this.fs.resolvePath(fp, base);
}
async selectDirectory(): Promise<string> {
@@ -276,12 +354,4 @@ export class NativeAppService extends BaseAppService {
});
return Array.isArray(selected) ? selected : selected ? [selected] : [];
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
};
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None');
};
}
+24 -25
View File
@@ -1,16 +1,16 @@
import { Book } from '@/types/book';
import { FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { FileSystem, BaseDir, AppPlatform, ResolvedPath } from '@/types/system';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { RemoteFile } from '@/utils/file';
import { isPWA } from './environment';
import { BaseAppService, ResolvedPath } from './appService';
import { LOCAL_BOOKS_SUBDIR, LOCAL_FONTS_SUBDIR } from './constants';
import { BaseAppService } from './appService';
import { LOCAL_BOOKS_SUBDIR, LOCAL_DATA_SUBDIR, LOCAL_FONTS_SUBDIR } from './constants';
const basePrefix = async () => '';
const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
switch (base) {
case 'Data':
return { baseDir: 0, basePrefix, fp: `${LOCAL_DATA_SUBDIR}/${path}`, base };
case 'Books':
return { baseDir: 0, basePrefix, fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base };
case 'Fonts':
@@ -42,6 +42,13 @@ async function openIndexedDB(): Promise<IDBDatabase> {
}
const indexedDBFileSystem: FileSystem = {
resolvePath,
async getPrefix(base: BaseDir) {
const { basePrefix, fp } = this.resolvePath('', base);
const basePath = await basePrefix();
const prefix = fp ? (basePath ? `${basePath}/${fp}` : fp) : basePath;
return prefix.replace(/\/+$/, '');
},
getURL(path: string) {
if (isValidURL(path)) {
return path;
@@ -66,7 +73,7 @@ const indexedDBFileSystem: FileSystem = {
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp } = resolvePath(dstPath, base);
const { fp } = this.resolvePath(dstPath, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
@@ -88,7 +95,7 @@ const indexedDBFileSystem: FileSystem = {
});
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp } = resolvePath(path, base);
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<string | ArrayBuffer>((resolve, reject) => {
@@ -121,7 +128,7 @@ const indexedDBFileSystem: FileSystem = {
});
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) {
const { fp } = resolvePath(path, base);
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
if (content instanceof File) {
@@ -138,7 +145,7 @@ const indexedDBFileSystem: FileSystem = {
});
},
async removeFile(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
@@ -158,7 +165,7 @@ const indexedDBFileSystem: FileSystem = {
// Directories are virtual in IndexedDB; no-op
},
async readDir(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<{ path: string; isDir: boolean }[]>((resolve, reject) => {
@@ -179,7 +186,7 @@ const indexedDBFileSystem: FileSystem = {
});
},
async exists(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<boolean>((resolve, reject) => {
@@ -191,11 +198,6 @@ const indexedDBFileSystem: FileSystem = {
request.onerror = () => reject(request.error);
});
},
async getPrefix(base: BaseDir) {
const { basePrefix, fp } = resolvePath('', base);
const basePath = await basePrefix();
return fp ? `${basePath}/${fp}` : basePath;
},
};
export class WebAppService extends BaseAppService {
@@ -204,8 +206,13 @@ export class WebAppService extends BaseAppService {
override appPlatform = 'web' as AppPlatform;
override hasSafeAreaInset = isPWA();
override async init() {
await this.loadSettings();
await super.init();
}
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
return resolvePath(fp, base);
return this.fs.resolvePath(fp, base);
}
async selectDirectory(): Promise<string> {
@@ -215,12 +222,4 @@ export class WebAppService extends BaseAppService {
async selectFiles(): Promise<string[]> {
throw new Error('selectFiles is not supported in browser');
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`);
};
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
return this.fs.getBlobURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`, 'None');
};
}
@@ -152,7 +152,7 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
});
const appService = await envConfig.getAppService();
const fontFile = await appService.fs.openFile(font.path, 'Fonts');
const fontFile = await appService.openFile(font.path, 'Fonts');
const format = getFontFormat(font.path);
const mimeType = getMimeType(format);
+3
View File
@@ -134,6 +134,9 @@ export interface CustomFont {
error?: string;
}
export type CustomFontInfo = Partial<CustomFont> &
Required<Pick<CustomFont, 'path' | 'name' | 'family' | 'style' | 'weight' | 'variable'>>;
export function getFontName(path: string): string {
const fileName = getFilename(path);
return fileName.replace(/\.(ttf|otf|woff|woff2)$/i, '');
+1
View File
@@ -38,6 +38,7 @@ export interface KOSyncSettings {
export interface SystemSettings {
version: number;
localBooksDir: string;
customRootDir?: string;
keepLogin: boolean;
autoUpload: boolean;
+17 -1
View File
@@ -2,13 +2,22 @@ import { SystemSettings } from './settings';
import { Book, BookConfig, BookContent, ViewSettings } from './book';
import { BookMetadata } from '@/libs/document';
import { ProgressHandler } from '@/utils/transfer';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
export type AppPlatform = 'web' | 'tauri';
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Fonts' | 'Log' | 'Cache' | 'Temp' | 'None';
export type DeleteAction = 'cloud' | 'local' | 'both';
export type ResolvedPath = {
baseDir: number;
basePrefix: () => Promise<string>;
fp: string;
base: BaseDir;
};
export interface FileSystem {
resolvePath(path: string, base: BaseDir): ResolvedPath;
getURL(path: string): string;
getBlobURL(path: string, base: BaseDir): Promise<string>;
openFile(path: string, base: BaseDir, filename?: string): Promise<File>;
@@ -24,7 +33,6 @@ export interface FileSystem {
}
export interface AppService {
fs: FileSystem;
osPlatform: OsPlatform;
appPlatform: AppPlatform;
hasTrafficLight: boolean;
@@ -43,13 +51,21 @@ export interface AppService {
isIOSApp: boolean;
isMacOSApp: boolean;
isLinuxApp: boolean;
isPortableApp: boolean;
distChannel: string;
init(): Promise<void>;
openFile(path: string, base: BaseDir): Promise<File>;
resolveFilePath(path: string, base: BaseDir): Promise<string>;
getCachedImageUrl(pathOrUrl: string): Promise<string>;
selectDirectory(): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
getDefaultViewSettings(): ViewSettings;
loadSettings(): Promise<SystemSettings>;
saveSettings(settings: SystemSettings): Promise<void>;
importFont(file?: string | File): Promise<CustomFontInfo | null>;
deleteFont(font: CustomFont): Promise<void>;
importBook(
file: string | File,
books: Book[],
+8
View File
@@ -9,8 +9,16 @@ export const getFilename = (fileOrUri: string) => {
const lastPart = parts.pop()!;
return lastPart.split('?')[0]!;
};
export const getBaseFilename = (filename: string) => {
const normalizedPath = filename.replace(/\\/g, '/');
const baseName = normalizedPath.split('/').pop()?.split('.').slice(0, -1).join('.') || '';
return baseName;
};
export const getDirPath = (filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
parts.pop();
return parts.join('/');
};