Files
obsidian-livesync/src/serviceFeatures/compatibilityReview.ts
T

187 lines
7.0 KiB
TypeScript

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<CompatibilityReviewSummaryAction>;
showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction>;
showReminder(openReview: () => void): void;
clearReminder(): void;
}
export class CompatibilityReviewController {
private pause: CompatibilityPause | undefined;
private activeReview: Promise<void> | 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<boolean> {
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<void> {
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<void> {
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<void> {
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;
}