fix: prevent file corruption using a robust backup strategy, closes #2512 (#2528)

This commit is contained in:
Huang Xin
2025-11-24 15:58:42 +08:00
committed by GitHub
parent a7937cd657
commit 72d9698f38
3 changed files with 167 additions and 84 deletions
+145 -81
View File
@@ -99,6 +99,8 @@ export abstract class BaseAppService implements AppService {
canCustomizeRootDir = false;
distChannel = 'readest' as DistChannel;
protected CURRENT_MIGRATION_VERSION = 20251124;
protected abstract fs: FileSystem;
protected abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
@@ -107,6 +109,16 @@ export abstract class BaseAppService implements AppService {
abstract selectDirectory(mode: SelectDirectoryMode): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
if (lastMigrationVersion < 20251124) {
try {
await this.migrate20251124();
} catch (error) {
console.error('Error migrating to version 20251124:', error);
}
}
}
async prepareBooksDir() {
this.localBooksDir = await this.fs.getPrefix('Books');
}
@@ -181,40 +193,39 @@ export abstract class BaseAppService implements AppService {
}
async loadSettings(): Promise<SystemSettings> {
let settings: SystemSettings;
const defaultSettings: SystemSettings = {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.fs.getPrefix('Books'),
koreaderSyncDeviceId: uuidv4(),
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
},
globalViewSettings: this.getDefaultViewSettings(),
} as SystemSettings;
try {
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.version = SYSTEM_SETTINGS_VERSION;
}
settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings };
settings.globalReadSettings = { ...DEFAULT_READSETTINGS, ...settings.globalReadSettings };
settings.globalViewSettings = {
...this.getDefaultViewSettings(),
...settings.globalViewSettings,
};
let settings = await this.safeLoadJSON<SystemSettings>(
SETTINGS_FILENAME,
'Settings',
defaultSettings,
);
settings.localBooksDir = await this.fs.getPrefix('Books');
if (!settings.kosync.deviceId) {
settings.kosync.deviceId = uuidv4();
await this.saveSettings(settings);
}
} catch {
settings = {
...DEFAULT_SYSTEM_SETTINGS,
version: SYSTEM_SETTINGS_VERSION,
localBooksDir: await this.fs.getPrefix('Books'),
koreaderSyncDeviceId: uuidv4(),
globalReadSettings: {
...DEFAULT_READSETTINGS,
...(this.isMobile ? DEFAULT_MOBILE_READSETTINGS : {}),
},
globalViewSettings: this.getDefaultViewSettings(),
} as SystemSettings;
const version = settings.version ?? 0;
if (this.isAppDataSandbox || version < SYSTEM_SETTINGS_VERSION) {
settings.version = SYSTEM_SETTINGS_VERSION;
}
settings = { ...DEFAULT_SYSTEM_SETTINGS, ...settings };
settings.globalReadSettings = { ...DEFAULT_READSETTINGS, ...settings.globalReadSettings };
settings.globalViewSettings = {
...this.getDefaultViewSettings(),
...settings.globalViewSettings,
};
settings.localBooksDir = await this.fs.getPrefix('Books');
if (!settings.kosync.deviceId) {
settings.kosync.deviceId = uuidv4();
await this.saveSettings(settings);
}
@@ -223,7 +234,7 @@ export abstract class BaseAppService implements AppService {
}
async saveSettings(settings: SystemSettings): Promise<void> {
await this.fs.writeFile(SETTINGS_FILENAME, 'Settings', JSON.stringify(settings));
await this.safeSaveJSON(SETTINGS_FILENAME, 'Settings', settings);
}
async importFont(file?: string | File): Promise<CustomFontInfo | null> {
@@ -716,49 +727,15 @@ export abstract class BaseAppService implements AppService {
: this.getCoverImageUrl(book);
}
private async loadJSONFile(
path: string,
base: BaseDir,
): Promise<{ success: boolean; data?: unknown; error?: unknown }> {
try {
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' };
}
try {
const data = JSON.parse(txt as string);
return { success: true, data };
} catch (parseError) {
return { success: false, error: `JSON parse error: ${parseError}` };
}
} catch (error) {
return { success: false, error };
}
}
async loadLibraryBooks(): Promise<Book[]> {
console.log('Loading library books...');
let books: Book[] = [];
const libraryFilename = getLibraryFilename();
const backupFilename = getLibraryBackupFilename();
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, 'Books');
if (backupResult.success) {
books = backupResult.data as Book[];
console.warn('Loaded library from backup file:', backupFilename);
} else {
await this.fs.writeFile(libraryFilename, 'Books', '[]');
await this.fs.writeFile(backupFilename, 'Books', '[]');
}
}
const books = await this.safeLoadJSON<Book[]>(libraryFilename, 'Books', []);
await Promise.all(
books.map(async (book) => {
@@ -773,19 +750,7 @@ export abstract class BaseAppService implements AppService {
async saveLibraryBooks(books: Book[]): Promise<void> {
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
const jsonData = JSON.stringify(libraryBooks, null, 2);
const libraryFilename = getLibraryFilename();
const backupFilename = getLibraryBackupFilename();
const saveResults = await Promise.allSettled([
this.fs.writeFile(backupFilename, 'Books', jsonData),
this.fs.writeFile(libraryFilename, 'Books', jsonData),
]);
const backupSuccess = saveResults[0].status === 'fulfilled';
const mainSuccess = saveResults[1].status === 'fulfilled';
if (!backupSuccess || !mainSuccess) {
throw new Error('Failed to save library books');
}
await this.safeSaveJSON(getLibraryFilename(), 'Books', libraryBooks);
}
private imageToArrayBuffer(imageUrl?: string, imageFile?: string): Promise<ArrayBuffer> {
@@ -824,4 +789,103 @@ export abstract class BaseAppService implements AppService {
await this.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer);
}
}
private async loadJSONFile(
path: string,
base: BaseDir,
): Promise<{ success: boolean; data?: unknown; error?: unknown }> {
try {
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' };
}
try {
const data = JSON.parse(txt as string);
return { success: true, data };
} catch (parseError) {
return { success: false, error: `JSON parse error: ${parseError}` };
}
} catch (error) {
return { success: false, error };
}
}
/**
* Safely loads a JSON file with automatic backup fallback.
* If the main file is corrupted, attempts to load from backup.
* @param filename - The name of the file to load (without .bak extension)
* @param base - The base directory
* @param defaultValue - Default value to return if both files fail
*/
private async safeLoadJSON<T>(filename: string, base: BaseDir, defaultValue: T): Promise<T> {
const backupFilename = `${filename}.bak`;
// Try loading main file
const mainResult = await this.loadJSONFile(filename, base);
if (mainResult.success) {
return mainResult.data as T;
}
console.warn(`Failed to load ${filename}, attempting backup...`, mainResult.error);
// Try loading backup file
const backupResult = await this.loadJSONFile(backupFilename, base);
if (backupResult.success) {
console.warn(`Loaded from backup: ${backupFilename}`);
// Restore the main file from backup
try {
const backupData = JSON.stringify(backupResult.data, null, 2);
await this.fs.writeFile(filename, base, backupData);
console.log(`Restored ${filename} from backup`);
} catch (error) {
console.error(`Failed to restore ${filename} from backup:`, error);
}
return backupResult.data as T;
}
console.error(`Both ${filename} and ${backupFilename} failed to load`);
return defaultValue;
}
/**
* Safely saves a JSON file with atomic write using backup strategy.
* Strategy: write to backup first, then to main file.
* This ensures at least one valid copy exists at all times.
* @param filename - The name of the file to save (without .bak extension)
* @param base - The base directory
* @param data - The data to save
*/
private async safeSaveJSON(filename: string, base: BaseDir, data: unknown): Promise<void> {
const backupFilename = `${filename}.bak`;
const jsonData = JSON.stringify(data, null, 2);
// Strategy: Always write to backup first, then to main file
// This ensures we always have at least one valid copy
try {
// Step 1: Write to backup file
await this.fs.writeFile(backupFilename, base, jsonData);
// Step 2: Write to main file
await this.fs.writeFile(filename, base, jsonData);
} catch (error) {
console.error(`Failed to save ${filename}:`, error);
throw new Error(`Failed to save ${filename}: ${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);
}
}
}
}
@@ -401,13 +401,13 @@ export class NativeAppService extends BaseAppService {
await this.runMigrations();
}
private CURRENT_MIGRATION_VERSION = 20251029;
private async runMigrations() {
override async runMigrations() {
try {
const settings = await this.loadSettings();
const lastMigrationVersion = settings.migrationVersion || 0;
await super.runMigrations(lastMigrationVersion);
if (lastMigrationVersion < 20251029) {
try {
await this.migrate20251029();
@@ -226,6 +226,25 @@ export class WebAppService extends BaseAppService {
override async init() {
await this.loadSettings();
await this.prepareBooksDir();
await this.runMigrations();
}
override async runMigrations() {
try {
const settings = await this.loadSettings();
const lastMigrationVersion = settings.migrationVersion || 0;
await super.runMigrations(lastMigrationVersion);
if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) {
await this.saveSettings({
...settings,
migrationVersion: this.CURRENT_MIGRATION_VERSION,
});
}
} catch (error) {
console.error('Failed to run migrations:', error);
}
}
override resolvePath(fp: string, base: BaseDir): ResolvedPath {