Merge main and verify catalogue Reload and Apply in Obsidian

This commit is contained in:
vorotamoroz committed 2026-10-26 02:36:32 +00:00
commit b2fc839584
117 files changed
+7215 -744

No files matched your search

+14 -3
View File
@@ -20,10 +20,14 @@
MODE_SHINY,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { normalizePath } from "@/deps";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import {
HiddenFileSync,
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { $msg as translateMessage } from "@/common/translation";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
@@ -139,6 +143,11 @@
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.deleteData(data);
}
function checkAutomaticSyncAvailable() {
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
return false;
}
function askMode(evt: MouseEvent, title: string, key: string) {
const menu = new Menu();
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
@@ -149,6 +158,7 @@
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
.onClick((e) => {
if (mode === MODE_AUTOMATIC) {
if (!checkAutomaticSyncAvailable()) return;
askOverwriteModeForAutomatic(evt, key);
} else {
setMode(key, mode as SYNC_MODE);
@@ -161,10 +171,11 @@
menu.showAtMouseEvent(evt);
}
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
if (!checkAutomaticSyncAvailable()) return;
setMode(key, MODE_AUTOMATIC);
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
}
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
const menu = new Menu();
@@ -0,0 +1,13 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
export function getAutomaticSyncTargetFiles(
key: string,
configDir: string,
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
): string[] {
const keys = key.startsWith("PLUGIN_ALL/")
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
: [key];
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
return [...files].map((file) => `${configDir}/${file}`);
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
return {
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
"PLUGIN_MAIN/example": {
key: "PLUGIN_MAIN/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
},
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/data.json"],
},
"PLUGIN_ETC/example": {
key: "PLUGIN_ETC/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/extra.json"],
},
"PLUGIN_MAIN/other": {
key: "PLUGIN_MAIN/other",
mode: MODE_AUTOMATIC,
files: ["plugins/other/main.js"],
},
};
}
describe("getAutomaticSyncTargetFiles", () => {
it("initialises a single selected item in the current configuration directory", () => {
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
".custom-config/app.json",
]);
});
it("initialises both saved child selections when the whole plug-in is selected", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
".obsidian/plugins/example/data.json",
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("retains the available child files when the other child has no saved entry", () => {
const entries = createEntries();
delete entries["PLUGIN_DATA/example"];
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
});
});
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
import { HiddenFileSync } from "./CmdHiddenFileSync";
const hiddenPath = ".obsidian/plugins/example/main.js" as FilePath;
const prefixedPath = `i:${hiddenPath}`;
const body = "module.exports = class ExamplePlugin {};";
const allowList = "\\.(?:md|png|pdf)$";
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function hiddenFiles() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.usePluginSync = false;
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: fixture.settings,
services: {
...fixture.services,
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
},
cacheFileRegExps: new Map(),
cacheCustomisationSyncIgnoredFiles: new Map(),
});
return { ...fixture, hiddenFileSync };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("issue #1272: Hidden File Sync with normal-file selectors", () => {
it("stores a selected hidden file while the normal-file allow-list is set", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const stored = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
expect(stored).not.toBe(false);
if (stored) expect(await readContent(stored)).toBe(body);
});
it("reads existing hidden-file content while the normal-file allow-list is set", async () => {
const fixture = await hiddenFiles();
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const meta = await fixture.localDatabase.getDBEntryMeta(prefixedPath as FilePath);
if (!meta) throw new Error("The seeded hidden-file Metadata is unavailable");
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
const entry = await fixture.localDatabase.getDBEntryFromMeta(meta);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("stores and reads the same hidden file when the normal-file allow-list is empty", async () => {
const fixture = await hiddenFiles();
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
ok: true,
});
const entry = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("continues to apply the normal-file allow-list to ordinary files", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
expect(fixture.localDatabase.isTargetFile("notes/example.md" as FilePath)).toBe(true);
expect(fixture.localDatabase.isTargetFile("notes/example.js" as FilePath)).toBe(false);
expect(fixture.localDatabase.isTargetFile("i:.obsidian/example.md" as FilePath)).toBe(true);
});
it("keeps Customisation Sync documents usable with the same normal-file allow-list", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
const path = "ix:device/PLUGIN_MAIN/example%main.js" as FilePath;
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, body))).resolves.toMatchObject({
ok: true,
});
const entry = await fixture.localDatabase.getDBEntry(path);
expect(entry).not.toBe(false);
if (entry) expect(await readContent(entry)).toBe(body);
});
it("keeps Hidden File Sync target and ignore patterns effective", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncInternalFilesTargetPatterns =
"^\\.obsidian/plugins/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
await expect(fixture.hiddenFileSync.isTargetFile(".obsidian/snippets/example.css" as FilePath)).resolves.toBe(
false
);
fixture.settings.syncInternalFilesIgnorePatterns =
"main\\.js$" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(false);
});
it("compatibility: applies the normal-file ignore-list to hidden documents", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
fixture.settings.syncIgnoreRegEx = "^\\.obsidian/" as typeof fixture.settings.syncIgnoreRegEx;
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
});
it("rejects hidden documents when Hidden File Sync is disabled", async () => {
const fixture = await hiddenFiles();
fixture.settings.syncInternalFiles = false;
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
});
});
@@ -69,6 +69,9 @@ type HiddenFileInitialisationProgress = {
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
function getComparingMTime(
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
includeDeleted = false
@@ -1444,6 +1447,11 @@ Offline Changed files: ${files.length}`;
initialisationProgress?: HiddenFileInitialisationProgress
) {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
if (!this.isThisModuleEnabled()) {
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
return false;
}
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// p.log("Resolving conflicts before starting...");
// await this.resolveConflictOnInternalFiles();
@@ -1494,6 +1502,7 @@ Offline Changed files: ${files.length}`;
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
return true;
}
// <-- Initialization functions
@@ -1925,6 +1934,7 @@ Offline Changed files: ${files.length}`;
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
let initialised = false;
let result: ConfigureHiddenFileSyncResult;
try {
result = await configureHiddenFileSyncMode(mode, {
@@ -1954,7 +1964,7 @@ Offline Changed files: ${files.length}`;
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialisationProgress = undefined;
},
});
@@ -1962,7 +1972,7 @@ Offline Changed files: ${files.length}`;
initialisationProgress?.done("Failed");
throw error;
}
if (result == "ignored" || result == "disabled") {
if (result == "ignored" || result == "disabled" || !initialised) {
return;
}
// this.plugin.settings.useAdvancedMode = true;
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
}
describe("HiddenFileSync configuration-change notices", () => {
it.each(["pushForce", "pullForce", "safe"] as const)(
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
async (direction) => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const path = ".obsidian/example.json" as FilePath;
const operations = {
rebuildFromStorage: vi.fn(async () => [path]),
rebuildFromDatabase: vi.fn(async () => []),
rebuildMerging: vi.fn(async () => [path]),
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
scanAllStorageChanges: vi.fn(async () => undefined),
scanAllDatabaseChanges: vi.fn(async () => undefined),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
const settings = { syncInternalFiles: false };
const createProgress = vi.fn(() => progress);
const log = vi.fn();
Object.assign(hiddenFileSync, {
core: { settings },
_progress: createProgress,
_log: log,
...operations,
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
for (const operation of Object.values(operations)) {
expect(operation).not.toHaveBeenCalled();
}
expect(result).toBe(false);
expect(settings.syncInternalFiles).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
expect(progress.done).not.toHaveBeenCalled();
}
);
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const createProgress = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: false } },
_progress: createProgress,
_log: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
expect(result).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
});
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
const path = ".obsidian/example.json" as FilePath;
const deleted: MetaEntry = {
_id: "i:example" as DocumentID,
_rev: "2-deleted",
path: `i:${path}` as FilePathWithPrefix,
type: "plain",
ctime: 1,
mtime: 10,
size: 0,
deleted: true,
children: [],
eden: {},
};
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const deleteFile = vi.fn(async () => "OK" as const);
const updateDeletion = vi.fn();
const getFiles = vi.fn(async () => [deleted]);
const processedStorage = new Map([[path, "storage-processed"]]);
const processedDatabase = new Map([[path, "database-processed"]]);
const resetProcessed = vi.fn(() => processedDatabase.clear());
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: { syncInternalFiles: false },
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
},
_progress: vi.fn(() => progress),
_log: vi.fn(),
_verbose: vi.fn(),
_fileInfoLastProcessed: processedStorage,
_databaseInfoLastProcessed: processedDatabase,
getAllDatabaseFiles: getFiles,
getPath: (doc: MetaEntry) => doc.path,
resetLastProcessedDatabase: resetProcessed,
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
__deleteFile: deleteFile,
updateLastProcessedDeletion: updateDeletion,
adoptCurrentStorageFilesAsProcessed: vi.fn(),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
expect(deleteFile).not.toHaveBeenCalled();
expect(updateDeletion).not.toHaveBeenCalled();
expect(getFiles).not.toHaveBeenCalled();
expect(resetProcessed).not.toHaveBeenCalled();
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
});
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
id: string;
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(command?.checkCallback?.(true)).toBe(false);
}
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(false);
expect(command?.checkCallback?.(false)).toBe(false);
}
settings.syncInternalFiles = true;
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress),
rebuildMerging,
adoptCurrentStorageFilesAsProcessed,
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
scanAllDatabaseChanges,
});
await hiddenFileSync.initialiseInternalFileSync("safe", true);
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(result).toBe(true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce();
});
it.each(["pushForce", "pullForce", "safe"] as const)(
"retains the selected files and completes %s initialisation when enabled",
async (direction) => {
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const rebuildFromStorage = vi.fn(async () => files);
const rebuildFromDatabase = vi.fn(async () => entries);
const rebuildMerging = vi.fn(async () => files);
const adoptStorage = vi.fn();
const adoptDatabase = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress),
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
rebuildFromStorage,
rebuildFromDatabase,
rebuildMerging,
adoptCurrentStorageFilesAsProcessed: adoptStorage,
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
expect(result).toBe(true);
if (direction === "pushForce") {
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromDatabase).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else if (direction === "pullForce") {
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else {
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildFromDatabase).not.toHaveBeenCalled();
}
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
expect(progress.done).toHaveBeenCalledOnce();
}
);
it("does not report completion when the initialisation entry point refuses to start", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
await handlers.initialise("safe");
return "enabled";
});
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { services: { setting: { applyPartial: vi.fn() } } },
_progress: vi.fn(() => progress),
_log: log,
initialiseInternalFileSync: vi.fn(async () => false),
});
await hiddenFileSync.configureHiddenFileSync("MERGE");
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const applyPartial = vi.fn(async () => {
events.push("apply-settings");
});
const initialiseInternalFileSync = vi.fn(async () => undefined);
const initialiseInternalFileSync = vi.fn(async () => true);
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { ValueStoreError } from "@vrtmrz/value-store";
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
AcceptedStatus,
@@ -33,6 +34,14 @@
let settings = $state<P2PSyncSetting>(initialSettings);
let deviceName = $state<string>("");
let deviceNameReady = $state(false);
let dialogStatusReady = $state(false);
type StorageErrorKey = "deviceNameLoad" | "dialogStatusLoad" | "deviceNameWrite" | "dialogStatusWrite";
let storageErrors = $state<Partial<Record<StorageErrorKey, string>>>({});
let mounted = false;
let deviceNameLoadSequence = 0;
let deviceNameWriteQueue: Promise<void> = Promise.resolve();
let dialogStatusWriteQueue: Promise<void> = Promise.resolve();
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
let eRelay = $state<string>(initialSettings.P2P_relays);
@@ -68,6 +77,7 @@
);
async function saveAndApply() {
const savedDeviceName = eDeviceName;
// const newSettings = {
// ...currentSettings(),
// P2P_Enabled: eP2PEnabled,
@@ -92,8 +102,39 @@
},
true
);
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
deviceName = eDeviceName;
try {
await queueDeviceNameWrite(savedDeviceName);
if (!mounted) return;
deviceName = savedDeviceName;
clearStorageError("deviceNameWrite");
} catch (error) {
setStorageError("deviceNameWrite", error);
}
}
function setStorageError(key: StorageErrorKey, error: unknown) {
if (mounted) {
storageErrors = {
...storageErrors,
[key]: error instanceof Error ? error.message : String(error),
};
}
}
function clearStorageError(key: StorageErrorKey) {
if (!mounted) return;
const nextErrors = { ...storageErrors };
delete nextErrors[key];
storageErrors = nextErrors;
}
function queueDeviceNameWrite(name: string): Promise<void> {
const write = deviceNameWriteQueue.catch(() => undefined).then(async () => {
if (!mounted) return;
await services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, name);
});
deviceNameWriteQueue = write;
return write;
}
async function revert() {
eP2PEnabled = settings.P2P_Enabled;
@@ -110,10 +151,7 @@
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
if (force) {
const initDeviceName =
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
deviceName = initDeviceName;
eDeviceName = initDeviceName;
void loadDeviceName(true).catch(() => undefined);
}
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
@@ -129,6 +167,7 @@
settings = d;
};
onMount(() => {
mounted = true;
const r = events.onEvent("setting-saved", async (d) => {
applyLoadSettings(d, false);
closeServer();
@@ -143,15 +182,52 @@
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorInfo = status;
});
applyLoadSettings(currentSettings(), true);
applyLoadSettings(currentSettings(), false);
void loadInitialPreferences().catch(() => undefined);
host.p2p.diagnostics.requestStatus();
return () => {
mounted = false;
r();
rx();
r2();
r3();
};
});
async function loadInitialPreferences() {
const deviceNameLoad = loadDeviceName(true).catch(() => undefined);
const dialogStatusLoad = readDialogStatus().then(
(dialogStatus) => {
if (!mounted) return;
isNoticeOpened = dialogStatus.notice ?? true;
isSettingOpened = dialogStatus.setting ?? true;
dialogStatusReady = true;
clearStorageError("dialogStatusLoad");
},
(error: unknown) => {
setStorageError("dialogStatusLoad", error);
}
);
await Promise.all([deviceNameLoad, dialogStatusLoad]);
}
async function loadDeviceName(force: boolean) {
const request = ++deviceNameLoadSequence;
try {
const savedDeviceName = await services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME, "");
if (!mounted || request !== deviceNameLoadSequence) return;
const initialDeviceName = savedDeviceName;
if (force || !isDeviceNameModified) {
deviceName = initialDeviceName;
eDeviceName = initialDeviceName;
}
deviceNameReady = true;
clearStorageError("deviceNameLoad");
} catch (error) {
if (mounted && request === deviceNameLoadSequence) setStorageError("deviceNameLoad", error);
throw error;
}
}
let isConnected = $derived.by(() => {
return serverInfo?.isConnected ?? false;
});
@@ -234,26 +310,42 @@
}
const initialDialogStatusKey = `p2p-dialog-status`;
const getDialogStatus = () => {
const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
try {
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
notice?: boolean;
setting?: boolean;
const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
return {};
}
const status = initialDialogStatus as Record<string, unknown>;
return {
...(typeof status.notice === "boolean" ? { notice: status.notice } : {}),
...(typeof status.setting === "boolean" ? { setting: status.setting } : {}),
};
return initialDialogStatus;
} catch {
return {};
} catch (error) {
if (error instanceof ValueStoreError && error.code === "invalid-data") {
// Invalid optional UI state is recovered with the historical defaults.
return {};
}
throw error;
}
};
const initialDialogStatus = getDialogStatus();
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true);
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
let isNoticeOpened = $state<boolean>(true);
let isSettingOpened = $state<boolean>(true);
$effect(() => {
if (!mounted || !dialogStatusReady) return;
const dialogStatus = {
notice: isNoticeOpened,
setting: isSettingOpened,
};
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
const write = dialogStatusWriteQueue.catch(() => undefined).then(async () => {
if (!mounted) return;
await services.config.setObject(initialDialogStatusKey, dialogStatus);
});
dialogStatusWriteQueue = write;
void write.then(
() => clearStorageError("dialogStatusWrite"),
(error: unknown) => setStorageError("dialogStatusWrite", error)
);
});
let isObsidian = $derived.by(() => {
return services.API.getPlatform() === "obsidian";
@@ -261,6 +353,9 @@
</script>
<article>
{#each Object.values(storageErrors) as storageError}
<p role="alert">{storageError}</p>
{/each}
<h1>{_msg("Peer to Peer Replicator")}</h1>
<details bind:open={isNoticeOpened}>
<summary>{_msg("P2P.Note.Summary")}</summary>
@@ -345,6 +440,7 @@
type="text"
placeholder="iphone-16"
bind:value={eDeviceName}
disabled={!deviceNameReady}
autocomplete="off"
/>
</label>
@@ -383,7 +479,7 @@
</tr> -->
</tbody>
</table>
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}
<button disabled={!isAnyModified || !deviceNameReady} class="button mod-cta" onclick={saveAndApply}
>{_msg("Save and Apply")}</button
>
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
@@ -1,4 +1,5 @@
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import {
REVIEW_HARNESS_SCENARIOS,
type ReviewHarnessScenarioId,
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
true
);
this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
this.controller.prepareCompatibilityReviewRestart()
this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
);
}
}
@@ -23,9 +23,9 @@ export interface ReviewHarnessRuntime {
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
readContinuation(): string | null;
writeContinuation(value: string): void;
deleteContinuation(): void;
readContinuation(): Promise<string | null>;
writeContinuation(value: string): Promise<void>;
deleteContinuation(): Promise<void>;
restart(): void;
reportError(error: unknown): void;
copyText(value: string): Promise<void>;
@@ -68,6 +68,7 @@ export class ReviewHarnessController {
private current: ReviewHarnessScenarioId | null = null;
private resumedRequestId: string | null = null;
private continuationError: string | null = null;
private continuationConsumption: Promise<void> | undefined;
constructor(private readonly runtime: ReviewHarnessRuntime) {}
@@ -102,10 +103,20 @@ export class ReviewHarnessController {
};
}
consumeContinuation(): void {
const serialised = this.runtime.readContinuation();
consumeContinuation(): Promise<void> {
if (this.continuationConsumption) return this.continuationConsumption;
const consumption = this.consumeContinuationFromStore();
const trackedConsumption = consumption.finally(() => {
if (this.continuationConsumption === trackedConsumption) this.continuationConsumption = undefined;
});
this.continuationConsumption = trackedConsumption;
return trackedConsumption;
}
private async consumeContinuationFromStore(): Promise<void> {
const serialised = await this.runtime.readContinuation();
if (!serialised) return;
this.runtime.deleteContinuation();
await this.runtime.deleteContinuation();
const parsed = parsePendingReviewRun(serialised);
if (!parsed.pendingRun) {
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
@@ -232,7 +243,7 @@ export class ReviewHarnessController {
this.record("scenario-failed", id);
}
prepareCompatibilityReviewRestart(): void {
async prepareCompatibilityReviewRestart(): Promise<void> {
const requestedAt = this.runtime.now().toISOString();
const pending = {
formatVersion: 1,
@@ -241,7 +252,7 @@ export class ReviewHarnessController {
stage: "awaiting-restart",
requestedAt,
} as const;
this.runtime.writeContinuation(JSON.stringify(pending));
await this.runtime.writeContinuation(JSON.stringify(pending));
this.results["compatibility-review"] = {
status: "waiting-for-user",
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
@@ -90,14 +90,14 @@ function createRuntime(): ReviewHarnessRuntime & {
detail: "File content hash measurements completed.",
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
})),
readContinuation() {
async readContinuation() {
return this.continuation;
},
writeContinuation(value) {
async writeContinuation(value) {
this.events.push("write");
this.continuation = value;
},
deleteContinuation() {
async deleteContinuation() {
this.events.push("delete");
this.continuation = null;
},
@@ -251,7 +251,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().running).toBe(false);
});
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
it("deletes a one-shot continuation before exposing the resumed guided step", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
@@ -262,7 +262,7 @@ describe("ReviewHarnessController", () => {
});
const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation();
await controller.consumeContinuation();
expect(runtime.events).toEqual(["delete"]);
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
@@ -271,7 +271,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
});
it("does not copy rejected continuation values into the report", () => {
it("does not copy rejected continuation values into the report", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
@@ -282,7 +282,7 @@ describe("ReviewHarnessController", () => {
});
const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation();
await controller.consumeContinuation();
expect(controller.snapshot().continuationError).toContain("private-vault");
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
@@ -292,7 +292,7 @@ describe("ReviewHarnessController", () => {
expect(controller.createReport()).not.toContain("secret-note.md");
});
it("does not accept an injected request ID which could enter the report transcript", () => {
it("does not accept an injected request ID which could enter the report transcript", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
@@ -303,18 +303,18 @@ describe("ReviewHarnessController", () => {
});
const controller = new ReviewHarnessController(runtime);
controller.consumeContinuation();
await controller.consumeContinuation();
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
expect(controller.createReport()).not.toContain("private-vault");
expect(controller.createReport()).not.toContain("secret-note.md");
});
it("persists the fixed continuation before requesting restart", () => {
it("persists the fixed continuation before requesting restart", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
controller.prepareCompatibilityReviewRestart();
await controller.prepareCompatibilityReviewRestart();
expect(runtime.events).toEqual(["write", "restart"]);
expect(JSON.parse(runtime.continuation!)).toMatchObject({
@@ -324,6 +324,135 @@ describe("ReviewHarnessController", () => {
});
});
it("waits for the continuation save before requesting restart", async () => {
const runtime = createRuntime();
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
});
let finishSave!: () => void;
const saving = new Promise<void>((resolve) => {
finishSave = resolve;
});
runtime.writeContinuation = vi.fn(async (value) => {
runtime.events.push("write-started");
markSaveStarted();
await saving;
runtime.continuation = value;
runtime.events.push("write-saved");
});
const controller = new ReviewHarnessController(runtime);
const preparing = controller.prepareCompatibilityReviewRestart();
await saveStarted;
expect(runtime.events).toEqual(["write-started"]);
expect(runtime.restart).not.toHaveBeenCalled();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
finishSave();
await preparing;
expect(runtime.events).toEqual(["write-started", "write-saved", "restart"]);
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("keeps the guided review retryable when saving its continuation fails", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
await controller.runScenario("compatibility-review");
runtime.writeContinuation = vi.fn(async () => {
runtime.events.push("write-failed");
throw new Error("continuation save failed");
});
await expect(controller.prepareCompatibilityReviewRestart()).rejects.toThrow("continuation save failed");
expect(runtime.events).toEqual(["write-failed"]);
expect(runtime.restart).not.toHaveBeenCalled();
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("waits for one continuation deletion before exposing it and coalesces concurrent consumption", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt: "2026-07-18T11:59:00.000Z",
});
let markDeleteStarted!: () => void;
const deleteStarted = new Promise<void>((resolve) => {
markDeleteStarted = resolve;
});
let finishDelete!: () => void;
const deleting = new Promise<void>((resolve) => {
finishDelete = resolve;
});
runtime.readContinuation = vi.fn(async () => {
runtime.events.push("read");
return runtime.continuation;
});
runtime.deleteContinuation = vi.fn(async () => {
runtime.events.push("delete-started");
markDeleteStarted();
await deleting;
runtime.continuation = null;
runtime.events.push("delete-finished");
});
const controller = new ReviewHarnessController(runtime);
const firstConsumption = controller.consumeContinuation();
const duplicateConsumption = controller.consumeContinuation();
await deleteStarted;
expect(duplicateConsumption).toBe(firstConsumption);
expect(runtime.events).toEqual(["read", "delete-started"]);
expect(controller.snapshot().resumedRequestId).toBeNull();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
finishDelete();
await firstConsumption;
expect(runtime.events).toEqual(["read", "delete-started", "delete-finished"]);
expect(runtime.readContinuation).toHaveBeenCalledOnce();
expect(runtime.deleteContinuation).toHaveBeenCalledOnce();
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("keeps a failed continuation deletion available for retry", async () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
formatVersion: 1,
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
scenarioId: "compatibility-review",
stage: "awaiting-restart",
requestedAt: "2026-07-18T11:59:00.000Z",
});
runtime.deleteContinuation = vi
.fn<ReviewHarnessRuntime["deleteContinuation"]>()
.mockRejectedValueOnce(new Error("continuation delete failed"))
.mockImplementation(async () => {
runtime.events.push("delete");
runtime.continuation = null;
});
const controller = new ReviewHarnessController(runtime);
await expect(controller.consumeContinuation()).rejects.toThrow("continuation delete failed");
expect(runtime.continuation).not.toBeNull();
expect(controller.snapshot().resumedRequestId).toBeNull();
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
await controller.consumeContinuation();
expect(runtime.continuation).toBeNull();
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
});
it("passes only after the actual compatibility review clears its pause", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);