import { fireAndForget } from "octagonal-wheels/promises"; import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { LiveSyncCore } from "@/main.ts"; import { COMPATIBILITY_PAUSE_SETTING_MESSAGE, DATABASE_COMPATIBILITY_VERSION_KEY, evaluateCompatibilityPause, legacyDatabaseCompatibilityVersionKey, type CompatibilityPause, } from "@/common/databaseCompatibility.ts"; export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false; export type CompatibilityReviewDetailsAction = "back" | false; // Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the // compatibility review only after those operations have completed; a recovery // handler which stops start-up also prevents this dialogue from competing with it. export const COMPATIBILITY_REVIEW_LAYOUT_PRIORITY = 30; export interface CompatibilityReviewUi { showSummary(pause: CompatibilityPause): Promise; showDetails(pause: CompatibilityPause): Promise; showReminder(openReview: () => void): void; clearReminder(): void; } export class CompatibilityReviewController { private pause: CompatibilityPause | undefined; private activeReview: Promise | undefined; private _initialised = false; private disposed = false; constructor( private readonly core: LiveSyncCore, private readonly ui: CompatibilityReviewUi, private readonly currentVersion: number = VER ) {} get pendingPause(): CompatibilityPause | undefined { return this.pause; } get initialised(): boolean { return this._initialised; } private readAcknowledgedVersion(): string | null { const setting = this.core.services.setting; const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY); if (currentMarker) return currentMarker; const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); const legacyMarker = setting.getDeviceLocalConfig(legacyKey); if (!legacyMarker) return null; setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker); setting.deleteDeviceLocalConfig(legacyKey); return legacyMarker; } async initialise(): Promise { if (this.disposed) return true; const setting = this.core.services.setting; const settings = setting.currentSettings(); const migrationState = setting.getSettingsMigrationState(); // An existing unconfigured Vault cannot replicate, so a database // compatibility pause would only compete with onboarding and persist // a misleading sync warning. Do not acknowledge the missing marker: // activation on a later start must evaluate the same state again. // Genuinely new Vaults still initialise their marker below. if (settings.isConfigured !== true && migrationState?.isNewVault !== true) { this.pause = undefined; this.ui.clearReminder(); this._initialised = true; return true; } const acknowledgedVersion = this.readAcknowledgedVersion(); const evaluation = evaluateCompatibilityPause({ acknowledgedVersion, currentVersion: this.currentVersion, migrationState, legacyReviewMessage: settings.versionUpFlash, }); this.pause = evaluation.pause; if (evaluation.initialiseAcknowledgedVersion) { setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); this._initialised = true; return true; } if (!this.pause) { this._initialised = true; return true; } if (settings.versionUpFlash === "") { settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE; await setting.saveSettingData(); } this._initialised = true; return true; } private async acknowledge(): Promise { if (!this.pause?.resumable) return; const setting = this.core.services.setting; const settings = setting.currentSettings(); const previousMessage = settings.versionUpFlash; settings.versionUpFlash = ""; try { await setting.saveSettingData(); } catch (error) { settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE; throw error; } setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); setting.deleteDeviceLocalConfig(legacyKey); this.pause = undefined; this.ui.clearReminder(); await this.core.services.control.applySettings(); } private async runReview(): Promise { while (this.pause) { const action = await this.ui.showSummary(this.pause); if (action === "details") { const detailsAction = await this.ui.showDetails(this.pause); if (detailsAction === "back") continue; break; } if (action === "resume" && this.pause.resumable) { await this.acknowledge(); return; } break; } if (this.pause && !this.disposed) { this.ui.showReminder(() => { fireAndForget(() => this.openReview()); }); } } openReview(): Promise { if (this.disposed || !this.pause) return Promise.resolve(); if (this.activeReview) return this.activeReview; this.ui.clearReminder(); this.activeReview = this.runReview().finally(() => { this.activeReview = undefined; }); return this.activeReview; } dispose(): void { this.disposed = true; this.pause = undefined; this.ui.clearReminder(); } } export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController { const controller = new CompatibilityReviewController(core, ui); core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise()); core.services.appLifecycle.onLayoutReady.addHandler(() => { fireAndForget(() => controller.openReview()); return Promise.resolve(true); }, COMPATIBILITY_REVIEW_LAYOUT_PRIORITY); core.services.appLifecycle.onUnload.addHandler(() => { controller.dispose(); return Promise.resolve(true); }); core.services.API.addCommand({ id: "livesync-review-compatibility-pause", name: "Review why synchronisation is paused", checkCallback: (checking) => { if (!controller.pendingPause) return false; if (!checking) fireAndForget(() => controller.openReview()); return true; }, }); return controller; }