diff --git a/src/bridges/utils.ts b/src/bridges/utils.ts index 9be7c91f..94f471db 100644 --- a/src/bridges/utils.ts +++ b/src/bridges/utils.ts @@ -5,7 +5,7 @@ import { TouchBarTexts, WindowStateListenerType, } from "../schema-types"; -import { CustomArgs } from "../general-types"; +import { type CustomArgs, type AutosaveNotifyResponse } from "../general-types"; import { IObjectWithKey } from "@fluentui/react"; const utilsBridge = { @@ -64,6 +64,27 @@ const utilsBridge = { )) as string; }, + autosaveBackupData: (writeContent: string): Promise => { + return ipcRenderer.invoke("write-autosave", writeContent); + }, + + getLastAutosaveTimestamp: (): Promise => { + return ipcRenderer.invoke("get-last-autosave-timestamp"); + }, + + notifyOfRecoveryNeeded: ( + title: string, + detail: string, + buttons: string[], + ): Promise => { + return ipcRenderer.invoke( + "notify-of-recovery-needed", + title, + detail, + buttons, + ); + }, + getCacheSize: async (): Promise => { return await ipcRenderer.invoke("get-cache"); }, diff --git a/src/general-types.ts b/src/general-types.ts index e02497da..e4b075a7 100644 --- a/src/general-types.ts +++ b/src/general-types.ts @@ -4,3 +4,8 @@ export type CustomArgs = { // Force using the window frame, regardless of platform. forceFrame: boolean; }; + +export type AutosaveNotifyResponse = + | { response: "ignored" } + | { response: "deleted" } + | { response: "recovered"; contents: string }; diff --git a/src/index.tsx b/src/index.tsx index 5175036e..337e73d3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,7 @@ import { initApp, openTextMenu } from "./scripts/models/app"; import { startPool } from "./scripts/fetch-pool"; import { rootStore } from "./scripts/reducer"; import { initThemeUpdateListener } from "./scripts/settings"; +import { handleAutosaves } from "./scripts/autosave"; startPool(); // Start global fetch pool. initThemeUpdateListener(); @@ -20,6 +21,7 @@ applyAnimationMotionPref(); applyThemeSettings(); initializeIcons("icons/"); +handleAutosaves(); rootStore.dispatch(initApp()); window.utils.addMainContextListener((pos, text) => { diff --git a/src/main/pure-utils.ts b/src/main/pure-utils.ts index 4cd81a71..aca20e3d 100644 --- a/src/main/pure-utils.ts +++ b/src/main/pure-utils.ts @@ -1,8 +1,13 @@ +/** + * pure-utils.ts is a collection of utilities that are self-contained, and require + * no inter-package imports. None are stateful. + */ + /** Ensure we don't have too long of a message. */ export function trimContent( s: string, charLen: number = 512, - lineLen: number = 5, + lineLen: number = 10, ): string { return limitLines(trimString(s, charLen), lineLen); } diff --git a/src/main/utils.ts b/src/main/utils.ts index f0878b1b..8628b528 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -7,13 +7,21 @@ import { clipboard, systemPreferences, } from "electron"; +import path from "path"; +import { getFonts } from "font-list"; +import { promises, writeFile } from "fs"; + import pkg from "../../package.json"; import { WindowManager } from "./window"; import { trimContent, trimString } from "./pure-utils"; -import { promises, writeFile } from "fs"; import { ImageCallbackTypes, TouchBarTexts } from "../schema-types"; import { initMainTouchBar } from "./touchbar"; -import { getFonts } from "font-list"; +import { type AutosaveNotifyResponse } from "../general-types"; + +function getAutosavePath() { + const userDataPath = app.getPath("userData"); + return path.join(userDataPath, "autosaves", "ffr_autosave.frdata"); +} export function setUtilsListeners(manager: WindowManager) { async function openExternal(url: string, background = false) { @@ -159,6 +167,84 @@ export function setUtilsListeners(manager: WindowManager) { }, ); + ipcMain.handle("write-autosave", async (_, writeContent: string) => { + const autosavePath = getAutosavePath(); + try { + const dirStat = await promises.stat(path.dirname(autosavePath), { + throwIfNoEntry: false, + }); + if (dirStat == undefined) { + await promises.mkdir(path.dirname(autosavePath)); + } + await promises.writeFile(getAutosavePath(), writeContent); + } catch (err: any) { + console.error("Failed to write autosave", err); + } + }); + + ipcMain.handle( + "get-last-autosave-timestamp", + async (): Promise => { + const autosavePath = getAutosavePath(); + try { + const result = await promises.stat(autosavePath, { + throwIfNoEntry: false, + }); + return result?.mtimeMs; + } catch (err: any) { + console.error(`Failed to stat ${autosavePath}`, err); + return undefined; + } + }, + ); + + ipcMain.handle( + "notify-of-recovery-needed", + async ( + _, + title: string, + detail: string, + buttons: string[], + ): Promise => { + if (manager.hasWindow()) { + const dialogResponse = await dialog.showMessageBox( + manager.mainWindow, + { + type: "error", + title: title, + message: title, + detail: detail, + buttons: buttons, + cancelId: 1, + defaultId: 2, + }, + ); + const autosavePath = getAutosavePath(); + switch (dialogResponse.response) { + case 0: + await promises.rm(autosavePath); + return { response: "deleted" }; + case 1: + return { response: "ignored" }; + case 2: + const contents = await promises.readFile(autosavePath, { + encoding: "utf8", + }); + return { response: "recovered", contents: contents }; + default: + // Unreachable. + throw Error("Unreachable"); + } + } else { + dialog.showErrorBox( + title, + "Unable to recover automatically, but autosave exists.", + ); + return { response: "ignored" }; + } + }, + ); + ipcMain.handle("get-cache", async () => { return await session.defaultSession.getCacheSize(); }); diff --git a/src/scripts/autosave.tsx b/src/scripts/autosave.tsx new file mode 100644 index 00000000..d261d7c1 --- /dev/null +++ b/src/scripts/autosave.tsx @@ -0,0 +1,97 @@ +/** + * Autosaves have somewhat complicated logic, because it would be really bad + * if we autosaved over a good save with a bad save. So, we have to put in + * some very reliable heuristics to figure out if we recently lost some data. + * + * This is the purpose of the `integrity` table in the IndexedDB database. + * Specifically, if we see we had an autosave available, yet the `integrity` + * is empty, it means that we wiped that table. Since that never happens in + * the renderer code paths, it's very likely there was a corruption and it + * was wiped silently by Electron on `IndexedDB.open`. We then can condition + * on that, and recommend recovery. + */ + +import intl from "react-intl-universal"; + +import { fluentDB, type ItemEntry, type SourceEntry } from "./db"; +import { loadFromConfig } from "./settings"; + +const ONE_DAY = 24 * 60 * 60 * 1000; + +export enum AutosaveResponse { + SUCCESS = 0, + SKIPPED = 1, + INTEGRITY_FAILED = 2, +} + +export async function handleAutosaves(): Promise { + const autosaveResp = await renewAutosave(); + if (autosaveResp === AutosaveResponse.INTEGRITY_FAILED) { + // Dangerous area! Everything in the DB is invalid! + const title = intl.get("autosave.corruption.title"); + const detail = intl.get("autosave.corruption.detail"); + const buttons = [ + intl.get("autosave.corruption.delete"), + intl.get("close"), + intl.get("autosave.corruption.recover"), + ]; + const response = await window.utils.notifyOfRecoveryNeeded( + title, + detail, + buttons, + ); + if (response.response === "recovered") { + // Resets integrity table itself. + await loadFromConfig(JSON.parse(response.contents)); + } else if (response.response === "deleted") { + fluentDB.integrity.put({ index: 0, timestamp: new Date() }); + } + } else { + fluentDB.integrity.put({ index: 0, timestamp: new Date() }); + } +} + +/** + * Renew the autosave file in userData if it's been long enough. + */ +async function renewAutosave(): Promise { + const autosaveMTimeMs = await window.utils.getLastAutosaveTimestamp(); + const integrityVals = await fluentDB.integrity.toArray(); + if (autosaveMTimeMs !== undefined && integrityVals.length === 0) { + console.error( + "Prior autosave exists, and current IndexedDB" + + " integrity check failed. Database is corrupt.", + ); + return AutosaveResponse.INTEGRITY_FAILED; + } + + const autosaveStartTime = Date.now(); + if (autosaveStartTime < autosaveMTimeMs + ONE_DAY) { + console.debug("Autosave still current, not rewriting."); + return AutosaveResponse.SKIPPED; + } + let sources: SourceEntry[]; + let items: ItemEntry[]; + const transaction = fluentDB.transaction( + "r", + fluentDB.sources, + fluentDB.items, + async () => { + sources = await fluentDB.sources.toArray(); + items = await fluentDB.items.toArray(); + }, + ); + const output = window.settings.getAll(); + await transaction; + output["database"] = { + sources: sources, + items: items, + }; + await window.utils.autosaveBackupData(JSON.stringify(output)); + const autosaveEndTime = Date.now(); + console.debug( + "Completed autosave in " + + `${Math.round(autosaveEndTime - autosaveStartTime)}ms.`, + ); + return AutosaveResponse.SUCCESS; +} diff --git a/src/scripts/db.ts b/src/scripts/db.ts index a7ff3dcd..11301e78 100644 --- a/src/scripts/db.ts +++ b/src/scripts/db.ts @@ -3,6 +3,13 @@ import { SourceRule } from "./models/rule"; import { Dexie, type EntityTable, type Transaction } from "dexie"; import type { ThumbnailAttributes } from "./thumb-utils"; +// Used to determine if the database has been initialised +// or corrupted. +export interface IntegrityEntry { + index: number; + timestamp: Date; +} + export interface SourceEntry { sid: number; url: string; @@ -37,9 +44,11 @@ export interface ItemEntry { } export const fluentDB = new Dexie("MainDB") as Dexie & { + integrity: EntityTable; sources: EntityTable; items: EntityTable; }; + fluentDB.version(4).stores({ sources: `++sid, &url`, items: `++iid, source, date, serviceRef`, @@ -77,6 +86,13 @@ fluentDB }) .upgrade(migrateOpenTarget); +// Introduce Database Integrity Validation Table. +fluentDB.version(9).stores({ + integrity: `++index`, + sources: `++sid, &url`, + items: `++iid, source, date, serviceRef`, +}); + export async function calculateItemSize(): Promise { await fluentDB.open(); let result = 0; @@ -294,7 +310,11 @@ function byteLength(str: string) { } export async function init() { - await migrateLovefieldSourcesDB("sourcesDB", 3); + try { + await migrateLovefieldSourcesDB("sourcesDB", 3); + } catch (e: any) { + console.error("Error migrating sources DB", e); + } try { await migrateLovefieldItemsDB("itemsDB", 1); } catch (e) { diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index dc1930ca..fc6a121c 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -81,6 +81,14 @@ "Vertical": "Vertical", "font": "Font" }, + "autosave": { + "corruption": { + "title": "Database corruption detected", + "detail": "IndexedDB database appears to be corrupted, and an autosave file has been detected.\n\nRecover from autosave?", + "delete": "Delete autosave", + "recover": "Recover" + } + }, "context": { "share": "Share", "read": "Read", diff --git a/src/scripts/settings.ts b/src/scripts/settings.ts index 7537dabf..8f029e88 100644 --- a/src/scripts/settings.ts +++ b/src/scripts/settings.ts @@ -201,6 +201,7 @@ export interface DatabaseConfig { globalRules: any[]; } +/** Interface to represent the current Fluentflame settings as serialised. */ export interface FluentflameSettingConfig { version: string; @@ -214,6 +215,13 @@ export interface FluentflameSettingConfig { viewConfig: ViewConfig; } +/** Interface to represent the old Fluent Reader settings that are omitted. */ +interface FluentReaderMigrationEntries { + nedb?: any; + useNeDB?: any; + lovefield?: any; +} + export async function exportAll() { const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }]; const write = await window.utils.showSaveDialog( @@ -235,9 +243,11 @@ function isFluentReaderExport(jsonObject: any) { } /** - * Migrate Fluent Reader exports. + * Migrate Fluent Reader exports to Fluentflame Reader. */ -function migrateExport(input: any): FluentflameSettingConfig { +function migrateExport( + input: FluentflameSettingConfig & FluentReaderMigrationEntries, +): FluentflameSettingConfig { console.log("Identified an input from Fluent Reader, trying to migrate..."); return { version: input.version, // This gets updated in update-scripts. @@ -289,6 +299,9 @@ function migrateExport(input: any): FluentflameSettingConfig { }; } +/** + * Ask the user to load a configuration, then load it. + */ export async function importAll(): Promise { const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }]; let data = await window.utils.showOpenDialog(filters); @@ -303,13 +316,25 @@ export async function importAll(): Promise { ); if (!confirmed) return true; let configs = JSON.parse(data); + return loadFromConfig(configs); +} +/** + * Load a configuration. + * + * @jsonObject: A configuration, either from Fluentflame or Fluent Reader. + */ +export async function loadFromConfig( + jsonObject: FluentflameSettingConfig & FluentReaderMigrationEntries, +): Promise { await db.fluentDB.sources.clear(); await db.fluentDB.items.clear(); - const newConfigs: FluentflameSettingConfig = isFluentReaderExport(configs) - ? migrateExport(configs) - : configs; + const newConfigs: FluentflameSettingConfig = isFluentReaderExport( + jsonObject, + ) + ? migrateExport(jsonObject) + : (jsonObject as FluentflameSettingConfig); newConfigs.database.sources.forEach((s) => { s.lastFetched = new Date(s.lastFetched); if (!s.textDir) s.textDir = SourceTextDirection.LTR; @@ -324,7 +349,13 @@ export async function importAll(): Promise { }); await db.fluentDB.items.bulkAdd(newConfigs.database.items); + // Ensure we actually update integrity entry. + const integrityPromise = db.fluentDB.integrity.put({ + index: 0, + timestamp: new Date(), + }); delete newConfigs.database; window.settings.setAll(newConfigs); + await integrityPromise; return false; } diff --git a/tests/main/test_pure-utils.ts b/tests/main/test_pure-utils.ts index b4117cf3..0080ec7b 100644 --- a/tests/main/test_pure-utils.ts +++ b/tests/main/test_pure-utils.ts @@ -6,7 +6,7 @@ describe("dialog trimming", () => { expect(trimContent("M".repeat(1000))).to.equal("M".repeat(511) + "…"); }); it("trims too many lines", () => { - expect(trimContent("M\n".repeat(10))).to.equal( + expect(trimContent("M\n".repeat(15), 512, 5)).to.equal( "M\n".repeat(4) + "…more lines…", ); });