mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-31 13:28:18 +00:00
Merge remote-tracking branch 'upstream/main' into feat/syncguard-bulk-sync-edit-guard
# Conflicts: # src/common/messages/LiveSyncProvisionalMessages.ts # styles.css # updates.md
This commit is contained in:
commit
acd3b0ade6
157 files changed
+15431
-1108
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
|
||||
@@ -11,7 +11,7 @@ import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
config: IConfigService;
|
||||
control: ControlService<T>;
|
||||
replicator: ReplicatorService<T>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
@@ -24,10 +23,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import {
|
||||
LiveSyncBrowserAPIService,
|
||||
type LiveSyncBrowserAPIServiceOptions,
|
||||
} from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
|
||||
export interface LiveSyncBrowserSettingsPersistence {
|
||||
@@ -43,6 +39,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
||||
}
|
||||
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
deviceValues: ValueStore & ValueDeleter;
|
||||
context?: T;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
@@ -51,35 +48,12 @@ export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
|
||||
}
|
||||
|
||||
class LiveSyncBrowserAppLifecycleService<
|
||||
T extends ServiceContext,
|
||||
> extends InjectableAppLifecycleService<T> {}
|
||||
class LiveSyncBrowserAppLifecycleService<T extends ServiceContext> extends InjectableAppLifecycleService<T> {}
|
||||
|
||||
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
|
||||
|
||||
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
|
||||
|
||||
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
private readonly setting: ISettingService
|
||||
) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
getSmallConfig(key: string): string | null {
|
||||
return this.setting.getSmallConfig(key);
|
||||
}
|
||||
|
||||
setSmallConfig(key: string, value: string): void {
|
||||
this.setting.setSmallConfig(key, value);
|
||||
}
|
||||
|
||||
deleteSmallConfig(key: string): void {
|
||||
this.setting.deleteSmallConfig(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
@@ -98,17 +72,14 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
const setting = new InjectableSettingService(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: options.deviceValues,
|
||||
});
|
||||
const settingsPersistence = options.settings;
|
||||
setting.loadData.setHandler(
|
||||
settingsPersistence
|
||||
? () => settingsPersistence.load()
|
||||
: () => Promise.resolve(undefined)
|
||||
settingsPersistence ? () => settingsPersistence.load() : () => Promise.resolve(undefined)
|
||||
);
|
||||
setting.saveData.setHandler(
|
||||
settingsPersistence
|
||||
? (settings) => settingsPersistence.save(settings)
|
||||
: () => Promise.resolve()
|
||||
settingsPersistence ? (settings) => settingsPersistence.save(settings) : () => Promise.resolve()
|
||||
);
|
||||
|
||||
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
|
||||
@@ -142,7 +113,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
setting,
|
||||
API,
|
||||
});
|
||||
const config = new LiveSyncBrowserConfigService(context, setting);
|
||||
const config = setting.config;
|
||||
const replicator = new InjectableReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
@@ -163,8 +134,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
databaseService: database,
|
||||
});
|
||||
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
|
||||
openKeyValueDatabase:
|
||||
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||
openKeyValueDatabase: options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||
appLifecycle,
|
||||
databaseEvents,
|
||||
vault,
|
||||
@@ -209,7 +179,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
}
|
||||
|
||||
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
|
||||
options: LiveSyncBrowserServiceHubOptions<T> = {}
|
||||
options: LiveSyncBrowserServiceHubOptions<T>
|
||||
): LiveSyncBrowserServiceHub<T> {
|
||||
return new LiveSyncBrowserServiceHub(options);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -21,7 +22,8 @@ describe("LiveSync browser service context contract", () => {
|
||||
const context = createServiceContext({
|
||||
translate: (key) => `webapp:${key}`,
|
||||
});
|
||||
const hub = createLiveSyncBrowserServiceHub({ context });
|
||||
const deviceValues = new MemoryValueStore();
|
||||
const hub = createLiveSyncBrowserServiceHub({ context, deviceValues });
|
||||
|
||||
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
|
||||
translation: "webapp:moduleLocalDatabase.logWaitingForReady",
|
||||
@@ -32,20 +34,17 @@ describe("LiveSync browser service context contract", () => {
|
||||
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||
expect(hub.config).toBe(hub.setting.config);
|
||||
});
|
||||
|
||||
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
|
||||
const deviceLocalSettings = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
|
||||
removeItem: (key: string) => deviceLocalSettings.delete(key),
|
||||
clear: () => deviceLocalSettings.clear(),
|
||||
});
|
||||
const deviceValues = new MemoryValueStore();
|
||||
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
|
||||
const restartCalls: string[] = [];
|
||||
const nativeFetch = vi.fn(async () => new Response("ok"));
|
||||
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
|
||||
deviceValues,
|
||||
getSystemVaultName: () => "browser-vault",
|
||||
settings: {
|
||||
load: async () => ({
|
||||
@@ -69,6 +68,8 @@ describe("LiveSync browser service context contract", () => {
|
||||
|
||||
const hub = createLiveSyncBrowserServiceHub(options);
|
||||
|
||||
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||
expect(hub.config).toBe(hub.setting.config);
|
||||
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
|
||||
expect(hub.API.getPlatform()).toBe("browser");
|
||||
const response = await hub.API.nativeFetch("https://example.invalid/");
|
||||
@@ -86,4 +87,30 @@ describe("LiveSync browser service context contract", () => {
|
||||
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
|
||||
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
|
||||
});
|
||||
|
||||
it("keeps each browser composition bound to its own device value store", async () => {
|
||||
const firstValues = new MemoryValueStore();
|
||||
const secondValues = new MemoryValueStore();
|
||||
const firstHub = createLiveSyncBrowserServiceHub({
|
||||
deviceValues: firstValues,
|
||||
getSystemVaultName: () => "first-vault",
|
||||
});
|
||||
const secondHub = createLiveSyncBrowserServiceHub({
|
||||
deviceValues: secondValues,
|
||||
getSystemVaultName: () => "second-vault",
|
||||
});
|
||||
|
||||
await firstHub.config.setValue("probe", "first");
|
||||
await secondHub.config.setValue("probe", "second");
|
||||
|
||||
expect(firstHub.setting.deviceValues).toBe(firstValues);
|
||||
expect(secondHub.setting.deviceValues).toBe(secondValues);
|
||||
expect(firstHub.config).toBe(firstHub.setting.config);
|
||||
expect(secondHub.config).toBe(secondHub.setting.config);
|
||||
expect(firstHub.config).not.toBe(secondHub.config);
|
||||
await expect(firstHub.config.getValue("probe")).resolves.toBe("first");
|
||||
await expect(secondHub.config.getValue("probe")).resolves.toBe("second");
|
||||
await expect(firstValues.getValue("first-vault-probe")).resolves.toBe("first");
|
||||
await expect(secondValues.getValue("second-vault-probe")).resolves.toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -719,8 +719,8 @@ src/apps/cli/
|
||||
│ └── ServiceFileAccessImpl.ts
|
||||
├── services/
|
||||
│ ├── NodeKeyValueDBService.ts
|
||||
│ ├── NodeServiceHub.ts
|
||||
│ └── NodeSettingService.ts
|
||||
│ ├── NodeLocalStorage.ts
|
||||
│ └── NodeServiceHub.ts
|
||||
├── test/
|
||||
│ ├── test-e2e-two-vaults-common.sh
|
||||
│ ├── test-e2e-two-vaults-matrix.sh
|
||||
|
||||
@@ -88,6 +88,27 @@ describe("CLI database preparation", () => {
|
||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||
}
|
||||
|
||||
it("loads legacy device values and persists configuration through the service-owned store", async () => {
|
||||
const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json");
|
||||
await mkdir(join(directory, ".livesync", "runtime"), { recursive: true });
|
||||
await writeFile(localStoragePath, JSON.stringify({ ["__proto__"]: "legacy value" }));
|
||||
let legacyValue: string | null | undefined;
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
legacyValue = await core.services.setting.deviceValues.getValue("__proto__");
|
||||
expect(core.services.config).toBe(core.services.setting.config);
|
||||
await core.services.config.setValue("bootstrap-probe", "config value");
|
||||
return false;
|
||||
});
|
||||
|
||||
await start("ls", runner);
|
||||
|
||||
expect(legacyValue).toBe("legacy value");
|
||||
const stored = JSON.parse(await readFile(localStoragePath, "utf-8")) as Record<string, string>;
|
||||
expect(Object.prototype.hasOwnProperty.call(stored, "__proto__")).toBe(true);
|
||||
expect(stored["__proto__"]).toBe("legacy value");
|
||||
expect(Object.values(stored)).toContain("config value");
|
||||
});
|
||||
|
||||
it.each([REMOTE_P2P, REMOTE_COUCHDB])(
|
||||
"restores configured P2P automation before the host connects (%s)",
|
||||
async (remoteType) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
|
||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||
import {
|
||||
@@ -78,7 +77,6 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
|
||||
},
|
||||
};
|
||||
|
||||
ensureGlobalNodeLocalStorage();
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
||||
|
||||
/** Injectable command boundary used by CLI integration probes. */
|
||||
@@ -400,8 +398,6 @@ export async function main(
|
||||
const settingsPath = options.settingsPath
|
||||
? path.resolve(options.settingsPath)
|
||||
: path.join(databasePath, SETTINGS_FILE);
|
||||
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
|
||||
|
||||
// Resolve vault path: mirror positional argument takes priority,
|
||||
// then --vault flag, otherwise fall back to databasePath.
|
||||
const vaultPath =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.39-cli",
|
||||
"version": "1.0.42-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,86 @@
|
||||
import { createDeletableValueStore, sanitiseValueStoreError, ValueStoreError } from "@vrtmrz/value-store";
|
||||
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type LocalStorageShape = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
type NodeLocalStorage = ValueStore & ValueDeleter;
|
||||
|
||||
class PersistentNodeLocalStorage {
|
||||
private storagePath: string | undefined;
|
||||
private localStore: Record<string, string> = {};
|
||||
class JsonFileValueBackend {
|
||||
private values: Map<string, string>;
|
||||
|
||||
configure(storagePath: string) {
|
||||
if (this.storagePath === storagePath) {
|
||||
return;
|
||||
}
|
||||
this.storagePath = storagePath;
|
||||
this.loadFromFile();
|
||||
constructor(private readonly storagePath: string) {
|
||||
this.values = this.loadValues();
|
||||
}
|
||||
|
||||
private loadFromFile() {
|
||||
if (!this.storagePath) {
|
||||
this.localStore = {};
|
||||
get(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
const nextValues = new Map(this.values);
|
||||
nextValues.set(key, value);
|
||||
this.writeValues(nextValues);
|
||||
this.values = nextValues;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
if (!this.values.has(key)) {
|
||||
return;
|
||||
}
|
||||
const nextValues = new Map(this.values);
|
||||
nextValues.delete(key);
|
||||
this.writeValues(nextValues);
|
||||
this.values = nextValues;
|
||||
}
|
||||
|
||||
private loadValues(): Map<string, string> {
|
||||
let contents: string;
|
||||
try {
|
||||
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
|
||||
this.localStore = { ...loaded };
|
||||
contents = nodeFs.readFileSync(this.storagePath, "utf-8");
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return new Map();
|
||||
}
|
||||
throw sanitiseValueStoreError(error);
|
||||
}
|
||||
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(contents);
|
||||
} catch {
|
||||
this.localStore = {};
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
if (document === null || typeof document !== "object" || Array.isArray(document)) {
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
|
||||
const values = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
values.set(key, value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private flushToFile() {
|
||||
if (!this.storagePath) {
|
||||
return;
|
||||
}
|
||||
private writeValues(values: ReadonlyMap<string, string>): void {
|
||||
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.localStore[key] ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.localStore[key] = value;
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
if (!(key in this.localStore)) {
|
||||
return;
|
||||
}
|
||||
delete this.localStore[key];
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.localStore = {};
|
||||
this.flushToFile();
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), null, 2), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
|
||||
|
||||
function createNodeLocalStorageShim(): LocalStorageShape {
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return persistentNodeLocalStorage.getItem(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
},
|
||||
clear() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureGlobalNodeLocalStorage() {
|
||||
const g = compatGlobal as unknown as Record<string, unknown>;
|
||||
if (
|
||||
!("localStorage" in g) ||
|
||||
typeof (g.localStorage as Record<string, unknown> | undefined)?.getItem !== "function"
|
||||
) {
|
||||
g.localStorage = createNodeLocalStorageShim();
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const code = Object.getOwnPropertyDescriptor(error, "code");
|
||||
return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function configureNodeLocalStorage(storagePath: string) {
|
||||
persistentNodeLocalStorage.configure(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
}
|
||||
|
||||
export function getNodeLocalStorageItem(key: string): string {
|
||||
return persistentNodeLocalStorage.getItem(key) ?? "";
|
||||
}
|
||||
|
||||
export function setNodeLocalStorageItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function deleteNodeLocalStorageItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
}
|
||||
|
||||
export function clearNodeLocalStorage() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
|
||||
return createDeletableValueStore(new JsonFileValueBackend(storagePath));
|
||||
}
|
||||
@@ -1,58 +1,127 @@
|
||||
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearNodeLocalStorage,
|
||||
configureNodeLocalStorage,
|
||||
ensureGlobalNodeLocalStorage,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||
|
||||
describe("NodeLocalStorage", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
clearNodeLocalStorage();
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists values to the configured file", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
setNodeLocalStorageItem("checkpoint", "42");
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved.checkpoint).toBe("42");
|
||||
});
|
||||
|
||||
it("reloads persisted values when configured again", () => {
|
||||
it("loads and preserves legacy flat string data, including empty values and exact keys", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const specialKey = "__proto__";
|
||||
const specialValue = "line one\n雪";
|
||||
|
||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8");
|
||||
fs.writeFileSync(storagePath, JSON.stringify({ [specialKey]: specialValue, empty: "" }), "utf-8");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
|
||||
expect(getNodeLocalStorageItem("persisted")).toBe("value");
|
||||
await expect(store.getValue(specialKey)).resolves.toBe(specialValue);
|
||||
await expect(store.getValue("empty", "default")).resolves.toBe("");
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved[specialKey]).toBe(specialValue);
|
||||
expect(saved.empty).toBe("");
|
||||
});
|
||||
|
||||
it("installs a global localStorage shim backed by the same store", () => {
|
||||
it("persists strings and objects, reloads them, and deletes keys", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
const object = { enabled: false, entries: ["value", null, 0] };
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
await store.setValue("empty", "");
|
||||
await store.setValue("text", "stored value");
|
||||
await store.setObject("object", object);
|
||||
|
||||
globalThis.localStorage.setItem("shared", "state");
|
||||
const reloaded = createNodeLocalStorage(storagePath);
|
||||
await expect(reloaded.getValue("empty", "default")).resolves.toBe("");
|
||||
await expect(reloaded.getValue("text")).resolves.toBe("stored value");
|
||||
await expect(reloaded.getObject("object")).resolves.toEqual(object);
|
||||
|
||||
expect(getNodeLocalStorageItem("shared")).toBe("state");
|
||||
await reloaded.delete("text");
|
||||
await expect(reloaded.getValue("text")).resolves.toBeNull();
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved.text).toBeUndefined();
|
||||
expect(saved.object).toBe(JSON.stringify(object));
|
||||
});
|
||||
|
||||
it("treats a missing file as empty and creates its parent on the first write", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
|
||||
await expect(store.getValue("absent")).resolves.toBeNull();
|
||||
expect(fs.existsSync(storagePath)).toBe(false);
|
||||
await store.setValue("created", "value");
|
||||
expect(fs.existsSync(storagePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps separate store instances isolated by their file paths", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const first = createNodeLocalStorage(path.join(tempDir, "first", "local-storage.json"));
|
||||
const second = createNodeLocalStorage(path.join(tempDir, "second", "local-storage.json"));
|
||||
|
||||
await first.setValue("shared-key", "first");
|
||||
await second.setValue("shared-key", "second");
|
||||
|
||||
await expect(first.getValue("shared-key")).resolves.toBe("first");
|
||||
await expect(second.getValue("shared-key")).resolves.toBe("second");
|
||||
});
|
||||
|
||||
it("surfaces malformed and unreadable files as sanitised value-store errors", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const malformedPath = path.join(tempDir, "malformed.json");
|
||||
fs.writeFileSync(malformedPath, "{ malformed", "utf-8");
|
||||
|
||||
let malformedError: unknown;
|
||||
try {
|
||||
createNodeLocalStorage(malformedPath);
|
||||
} catch (error) {
|
||||
malformedError = error;
|
||||
}
|
||||
expect(malformedError).toBeInstanceOf(ValueStoreError);
|
||||
expect(malformedError).toMatchObject({ code: "invalid-data" });
|
||||
expect(fs.readFileSync(malformedPath, "utf-8")).toBe("{ malformed");
|
||||
|
||||
const unreadablePath = path.join(tempDir, "unreadable.json");
|
||||
fs.mkdirSync(unreadablePath);
|
||||
expect(() => createNodeLocalStorage(unreadablePath)).toThrowError(ValueStoreError);
|
||||
});
|
||||
|
||||
it("does not change its visible values when a write fails", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
await store.setValue("retained", "before failure");
|
||||
|
||||
fs.rmSync(storagePath);
|
||||
fs.mkdirSync(storagePath);
|
||||
|
||||
await expect(store.setValue("new", "write fails")).rejects.toBeInstanceOf(ValueStoreError);
|
||||
await expect(store.getValue("retained")).resolves.toBe("before failure");
|
||||
await expect(store.getValue("new")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not install or modify a global localStorage value", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const before = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
|
||||
createNodeLocalStorage(path.join(tempDir, "runtime", "local-storage.json"));
|
||||
|
||||
expect(Object.getOwnPropertyDescriptor(globalThis, "localStorage")).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
||||
import type {
|
||||
ComponentHasResult,
|
||||
SvelteDialogManager,
|
||||
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
|
||||
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
|
||||
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
|
||||
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
|
||||
@@ -21,7 +21,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
|
||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
import { NodeSettingService } from "./NodeSettingService";
|
||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -96,11 +96,11 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const fileProcessing = new InjectableFileProcessingService(context);
|
||||
|
||||
const setting = new NodeSettingService(
|
||||
context,
|
||||
{ APIService: API, onDisplayLanguageChanged: setLang },
|
||||
localStoragePath
|
||||
);
|
||||
const setting = new InjectableSettingService<T>(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: createNodeLocalStorage(localStoragePath),
|
||||
});
|
||||
|
||||
const appLifecycle = new NodeAppLifecycleService<T>(context, {
|
||||
settingService: setting,
|
||||
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
setting,
|
||||
});
|
||||
|
||||
const config = new ConfigServiceBrowserCompat<T>(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const config = setting.config;
|
||||
|
||||
const replicator = new InjectableReplicatorService(context, {
|
||||
settingService: setting,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB } from "@/common/events";
|
||||
import { handlers } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import {
|
||||
configureNodeLocalStorage,
|
||||
deleteNodeLocalStorageItem,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
|
||||
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
|
||||
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
|
||||
super(context, dependencies);
|
||||
configureNodeLocalStorage(storagePath);
|
||||
this.onSettingSaved.addHandler((settings) => {
|
||||
this.context.events.emitEvent(EVENT_SETTING_SAVED, settings);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
this.onSettingLoaded.addHandler((settings) => {
|
||||
this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
protected setItem(key: string, value: string) {
|
||||
setNodeLocalStorageItem(key, value);
|
||||
}
|
||||
|
||||
protected getItem(key: string): string {
|
||||
return getNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
protected deleteItem(key: string): void {
|
||||
deleteNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
|
||||
public loadData = handlers<{ loadData: () => Promise<ObsidianLiveSyncSettings | undefined> }>().binder("loadData");
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFea
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
createLiveSyncBrowserServiceHub,
|
||||
@@ -56,6 +58,7 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
||||
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
|
||||
|
||||
export interface WebAppRuntimeOptions {
|
||||
deviceValues?: ValueStore & ValueDeleter;
|
||||
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
|
||||
scheduleReload?: (delayMilliseconds: number) => void;
|
||||
}
|
||||
@@ -71,7 +74,10 @@ export class WebAppRuntime {
|
||||
private paneHost: P2PReplicatorPaneHost | null = null;
|
||||
private restartScheduled = false;
|
||||
|
||||
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
|
||||
constructor(
|
||||
rootHandle: FileSystemDirectoryHandle,
|
||||
private readonly options: WebAppRuntimeOptions = {}
|
||||
) {
|
||||
this.rootHandle = rootHandle;
|
||||
this.reportStatus = options.reportStatus ?? (() => {});
|
||||
this.scheduleReload =
|
||||
@@ -89,6 +95,8 @@ export class WebAppRuntime {
|
||||
|
||||
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
|
||||
return {
|
||||
deviceValues:
|
||||
this.options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
|
||||
settings: {
|
||||
save: async (data) => {
|
||||
@@ -219,7 +227,9 @@ export class WebAppRuntime {
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
prepareP2PSettings: useP2PSettingsPreparation(
|
||||
core.services.API.webCompatFetch.bind(core.services.API)
|
||||
),
|
||||
});
|
||||
this.paneHost = {
|
||||
services: core.services,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.39-webapp",
|
||||
"version": "1.0.42-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -21,6 +21,6 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.39-webpeer",
|
||||
"version": "1.0.42-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,6 +24,6 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/t
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
@@ -20,6 +22,7 @@ import { translateLiveSyncMessage } from "@/common/translation";
|
||||
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
|
||||
|
||||
export interface WebPeerRuntimeOptions {
|
||||
deviceValues?: ValueStore & ValueDeleter;
|
||||
context?: ServiceContext;
|
||||
store?: SimpleStore<unknown>;
|
||||
deviceName?: string;
|
||||
@@ -62,6 +65,7 @@ export class WebPeerRuntime {
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
context: this.context,
|
||||
deviceValues: options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
|
||||
settings: persistence.settings,
|
||||
restart: {
|
||||
@@ -103,7 +107,7 @@ export class WebPeerRuntime {
|
||||
await this.services.setting.loadSettings();
|
||||
const deviceName = this.options.deviceName?.trim();
|
||||
if (deviceName) {
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
await this.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
}
|
||||
const opened = await this.services.database.openDatabase({
|
||||
replicator: this.services.replicator,
|
||||
@@ -192,7 +196,7 @@ export class WebPeerRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
getDeviceName(): string {
|
||||
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
|
||||
async getDeviceName(): Promise<string> {
|
||||
return (await this.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME)) || this.services.vault.getVaultName();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,74 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Synchronisation was interrupted. You can continue locally, but some notes may be out of date.",
|
||||
"At least ${COUNT} changed files received": "At least ${COUNT} changed files received",
|
||||
"Continue without waiting": "Continue without waiting",
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
|
||||
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
|
||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
|
||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.",
|
||||
"Unknown platform": "Unknown platform",
|
||||
"This removes only this device's record from the remote.":
|
||||
"This removes only this device's record from the remote.",
|
||||
"This remote is unlocked. A device that returns can register again.":
|
||||
"This remote is unlocked. A device that returns can register again.",
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again.":
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again.",
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again.":
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again.",
|
||||
"This remote does not support conditional updates, so the device record could not be removed.":
|
||||
"This remote does not support conditional updates, so the device record could not be removed.",
|
||||
"This remote does not currently have a record of joined devices.":
|
||||
"This remote does not currently have a record of joined devices.",
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.":
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
|
||||
"This device has been locked out. Use the existing recovery process to join again.":
|
||||
"This device has been locked out. Use the existing recovery process to join again.",
|
||||
"This device": "This device",
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.":
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.",
|
||||
"The device record was forgotten.": "The device record was forgotten.",
|
||||
"The device record was already absent.": "The device record was already absent.",
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again.":
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again.",
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.":
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.",
|
||||
"Review device records for the active CouchDB or Object Storage connection.":
|
||||
"Review device records for the active CouchDB or Object Storage connection.",
|
||||
Refresh: "Refresh",
|
||||
"Plug-in version": "Plug-in version",
|
||||
Platform: "Platform",
|
||||
"Older Self-hosted LiveSync versions detected: ${versions}.":
|
||||
"Older Self-hosted LiveSync versions detected: ${versions}.",
|
||||
"Newer Self-hosted LiveSync versions detected: ${versions}.":
|
||||
"Newer Self-hosted LiveSync versions detected: ${versions}.",
|
||||
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.":
|
||||
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.",
|
||||
Open: "Open",
|
||||
"Not recorded": "Not recorded",
|
||||
"Not accepted": "Not accepted",
|
||||
"No joined devices are recorded on this remote.": "No joined devices are recorded on this remote.",
|
||||
"Locked out": "Locked out",
|
||||
"Loading joined devices…": "Loading joined devices…",
|
||||
"Last recorded": "Last recorded",
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.":
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.",
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.":
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.",
|
||||
"Joined devices": "Joined devices",
|
||||
"Review devices": "Review devices",
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.":
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
|
||||
"Forgetting…": "Forgetting…",
|
||||
"Forgetting the record does not delete Vault files or revoke credentials.":
|
||||
"Forgetting the record does not delete Vault files or revoke credentials.",
|
||||
"Forget this device record?": "Forget this device record?",
|
||||
"Forget device": "Forget device",
|
||||
Close: "Close",
|
||||
Cancel: "Cancel",
|
||||
"App version": "App version",
|
||||
Accepted: "Accepted",
|
||||
"Preparing follow-up report…": "Preparing follow-up report…",
|
||||
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
|
||||
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
import {
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBInitialFetchFastForwardResourceFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
@@ -115,6 +118,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||
createCouchDBJoinedDevicesResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createCouchDBPreferredTweakProbeFactory(host)
|
||||
@@ -125,6 +131,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
|
||||
createCouchDBSynchronisationInformationResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: supportedCapability(
|
||||
createCouchDBInitialFetchFastForwardResourceFactory(host)
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
@@ -141,6 +150,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||
createObjectStorageJoinedDevicesResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||
createObjectStorageConnectionProbeFactory(host)
|
||||
),
|
||||
@@ -151,6 +163,7 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
createObjectStorageSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
|
||||
@@ -44,7 +44,16 @@ describe("central Replicator provider definitions", () => {
|
||||
it("keeps the retained remote-resource catalogue bounded", () => {
|
||||
expect
|
||||
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||
.toEqual(
|
||||
[
|
||||
"connection",
|
||||
"initial-fetch-fast-forward",
|
||||
"joined-devices",
|
||||
"preferred-tweak",
|
||||
"security-seed",
|
||||
"synchronisation-information",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
|
||||
@@ -106,18 +115,27 @@ describe("central Replicator provider definitions", () => {
|
||||
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
||||
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
||||
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
const resources = Object.values(REMOTE_RESOURCE_KINDS).sort();
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(resources);
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(resources);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logger: vi.fn(),
|
||||
initialFetchFastForwardResource: undefined as unknown,
|
||||
couchDB: [] as Array<{
|
||||
host: unknown;
|
||||
isMobile: ReturnType<typeof vi.fn>;
|
||||
@@ -12,6 +13,9 @@ const mocks = vi.hoisted(() => ({
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||
createInitialFetchFastForwardResource: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
objectStorage: [] as Array<{
|
||||
@@ -20,6 +24,8 @@ const mocks = vi.hoisted(() => ({
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
@@ -42,6 +48,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
getJoinedDevices = vi.fn();
|
||||
forgetJoinedDevice = vi.fn();
|
||||
createInitialFetchFastForwardResource = vi.fn(() => mocks.initialFetchFastForwardResource);
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
@@ -58,6 +67,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
getJoinedDevices = vi.fn();
|
||||
forgetJoinedDevice = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
@@ -69,10 +80,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
||||
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createCouchDBInitialFetchFastForwardResourceFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
@@ -92,6 +106,7 @@ describe("replicator probe factories", () => {
|
||||
beforeEach(() => {
|
||||
mocks.couchDB.length = 0;
|
||||
mocks.objectStorage.length = 0;
|
||||
mocks.initialFetchFastForwardResource = undefined;
|
||||
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
|
||||
mocks.logger.mockClear();
|
||||
});
|
||||
@@ -226,6 +241,65 @@ describe("replicator probe factories", () => {
|
||||
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: "CouchDB",
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
createFactory: createCouchDBJoinedDevicesResourceFactory,
|
||||
instances: () => mocks.couchDB,
|
||||
},
|
||||
{
|
||||
kind: "Object Storage",
|
||||
remoteType: REMOTE_MINIO,
|
||||
createFactory: createObjectStorageJoinedDevicesResourceFactory,
|
||||
instances: () => mocks.objectStorage,
|
||||
},
|
||||
])(
|
||||
"keeps $kind joined-device operations on an owned settings snapshot",
|
||||
async ({ remoteType, createFactory, instances }) => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings({ remoteType });
|
||||
const snapshot = { ...source };
|
||||
const factory = createFactory(host as never);
|
||||
const resource = await factory(source);
|
||||
const nextResource = await factory(source);
|
||||
const [replicator, nextReplicator] = instances();
|
||||
expect(instances()).toHaveLength(2);
|
||||
expect(replicator.host).toBe(host);
|
||||
expect(replicator.getJoinedDevices).not.toHaveBeenCalled();
|
||||
expect(replicator.forgetJoinedDevice).not.toHaveBeenCalled();
|
||||
const devices = {
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true }],
|
||||
};
|
||||
replicator.getJoinedDevices.mockResolvedValue(devices);
|
||||
replicator.forgetJoinedDevice.mockResolvedValue("conflict");
|
||||
|
||||
source.couchDB_DBNAME = "changed-vault";
|
||||
source.bucket = "changed-bucket";
|
||||
await expect(resource.read()).resolves.toBe(devices);
|
||||
await expect(resource.forget("other")).resolves.toBe("conflict");
|
||||
expect(replicator.getJoinedDevices).toHaveBeenCalledWith(snapshot);
|
||||
expect(replicator.getJoinedDevices.mock.calls[0][0]).not.toBe(source);
|
||||
expect(replicator.forgetJoinedDevice).toHaveBeenCalledWith(snapshot, "other");
|
||||
|
||||
const failure = new Error("remote unavailable");
|
||||
replicator.getJoinedDevices.mockRejectedValue(failure);
|
||||
await expect(resource.read()).rejects.toBe(failure);
|
||||
replicator.forgetJoinedDevice.mockRejectedValue(failure);
|
||||
await expect(resource.forget("other")).rejects.toBe(failure);
|
||||
const disposal = resource.dispose();
|
||||
expect(resource.dispose()).toBe(disposal);
|
||||
await disposal;
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(nextReplicator.closeReplication).not.toHaveBeenCalled();
|
||||
await nextResource.dispose();
|
||||
expect(nextReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
|
||||
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
|
||||
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
|
||||
@@ -370,4 +444,29 @@ describe("replicator probe factories", () => {
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("creates an Initial Fetch Fast Forward resource from a settings snapshot and leaves ownership to the resource", async () => {
|
||||
const host = { name: "host" };
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
const prepare = vi.fn(async () => undefined);
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const resource = { prepare, dispose };
|
||||
const factory = createCouchDBInitialFetchFastForwardResourceFactory(host as never);
|
||||
mocks.initialFetchFastForwardResource = resource;
|
||||
|
||||
const result = await factory(settings);
|
||||
const replicator = mocks.couchDB[0];
|
||||
|
||||
settings.couchDB_URI = "https://changed.example.test";
|
||||
expect(replicator.createInitialFetchFastForwardResource).toHaveBeenCalledWith(snapshot);
|
||||
expect(replicator.createInitialFetchFastForwardResource.mock.calls[0][0]).not.toBe(settings);
|
||||
expect(replicator.host).toBe(host);
|
||||
expect(result).toBe(resource);
|
||||
|
||||
await expect(result.prepare()).resolves.toBeUndefined();
|
||||
await result.dispose();
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,9 @@ export {
|
||||
type SecuritySeedResourceHost,
|
||||
} from "./securitySeed";
|
||||
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
|
||||
|
||||
export {
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
} from "./joinedDevices";
|
||||
export { createCouchDBInitialFetchFastForwardResourceFactory } from "./initialFetchFastForward";
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { InitialFetchFastForwardResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { snapshotRemoteSettings } from "./shared";
|
||||
|
||||
/** Create a private CouchDB Replicator for a fresh Fast Fetch checkpoint handoff. */
|
||||
export function createCouchDBInitialFetchFastForwardResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): InitialFetchFastForwardResourceFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
const replicator = new LiveSyncCouchDBReplicator(host);
|
||||
return Promise.resolve(replicator.createInitialFetchFastForwardResource(snapshot));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
JoinedDevicesResource,
|
||||
JoinedDevicesResourceFactory,
|
||||
JoinedDevicesSnapshot,
|
||||
ForgetJoinedDeviceResult,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
|
||||
|
||||
interface JoinedDevicesReplicator extends ResourceReplicator {
|
||||
getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
|
||||
forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
|
||||
}
|
||||
|
||||
function createResource(replicator: JoinedDevicesReplicator, snapshot: RemoteDBSettings): JoinedDevicesResource {
|
||||
return {
|
||||
read: () => replicator.getJoinedDevices(snapshot),
|
||||
forget: (deviceId) => replicator.forgetJoinedDevice(snapshot, deviceId),
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCouchDBJoinedDevicesResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): JoinedDevicesResourceFactory {
|
||||
return (setting) =>
|
||||
Promise.resolve(createResource(new LiveSyncCouchDBReplicator(host), snapshotRemoteSettings(setting)));
|
||||
}
|
||||
|
||||
export function createObjectStorageJoinedDevicesResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): JoinedDevicesResourceFactory {
|
||||
return (setting) =>
|
||||
Promise.resolve(createResource(new LiveSyncJournalReplicator(host), snapshotRemoteSettings(setting)));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -18,17 +19,7 @@ import {
|
||||
} from "./turnSettingsPrivacy";
|
||||
|
||||
class MemorySettingService extends SettingService {
|
||||
readonly items = new Map<string, string>();
|
||||
saved?: ObsidianLiveSyncSettings;
|
||||
protected setItem(key: string, value: string) {
|
||||
this.items.set(key, value);
|
||||
}
|
||||
protected getItem(key: string) {
|
||||
return this.items.get(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string) {
|
||||
this.items.delete(key);
|
||||
}
|
||||
protected saveData(settings: ObsidianLiveSyncSettings) {
|
||||
this.saved = structuredClone(settings);
|
||||
return Promise.resolve();
|
||||
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
|
||||
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
|
||||
current.remoteConfigurations.managed.uri = originalURI;
|
||||
const service = new MemorySettingService(new ServiceContext(), {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
APIService: {
|
||||
getSystemVaultName: () => "test-vault",
|
||||
getAppID: () => "test-app",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: (path: string) => path,
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
|
||||
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { ConfigSync, PluginDataExDisplayV2, pluginManifests } from "./CmdConfigSync";
|
||||
|
||||
const filenames = ["main.js", "manifest.json", "styles.css"];
|
||||
const content: Record<string, string> = {
|
||||
"main.js": "module.exports = class ExamplePlugin {};",
|
||||
"manifest.json": JSON.stringify({ id: "example", name: "Example", version: "1.0.0" }),
|
||||
"styles.css": ".example { color: red; }",
|
||||
};
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function catalogue() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.syncOnlyRegEx = "\\.(?:md|png|pdf)$" as typeof fixture.settings.syncOnlyRegEx;
|
||||
const paths = filenames.map((name) => `ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix);
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const wireContent = `fixture-header\u200b\n\u200c${btoa(content[filenames[index]])}`;
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, wireContent))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
const gates = new Map(paths.map((path) => [path, deferred()]));
|
||||
const readEntry = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
|
||||
await gates.get(path)?.promise;
|
||||
return readEntry(path, ...args);
|
||||
});
|
||||
const written = new Map<string, string>();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
localDatabase: fixture.localDatabase,
|
||||
settings: fixture.settings,
|
||||
services: fixture.services,
|
||||
storageAccess: {
|
||||
ensureDir: vi.fn(async () => true),
|
||||
readHiddenFileText: vi.fn(async (path: string) => written.get(path) ?? ""),
|
||||
writeHiddenFileAuto: vi.fn(async (path: string, data: string) => {
|
||||
written.set(path, data);
|
||||
}),
|
||||
},
|
||||
},
|
||||
pluginList: [],
|
||||
pluginListGeneration: 0,
|
||||
loadedManifest_mTime: new Map(),
|
||||
updatingV2Count: 0,
|
||||
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
|
||||
_log: vi.fn(),
|
||||
storeCustomisationFileV2: vi.fn(async () => true),
|
||||
});
|
||||
const updates: Promise<void>[] = [];
|
||||
const update = configSync.updatePluginListV2.bind(configSync);
|
||||
vi.spyOn(configSync, "updatePluginListV2").mockImplementation((...args) => {
|
||||
const promise = update(...args);
|
||||
updates.push(promise);
|
||||
return promise;
|
||||
});
|
||||
const release = (order = filenames) => {
|
||||
for (const name of order) gates.get(`ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix)!.resolve();
|
||||
};
|
||||
return { ...fixture, configSync, updates, paths, release, written };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
pluginManifests.clear();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("issue #1271: rebuilding a multi-file Customisation Sync item", () => {
|
||||
it.each([
|
||||
{ order: filenames },
|
||||
{ order: [...filenames].reverse() },
|
||||
{ order: ["manifest.json", "main.js", "styles.css"] },
|
||||
])("retains all per-file documents when reads are released as $order", async ({ order }) => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release(order);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
const documents = await fixture.database.allDocs({ keys: fixture.paths });
|
||||
expect(documents.rows).toHaveLength(3);
|
||||
expect(fixture.configSync.pluginList).toHaveLength(1);
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("applies all three files after the first reload", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
await expect(
|
||||
fixture.configSync.applyDataV2(fixture.configSync.pluginList[0] as PluginDataExDisplayV2)
|
||||
).resolves.toBe(true);
|
||||
expect([...fixture.written.keys()].sort()).toEqual(
|
||||
filenames.map((name) => `.obsidian/plugins/example/${name}`).sort()
|
||||
);
|
||||
for (const name of filenames)
|
||||
expect(fixture.written.get(`.obsidian/plugins/example/${name}`)).toBe(content[name]);
|
||||
});
|
||||
|
||||
it("fills an existing row during a second enumeration", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("retains all files when item updates run sequentially", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a different item while another item's reads are pending", async () => {
|
||||
const fixture = await catalogue();
|
||||
const otherPath = "ix:device/PLUGIN_MAIN/other%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(otherPath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
try {
|
||||
await fixture.configSync.updatePluginListV2(false, otherPath);
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["other"]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(3);
|
||||
} finally {
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
}
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("continues updating an item after an earlier read fails", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockRejectedValueOnce(new Error("Read failed"));
|
||||
const failed = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
const next = fixture.configSync.updatePluginListV2(false, fixture.paths[1]);
|
||||
|
||||
await expect(failed).rejects.toThrow("Read failed");
|
||||
await next;
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/manifest.json",
|
||||
]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["read", "file update"])("reload discards cached files while an earlier %s is pending", async (phase) => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
const earlierRead = deferred();
|
||||
const freshRead = deferred();
|
||||
const entered = deferred();
|
||||
const createFile = fixture.configSync.createPluginDataExFileV2.bind(fixture.configSync);
|
||||
let reads = 0;
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockImplementation(async (path) => {
|
||||
if (reads++ === 0) {
|
||||
if (phase === "read") {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
}
|
||||
} else {
|
||||
await freshRead.promise;
|
||||
}
|
||||
return createFile(path);
|
||||
});
|
||||
if (phase === "file update") {
|
||||
const entry = fixture.configSync.pluginList[0] as PluginDataExDisplayV2;
|
||||
const setFile = entry.setFile.bind(entry);
|
||||
vi.spyOn(entry, "setFile").mockImplementationOnce(async (file) => {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
await setFile(file);
|
||||
});
|
||||
}
|
||||
const pending = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
try {
|
||||
await entered.promise;
|
||||
for (const path of fixture.paths.slice(1)) await fixture.database.remove(await fixture.database.get(path));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
earlierRead.resolve();
|
||||
await pending;
|
||||
} finally {
|
||||
earlierRead.resolve();
|
||||
freshRead.resolve();
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/main.js",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clearing the disabled feature prevents pending updates from repopulating the list", async () => {
|
||||
const fixture = await catalogue();
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
fixture.settings.usePluginSync = false;
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
|
||||
expect(fixture.configSync.pluginList).toEqual([]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("a superseded enumeration cannot reintroduce a deleted item", async () => {
|
||||
const fixture = await catalogue();
|
||||
const obsoletePath = "ix:device/PLUGIN_MAIN/obsolete%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(obsoletePath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const enumerate = fixture.localDatabase.findEntries.bind(fixture.localDatabase);
|
||||
type EnumeratedEntry = ReturnType<typeof enumerate> extends AsyncGenerator<infer Entry> ? Entry : never;
|
||||
const obsoleteDocs: EnumeratedEntry[] = [];
|
||||
for await (const doc of enumerate(obsoletePath, `${obsoletePath}\u{10ffff}`, { include_docs: true })) {
|
||||
obsoleteDocs.push(doc);
|
||||
}
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
let scans = 0;
|
||||
vi.spyOn(fixture.localDatabase, "findEntries").mockImplementation((...args) => {
|
||||
if (scans++ !== 0) return enumerate(...args);
|
||||
return (async function* () {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
yield* obsoleteDocs;
|
||||
})();
|
||||
});
|
||||
const pending = fixture.configSync.updatePluginList(false);
|
||||
try {
|
||||
await entered.promise;
|
||||
await fixture.database.remove(await fixture.database.get(obsoletePath));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
} finally {
|
||||
release.resolve();
|
||||
fixture.release();
|
||||
await pending;
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["example"]);
|
||||
expect(fixture.configSync.pluginList[0].files).toHaveLength(3);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({
|
||||
MARK_DONE: "",
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { ConfigSync } from "./CmdConfigSync";
|
||||
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/snippets/test.css" as FilePath;
|
||||
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function createConfigSync(useV2 = false) {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtures.push(fixture);
|
||||
fixture.settings.usePluginSyncV2 = useV2;
|
||||
const files = new Map<string, { content: string; mtime: number }>([
|
||||
[path, { content: "body { color: red; }", mtime: 2000 }],
|
||||
]);
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
return entry
|
||||
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
|
||||
: null;
|
||||
}),
|
||||
readHiddenFileBinary: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
if (!entry) throw new Error(`Missing fixture: ${file}`);
|
||||
return new TextEncoder().encode(entry.content).buffer;
|
||||
}),
|
||||
};
|
||||
const services = {
|
||||
...fixture.services,
|
||||
appLifecycle: { isReady: () => false, isSuspended: () => true },
|
||||
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
isMarkedAsSameChanges: vi.fn(),
|
||||
markChangesAreSame: vi.fn(),
|
||||
},
|
||||
};
|
||||
const config = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(config, {
|
||||
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
|
||||
_log: vi.fn(),
|
||||
updatePluginList: vi.fn(async () => undefined),
|
||||
updatePluginListV2: vi.fn(async () => undefined),
|
||||
recentProcessedInternalFiles: [],
|
||||
});
|
||||
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
|
||||
return { fixture, config, files, services, storageAccess, databasePath };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtures.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Customisation Sync optional save completion", () => {
|
||||
const flush = { whenFlushRequested: Promise.resolve() };
|
||||
|
||||
it("waits for the database write while the application is suspended or still starting", async () => {
|
||||
const { fixture, config } = await createConfigSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
|
||||
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
|
||||
await gate;
|
||||
return await put(...args);
|
||||
});
|
||||
let completed = false;
|
||||
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
|
||||
completed = true;
|
||||
return outcome;
|
||||
});
|
||||
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
|
||||
expect(completed).toBe(false);
|
||||
release();
|
||||
await expect(result).resolves.toEqual({ kind: "completed" });
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const first = await fixture.database.get(databasePath);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
|
||||
files.get(path)!.content = "body { color: tan; }";
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ useV2: false, throws: false },
|
||||
{ useV2: true, throws: false },
|
||||
{ useV2: false, throws: true },
|
||||
{ useV2: true, throws: true },
|
||||
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
|
||||
const { fixture, config, databasePath } = await createConfigSync(useV2);
|
||||
const failure = new Error("temporary write failure");
|
||||
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
if (throws) put.mockRejectedValueOnce(failure);
|
||||
else put.mockResolvedValueOnce(false);
|
||||
const failed = config._processOptionalFileEventWithResult(path, flush);
|
||||
if (throws) await expect(failed).rejects.toBe(failure);
|
||||
else await expect(failed).rejects.toThrow("Could not save customisation");
|
||||
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
files.delete(path);
|
||||
const put = vi.spyOn(fixture.localDatabase, "putRaw");
|
||||
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
|
||||
"Could not delete customisation"
|
||||
);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const deleted = await fixture.database.get(databasePath);
|
||||
expect("deleted" in deleted && deleted.deleted).toBe(true);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
|
||||
});
|
||||
|
||||
it("distinguishes a different owner and unmet prerequisites", async () => {
|
||||
const { config, services, storageAccess } = await createConfigSync();
|
||||
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "database-not-ready",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(true);
|
||||
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "device-name-not-configured",
|
||||
});
|
||||
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
|
||||
const { fixture, config, files } = await createConfigSync(true);
|
||||
const main = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const style = ".obsidian/plugins/example/styles.css" as FilePath;
|
||||
files.set(main, { content: "module.exports = {};", mtime: 3000 });
|
||||
files.set(style, { content: "body {}", mtime: 3000 });
|
||||
await Promise.all([
|
||||
config._processOptionalFileEventWithResult(main, flush),
|
||||
config._processOptionalFileEventWithResult(style, flush),
|
||||
]);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
|
||||
});
|
||||
|
||||
it("does not acknowledge a database deletion which was not accepted", async () => {
|
||||
const { fixture, config, databasePath } = await createConfigSync();
|
||||
await config.storeCustomizationFiles(path);
|
||||
const original = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(original).not.toBe(false);
|
||||
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
|
||||
@@ -412,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
|
||||
|
||||
pluginList: IPluginDataExDisplay[] = [];
|
||||
private pluginListGeneration = 0;
|
||||
showPluginSyncModal() {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
return;
|
||||
@@ -532,6 +537,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async reloadPluginList(showMessage: boolean) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginList = [];
|
||||
this.loadedManifest_mTime.clear();
|
||||
pluginList.set(this.pluginList);
|
||||
@@ -761,39 +767,46 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
updatingV2Count = 0;
|
||||
|
||||
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||
const generation = this.pluginListGeneration;
|
||||
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
|
||||
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
|
||||
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||
await serialized(`config:catalogue:${pathV1}`, async () => {
|
||||
if (!isCurrent()) return;
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||
|
||||
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
||||
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
||||
if (newEntry) {
|
||||
entry = newEntry;
|
||||
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
||||
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
||||
if (newEntry) {
|
||||
entry = newEntry;
|
||||
}
|
||||
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
||||
entry = oldEntry;
|
||||
}
|
||||
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
||||
entry = oldEntry;
|
||||
}
|
||||
if (!entry) return;
|
||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||
if (file) {
|
||||
await entry.setFile(file);
|
||||
} else {
|
||||
entry.deleteFile(unifiedFilenameWithKey);
|
||||
if (entry.files.length == 0) {
|
||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||
if (!entry) return;
|
||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||
if (!isCurrent()) return;
|
||||
if (file) {
|
||||
await entry.setFile(file);
|
||||
} else {
|
||||
entry.deleteFile(unifiedFilenameWithKey);
|
||||
if (entry.files.length == 0) {
|
||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||
newList.push(entry);
|
||||
this.pluginList = newList;
|
||||
if (!isCurrent()) return;
|
||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||
newList.push(entry);
|
||||
this.pluginList = newList;
|
||||
|
||||
scheduleTask("updatePluginListV2", 100, () => {
|
||||
pluginList.set(this.pluginList);
|
||||
scheduleTask("updatePluginListV2", 100, () => {
|
||||
pluginList.set(this.pluginList);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
this.updatingV2Count--;
|
||||
@@ -871,11 +884,13 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginScanProcessor.clearQueue();
|
||||
this.pluginList = [];
|
||||
pluginList.set(this.pluginList);
|
||||
return;
|
||||
}
|
||||
const generation = this.pluginListGeneration;
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
@@ -888,6 +903,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
})
|
||||
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||
for await (const v of plugins) {
|
||||
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
|
||||
if (v.deleted || v._deleted) continue;
|
||||
if (v.path.indexOf("%") !== -1) {
|
||||
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
||||
@@ -1206,7 +1222,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
recentProcessedInternalFiles = [] as string[];
|
||||
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
|
||||
async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
let version: string | undefined;
|
||||
let displayName: string | undefined;
|
||||
@@ -1240,6 +1256,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
} catch (ex) {
|
||||
this._log(`The file ${path} could not be encoded`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
@@ -1254,6 +1271,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
|
||||
return await this.storeCustomisationFileV2Core(path, term, force, false);
|
||||
}
|
||||
|
||||
private async storeCustomisationFileV2Core(
|
||||
path: FilePath,
|
||||
term: string,
|
||||
force: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const vf = this.filenameWithUnifiedKey(path, term);
|
||||
return await serialized(`plugin-${vf}`, async () => {
|
||||
const prefixedFileName = vf;
|
||||
@@ -1261,7 +1287,8 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const id = await this.path2id(prefixedFileName);
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (!stat) {
|
||||
return false;
|
||||
if (!requireCompletion) return false;
|
||||
return await this.deleteConfigOnDatabaseCore(vf, force, true);
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
const content = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
@@ -1285,7 +1312,10 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
|
||||
if (
|
||||
!requireCompletion &&
|
||||
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
|
||||
) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
|
||||
LOG_LEVEL_DEBUG
|
||||
@@ -1300,7 +1330,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const dataStart = dataSrc.indexOf(DUMMY_END);
|
||||
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
|
||||
const oldContentArray = base64ToArrayBuffer(oldContent);
|
||||
if (await isDocContentSame(oldContentArray, content)) {
|
||||
if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1320,24 +1350,36 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
|
||||
return await this.storeCustomizationFilesCore(path, termOverRide, false);
|
||||
}
|
||||
|
||||
private async storeCustomizationFilesCore(
|
||||
path: FilePath,
|
||||
termOverRide: string | undefined,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
||||
if (term == "") {
|
||||
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
|
||||
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
|
||||
return;
|
||||
}
|
||||
if (this.useV2) {
|
||||
return await this.storeCustomisationFileV2(path, term);
|
||||
return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
|
||||
}
|
||||
const vf = this.filenameToUnifiedKey(path, term);
|
||||
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
|
||||
@@ -1378,7 +1420,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
|
||||
}
|
||||
for (const target of fileTargets) {
|
||||
const data = await this.makeEntryFromFile(target);
|
||||
const data = await this.makeEntryFromFile(target, requireCompletion);
|
||||
if (data == false) {
|
||||
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
@@ -1398,9 +1440,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
// this._log(`Configuration saving: ${prefixedFileName}`);
|
||||
if (dt.files.length == 0) {
|
||||
this._log(`Nothing left: deleting.. ${path}`);
|
||||
await this.deleteConfigOnDatabase(prefixedFileName);
|
||||
const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
return;
|
||||
return requireCompletion ? deleted : undefined;
|
||||
}
|
||||
|
||||
const content = createTextBlob(serialize(dt));
|
||||
@@ -1422,7 +1464,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (old.mtime == mtime) {
|
||||
if (!requireCompletion && old.mtime == mtime) {
|
||||
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
@@ -1443,7 +1485,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
});
|
||||
const isSame = (await Promise.all(diffs)).every((e) => e == true);
|
||||
if (isSame) {
|
||||
if (isSame && !old.deleted) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1464,16 +1506,51 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
await this.updatePluginList(false, saveData.path);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
private pendingOptionalSaves?: PendingConfigSaves;
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
|
||||
const configDir = normalizePath(this.configDir);
|
||||
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
|
||||
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
|
||||
.flatMap((entry) => entry.files)
|
||||
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
|
||||
if (handledElsewhere) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
|
||||
const term = this.services.setting.getDeviceAndVaultName();
|
||||
if (!term) return { kind: "veto", value: "device-name-not-configured" };
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat && stat.type !== "file") return { kind: "not-applicable" };
|
||||
|
||||
// V1 saves a complete file group; V2 persists each file separately.
|
||||
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
|
||||
this.pendingOptionalSaves ??= new PendingConfigSaves();
|
||||
await this.pendingOptionalSaves.enqueue(
|
||||
key,
|
||||
async () => {
|
||||
await this.storeCustomizationFilesCore(path, term, true);
|
||||
},
|
||||
options?.whenFlushRequested
|
||||
);
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
|
||||
return await this.watchVaultRawEventsAsync(path);
|
||||
}
|
||||
@@ -1615,6 +1692,14 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
|
||||
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteConfigOnDatabaseCore(
|
||||
prefixedFileName: FilePathWithPrefix,
|
||||
forceWrite: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
// const id = await this.path2id(prefixedFileName);
|
||||
const mtime = new Date().getTime();
|
||||
return await serialized("file-x-" + prefixedFileName, async () => {
|
||||
@@ -1640,13 +1725,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
type: "newnote",
|
||||
};
|
||||
}
|
||||
await this.localDatabase.putRaw(saveData);
|
||||
const result = await this.localDatabase.putRaw(saveData);
|
||||
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1756,6 +1843,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type PendingSave = {
|
||||
save: () => Promise<void>;
|
||||
completion: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
timer?: CompatTimeoutHandle;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
|
||||
export class PendingConfigSaves {
|
||||
private readonly pending = new Map<string, PendingSave>();
|
||||
|
||||
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
|
||||
let group = this.pending.get(key);
|
||||
if (!group) {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const completion = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
group = { save, completion, resolve, reject, started: false };
|
||||
this.pending.set(key, group);
|
||||
} else {
|
||||
group.save = save;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
}
|
||||
const captured = group;
|
||||
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
|
||||
// Capture this group: a late flush of an earlier event must not release
|
||||
// a newer group which happens to use the same customisation key.
|
||||
void whenFlushRequested?.then(
|
||||
() => this.run(key, captured),
|
||||
() => {}
|
||||
);
|
||||
return group.completion;
|
||||
}
|
||||
|
||||
private run(key: string, group: PendingSave): void {
|
||||
if (group.started) return;
|
||||
group.started = true;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
this.pending.delete(key);
|
||||
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("pending customisation saves", () => {
|
||||
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const save = deferred();
|
||||
const firstSave = vi.fn(async () => {});
|
||||
const lastSave = vi.fn(() => save.promise);
|
||||
const first = pending.enqueue("plugin", firstSave);
|
||||
await vi.advanceTimersByTimeAsync(80);
|
||||
const second = pending.enqueue("plugin", lastSave);
|
||||
let completed = false;
|
||||
const all = Promise.all([first, second]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(lastSave).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(firstSave).not.toHaveBeenCalled();
|
||||
expect(lastSave).toHaveBeenCalledOnce();
|
||||
expect(completed).toBe(false);
|
||||
save.resolve();
|
||||
await all;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes the captured group without waiting for or releasing later notifications", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const flush = deferred();
|
||||
const lateFlush = deferred();
|
||||
const firstSave = deferred();
|
||||
const work = vi.fn(() => firstSave.promise);
|
||||
const first = pending.enqueue("plugin", work, flush.promise);
|
||||
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
|
||||
flush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(work).toHaveBeenCalledOnce();
|
||||
const nextWork = vi.fn(async () => {});
|
||||
const later = pending.enqueue("plugin", nextWork);
|
||||
lateFlush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
firstSave.resolve();
|
||||
await Promise.all([first, coalesced]);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await later;
|
||||
expect(nextWork).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const failure = new Error("database write failed");
|
||||
const save = async () => {
|
||||
throw failure;
|
||||
};
|
||||
const first = pending.enqueue("plugin", save);
|
||||
const second = pending.enqueue("plugin", save);
|
||||
const outcomes = Promise.allSettled([first, second]);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await outcomes).toEqual([
|
||||
{ status: "rejected", reason: failure },
|
||||
{ status: "rejected", reason: failure },
|
||||
]);
|
||||
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
|
||||
await retry;
|
||||
});
|
||||
});
|
||||
@@ -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,260 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
|
||||
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, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const content = "module.exports = class ExamplePlugin {};";
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function getText(entry: Parameters<typeof readContent>[0]) {
|
||||
const value = await readContent(entry);
|
||||
return typeof value === "string" ? value : new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
async function createHiddenFileSync() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.usePluginSync = false;
|
||||
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
|
||||
let fileContent = content;
|
||||
let stat: UXStat | null = {
|
||||
ctime: 1_000,
|
||||
mtime: 2_000,
|
||||
size: fileContent.length,
|
||||
type: "file",
|
||||
};
|
||||
const values = new Map<string, unknown>();
|
||||
const kvDB = {
|
||||
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
|
||||
set: vi.fn(async <T>(key: string, value: T) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
};
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async () => stat),
|
||||
readHiddenFileAuto: vi.fn(async () => fileContent),
|
||||
writeHiddenFileAuto: vi.fn(async () => undefined),
|
||||
removeHidden: vi.fn(async () => true),
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
ensureDir: vi.fn(async () => undefined),
|
||||
triggerHiddenFile: vi.fn(async () => undefined),
|
||||
setFile(nextContent: string | null, mtime = 2_000) {
|
||||
fileContent = nextContent ?? "";
|
||||
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
|
||||
},
|
||||
};
|
||||
const databaseService = { isDatabaseReady: vi.fn(() => true) };
|
||||
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: fixture.settings,
|
||||
localDatabase: fixture.localDatabase,
|
||||
kvDB,
|
||||
storageAccess,
|
||||
services: {
|
||||
...fixture.services,
|
||||
database: databaseService,
|
||||
appLifecycle,
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
markChangesAreSame: vi.fn(),
|
||||
unmarkChanges: vi.fn(),
|
||||
},
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
},
|
||||
cacheFileRegExps: new Map(),
|
||||
cacheCustomisationSyncIgnoredFiles: new Map(),
|
||||
_fileInfoLastProcessed: new Map(),
|
||||
_databaseInfoLastProcessed: new Map(),
|
||||
_fileInfoLastKnown: new Map(),
|
||||
semaphore: Semaphore(10),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Hidden File Sync optional save outcomes", () => {
|
||||
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
|
||||
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
|
||||
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
|
||||
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary database failure"
|
||||
);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
if (saved) expect(await getText(saved)).toBe(content);
|
||||
expect(appLifecycle.isReady()).toBe(false);
|
||||
expect(appLifecycle.isSuspended()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockImplementationOnce(async () => false as never);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
`Failed to store hidden file in the database: ${path}`
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(first).not.toBe(false);
|
||||
if (first) {
|
||||
const firstRevision = first._rev;
|
||||
hiddenFileSync._fileInfoLastProcessed.delete(path);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(unchanged).not.toBe(false);
|
||||
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
|
||||
}
|
||||
|
||||
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(updated).not.toBe(false);
|
||||
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
|
||||
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
|
||||
|
||||
databaseService.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
|
||||
databaseService.isDatabaseReady.mockReturnValue(true);
|
||||
|
||||
fixture.settings.syncInternalFiles = false;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
fixture.settings.syncInternalFiles = true;
|
||||
fixture.settings.syncInternalFilesTargetPatterns =
|
||||
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
|
||||
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
|
||||
storageAccess.setFile(null);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
const lifecycle = hiddenFileSync as unknown as {
|
||||
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
|
||||
};
|
||||
hiddenFileSync.core.settings.syncInternalFiles = false;
|
||||
await lifecycle._everyOnDatabaseInitialized(false);
|
||||
expect(kvDB.get).toHaveBeenCalledTimes(3);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
});
|
||||
|
||||
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
|
||||
storageAccess.setFile(null);
|
||||
const putRaw = vi
|
||||
.spyOn(fixture.localDatabase, "putRaw")
|
||||
.mockRejectedValueOnce(new Error("temporary deletion failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary deletion failure"
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
hiddenFileSync.resetLastProcessedFile([path]);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
|
||||
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
|
||||
const deletedBase = await fixture.localDatabase.getDBEntry(
|
||||
`i:${path}` as FilePath,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
);
|
||||
expect(deletedBase).not.toBe(false);
|
||||
if (deletedBase) expect(deletedBase.deleted).toBe(true);
|
||||
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
|
||||
|
||||
storageAccess.setFile("", 4_000);
|
||||
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(1);
|
||||
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(recreated).not.toBe(false);
|
||||
if (recreated) {
|
||||
expect(recreated.deleted).not.toBe(true);
|
||||
expect(await getText(recreated)).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,8 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
@@ -69,6 +71,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
|
||||
@@ -161,9 +166,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
// return true;
|
||||
// }
|
||||
private async _everyOnDatabaseInitialized(showNotice: boolean) {
|
||||
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed");
|
||||
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
|
||||
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
|
||||
await this.ensureProcessedCaches();
|
||||
if (this.isThisModuleEnabled()) {
|
||||
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
|
||||
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
|
||||
@@ -249,6 +252,26 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
return false;
|
||||
}
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
_options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
|
||||
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) {
|
||||
return { kind: "veto", value: "The local database is not ready" };
|
||||
}
|
||||
|
||||
await this.ensureProcessedCaches();
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
if (!(await this.trackStorageFileModificationStrict(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
|
||||
if (isInternalMetadata(path)) {
|
||||
this.queueConflictCheck(path);
|
||||
@@ -317,6 +340,51 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
_fileInfoLastKnown!: MapLike<string, number>;
|
||||
_databaseInfoLastProcessed!: MapLike<string, string>;
|
||||
|
||||
private processedCacheStore: unknown;
|
||||
private processedCacheStoreReady = false;
|
||||
private processedCacheLoadStore: unknown;
|
||||
private processedCacheLoad?: Promise<void>;
|
||||
|
||||
private async ensureProcessedCaches() {
|
||||
const store = this.kvDB;
|
||||
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
|
||||
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
|
||||
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
|
||||
await this.processedCacheLoad;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let load!: Promise<void>;
|
||||
load = Promise.all([
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
|
||||
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
|
||||
])
|
||||
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
|
||||
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
|
||||
this._fileInfoLastProcessed = fileInfoLastProcessed;
|
||||
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
|
||||
this._fileInfoLastKnown = fileInfoLastKnown;
|
||||
this.processedCacheStore = store;
|
||||
this.processedCacheStoreReady = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.processedCacheLoad === load) {
|
||||
this.processedCacheLoad = undefined;
|
||||
this.processedCacheLoadStore = undefined;
|
||||
}
|
||||
});
|
||||
this.processedCacheLoadStore = store;
|
||||
this.processedCacheLoad = load;
|
||||
await load;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
}
|
||||
|
||||
statToKey(stat: UXStat | null) {
|
||||
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
|
||||
}
|
||||
@@ -600,7 +668,6 @@ Offline Changed files: ${processFiles.length}`;
|
||||
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
|
||||
stat = cache.stat;
|
||||
}
|
||||
this.updateLastProcessedFile(path, stat!);
|
||||
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
|
||||
const nowIsNotFound = cache.deleted;
|
||||
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
|
||||
@@ -651,6 +718,27 @@ Offline Changed files: ${processFiles.length}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
|
||||
return await this.serializedForEvent(path, async () => {
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat != null && stat.type != "file") return true;
|
||||
const key = await this.fileToStatKey(path, stat);
|
||||
if (this.getLastProcessedFileKey(path) === key) {
|
||||
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
|
||||
const fileInfo = await this.loadFileWithInfo(path);
|
||||
if (fileInfo.deleted) {
|
||||
await this.deleteInternalFileOnDatabaseStrict(path);
|
||||
return true;
|
||||
}
|
||||
await this.storeInternalFileToDatabaseStrict(fileInfo);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --> Conflict processing
|
||||
|
||||
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
|
||||
@@ -1444,6 +1532,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,12 +1587,25 @@ Offline Changed files: ${files.length}`;
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
// <-- Initialization functions
|
||||
|
||||
// --> Storage To Database Functions
|
||||
|
||||
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, false);
|
||||
}
|
||||
|
||||
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, true);
|
||||
}
|
||||
|
||||
private async loadBaseSaveData(
|
||||
file: FilePath,
|
||||
includeContent: boolean,
|
||||
strict: boolean
|
||||
): Promise<LoadedEntry | false> {
|
||||
const prefixedFileName = addPrefix(file, ICHeader);
|
||||
const id = await this.path2id(prefixedFileName, ICHeader);
|
||||
try {
|
||||
@@ -1525,6 +1631,7 @@ Offline Changed files: ${files.length}`;
|
||||
return old;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (strict) throw ex;
|
||||
this._log(`Getting base save data failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
@@ -1552,6 +1659,18 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseCore(
|
||||
file: InternalFileInfo | UXFileInfo,
|
||||
forceWrite: boolean,
|
||||
strict: boolean
|
||||
) {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
@@ -1565,9 +1684,11 @@ Offline Changed files: ${files.length}`;
|
||||
if (fileInfo.deleted) {
|
||||
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
|
||||
}
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, true);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, true)
|
||||
: await this.__loadBaseSaveData(storeFilePath, true);
|
||||
if (baseData === false) throw new Error("Failed to load base data");
|
||||
if (baseData._rev && !forceWrite) {
|
||||
if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
|
||||
// Not newly created, we should check the content is actually modified.
|
||||
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
|
||||
if (isSame) {
|
||||
@@ -1592,11 +1713,15 @@ Offline Changed files: ${files.length}`;
|
||||
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
|
||||
}
|
||||
const success = ret && ret.ok;
|
||||
if (!success && strict) {
|
||||
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
|
||||
}
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
|
||||
return success;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1676,6 +1801,14 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
|
||||
const storeFilePath = filenameSrc;
|
||||
const storageFilePath = filenameSrc;
|
||||
const displayFileName = filenameSrc;
|
||||
@@ -1686,18 +1819,26 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, false);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, false)
|
||||
: await this.__loadBaseSaveData(storeFilePath, false);
|
||||
if (baseData === false) throw new Error("Failed to load base data during deleting");
|
||||
if (baseData._conflicts !== undefined) {
|
||||
for (const conflictRev of baseData._conflicts) {
|
||||
await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
if (strict && removed === false) {
|
||||
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
|
||||
}
|
||||
this._log(
|
||||
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
}
|
||||
if (baseData.deleted) {
|
||||
const alreadyDeleted = strict
|
||||
? !baseData._rev || baseData.deleted || baseData._deleted
|
||||
: baseData.deleted;
|
||||
if (alreadyDeleted) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
|
||||
this.updateLastProcessedDeletion(storeFilePath, baseData);
|
||||
return true;
|
||||
@@ -1718,11 +1859,13 @@ Offline Changed files: ${files.length}`;
|
||||
return true;
|
||||
} else {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1925,6 +2068,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 +2098,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 +2106,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;
|
||||
@@ -2084,6 +2228,9 @@ Offline Changed files: ${files.length}`;
|
||||
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -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);
|
||||
|
||||
+10
-1
@@ -1,4 +1,5 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
|
||||
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
setGetLanguage(getLanguage);
|
||||
@@ -53,6 +54,7 @@ import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianR
|
||||
import { useSyncGuardFeature } from "./serviceFeatures/syncGuard";
|
||||
import { SyncGuardOverlay } from "./serviceFeatures/syncGuard/SyncGuardOverlay";
|
||||
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
|
||||
import { useJoinedDevicesFeature } from "./serviceFeatures/useJoinedDevices";
|
||||
import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
@@ -200,6 +202,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
useObsidianReplicationRibbonFeature(core);
|
||||
useSyncGuardFeature(core, (onBypass) => new SyncGuardOverlay(this.app.workspace.containerEl, onBypass));
|
||||
useDatabaseDiagnosticsFeature(core);
|
||||
useJoinedDevicesFeature(core, serviceHub.context.noticeGroups);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -234,6 +237,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
void this._startUp();
|
||||
}
|
||||
override onunload(): void {
|
||||
return void this.core.services.control.onUnload();
|
||||
void this.core.services.control.onUnload().catch((error) => {
|
||||
this.core.services.API.addLog(
|
||||
"Failed to unload Self-hosted LiveSync: " + String(error),
|
||||
LOG_LEVEL_URGENT,
|
||||
"shutdown"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
this.isLastHidden = isHidden;
|
||||
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
|
||||
if (!pendingFileEventsCommitted) {
|
||||
this._log(
|
||||
"Pending file events remain because their commit did not complete before the window visibility changed.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
|
||||
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both
|
||||
@@ -287,7 +293,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
if (this.settings.suspendFileWatching) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
if (!this.services.appLifecycle.isReady()) return;
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
|
||||
if (!pendingFileEventsCommitted) {
|
||||
return;
|
||||
}
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
|
||||
type SetupOptions = {
|
||||
settings?: Partial<typeof DEFAULT_SETTINGS>;
|
||||
@@ -23,6 +24,8 @@ function setup(opts: SetupOptions) {
|
||||
onResumed: vi.fn(async () => true),
|
||||
};
|
||||
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
|
||||
const replication = { replicateUnattendedByEvent: vi.fn(async () => undefined) };
|
||||
const conflict = { queueCheckForIfOpen: vi.fn(async () => undefined) };
|
||||
const boundedRemoteActivityCount = reactiveSource(0);
|
||||
const boundedLocalApplicationActivityCount = reactiveSource(0);
|
||||
|
||||
@@ -39,6 +42,8 @@ function setup(opts: SetupOptions) {
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replication,
|
||||
conflict,
|
||||
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
|
||||
},
|
||||
settings: {
|
||||
@@ -61,6 +66,9 @@ function setup(opts: SetupOptions) {
|
||||
module,
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replication,
|
||||
conflict,
|
||||
api: core._services.API,
|
||||
boundedRemoteActivityCount,
|
||||
boundedLocalApplicationActivityCount,
|
||||
};
|
||||
@@ -303,6 +311,23 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs a failed pending-file commit and still suspends on hide", async () => {
|
||||
const { module, appLifecycle, fileProcessing, api } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: true },
|
||||
hidden: true,
|
||||
});
|
||||
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
expect(api.addLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Pending file events remain"),
|
||||
LOG_LEVEL_NOTICE,
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply on mobile even if the flag is set", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
@@ -313,3 +338,20 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchWorkspaceOpenAsync — pending file event commit", () => {
|
||||
it("skips replication and conflict checks when pending file events fail to commit", async () => {
|
||||
const { module, fileProcessing, replication, conflict } = setup({
|
||||
settings: { syncOnFileOpen: true },
|
||||
hidden: false,
|
||||
});
|
||||
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
|
||||
|
||||
await module.watchWorkspaceOpenAsync({ path: "note.md" } as Parameters<
|
||||
typeof module.watchWorkspaceOpenAsync
|
||||
>[0]);
|
||||
|
||||
expect(replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(conflict.queueCheckForIfOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
PREFIXMD_LOGFILE,
|
||||
type DatabaseConnectingStatus,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
@@ -31,16 +30,14 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import {
|
||||
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts";
|
||||
import {
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
import { createFastForwardCheckedChangesDisplay, projectReplicationStatusLabel } from "./ReplicationStatusLabel.ts";
|
||||
import { createStatusBarRenderer, type StatusBarPart, type StatusBarRenderer } from "./StatusBarRenderer.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
@@ -124,7 +121,14 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
logHistory?: HTMLDivElement;
|
||||
messageArea?: HTMLDivElement;
|
||||
|
||||
statusBarLabels!: ReactiveValue<{ message: string; status: string }>;
|
||||
statusBarRenderer?: StatusBarRenderer;
|
||||
statusLineRenderer?: StatusBarRenderer;
|
||||
statusBarLabels!: ReactiveValue<{
|
||||
message: string;
|
||||
status: string;
|
||||
parts: readonly StatusBarPart[];
|
||||
statusBarParts: readonly StatusBarPart[];
|
||||
}>;
|
||||
statusLog = reactiveSource("");
|
||||
activeFileStatus = reactiveSource("");
|
||||
notifies: { [key: string]: { count: number } } = {};
|
||||
@@ -164,79 +168,54 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
);
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
return formatRemoteActivityStatusLabel({
|
||||
return {
|
||||
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
|
||||
trackedRequestCount: displayedTrackedRequestCount.value,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
const e = this.services.replicator.replicationStatics.value;
|
||||
const sent = e.sent;
|
||||
const arrived = e.arrived;
|
||||
const maxPullSeq = e.maxPullSeq;
|
||||
const maxPushSeq = e.maxPushSeq;
|
||||
const lastSyncPullSeq = e.lastSyncPullSeq;
|
||||
const lastSyncPushSeq = e.lastSyncPushSeq;
|
||||
let pushLast = "";
|
||||
let pullLast = "";
|
||||
let w = "";
|
||||
const labels: Partial<Record<DatabaseConnectingStatus, string>> = {
|
||||
CONNECTED: "⚡",
|
||||
JOURNAL_SEND: "📦↑",
|
||||
JOURNAL_RECEIVE: "📦↓",
|
||||
};
|
||||
switch (e.syncStatus) {
|
||||
case "CLOSED":
|
||||
case "COMPLETED":
|
||||
case "NOT_CONNECTED":
|
||||
w = "⏹";
|
||||
break;
|
||||
case "STARTED":
|
||||
w = "🌀";
|
||||
break;
|
||||
case "PAUSED":
|
||||
w = "💤";
|
||||
break;
|
||||
case "CONNECTED":
|
||||
case "JOURNAL_SEND":
|
||||
case "JOURNAL_RECEIVE":
|
||||
w = labels[e.syncStatus] || "⚡";
|
||||
pushLast =
|
||||
lastSyncPushSeq == 0
|
||||
? ""
|
||||
: lastSyncPushSeq >= maxPushSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPushSeq - lastSyncPushSeq})`;
|
||||
pullLast =
|
||||
lastSyncPullSeq == 0
|
||||
? ""
|
||||
: lastSyncPullSeq >= maxPullSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPullSeq - lastSyncPullSeq})`;
|
||||
break;
|
||||
case "ERRORED":
|
||||
w = "⚠";
|
||||
break;
|
||||
default:
|
||||
w = "?";
|
||||
}
|
||||
return { w, sent, pushLast, arrived, pullLast };
|
||||
});
|
||||
const fastForwardCount = registerDisplay(
|
||||
createFastForwardCheckedChangesDisplay(this.services.replicator.replicationStatics)
|
||||
);
|
||||
const replicationStatLabel = computed(() =>
|
||||
projectReplicationStatusLabel(
|
||||
this.services.replicator.replicationStatics.value,
|
||||
requestingStatLabel(),
|
||||
fastForwardCount.value
|
||||
)
|
||||
);
|
||||
const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `⏳`));
|
||||
const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
|
||||
const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
|
||||
const waitingLabel = computed(() => {
|
||||
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
|
||||
});
|
||||
const counterPart = (key: string, mark: string, value: number | undefined): StatusBarPart[] =>
|
||||
value === undefined ? [] : [{ key, text: " " + mark, value }];
|
||||
const statusLineLabel = computed(() => {
|
||||
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
|
||||
const replication = replicationStatLabel();
|
||||
const queued = queueCountLabel();
|
||||
const waiting = waitingLabel();
|
||||
const networkActivity = requestingStatLabel();
|
||||
const p2p = this.p2pLogCollector.p2pReplicationLine.value;
|
||||
const firstLineParts = [
|
||||
...replication.parts,
|
||||
...counterPart("file-processing", "⏳", labelProc.count.value),
|
||||
...counterPart("file-pending", "🛫", labelPend.count.value),
|
||||
...counterPart("file-batched", "📬", labelInBatchDelay.count.value),
|
||||
...counterPart("queue-replication", "📥", labelReplication.count.value),
|
||||
...counterPart("queue-database", "📄", labelDBCount.count.value),
|
||||
...counterPart("queue-storage", "💾", labelStorageCount.count.value),
|
||||
...counterPart("queue-chunk-initial", "🛄", labelChunkCount.initialCount.value),
|
||||
...counterPart("queue-chunk-retrying", "🔁", labelChunkCount.retryingCount.value),
|
||||
...counterPart("queue-plugin-scan", "🔌", labelPluginScanCount.count.value),
|
||||
...counterPart("queue-hidden-files", "⚙️", labelHiddenFilesCount.count.value),
|
||||
...counterPart("queue-conflicts", "🔩", labelConflictProcessCount.count.value),
|
||||
];
|
||||
return {
|
||||
message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`,
|
||||
message: replication.message + waiting + queued + (p2p === "" ? "" : "\n" + p2p),
|
||||
parts: p2p === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" + p2p }],
|
||||
statusBarParts: firstLineParts,
|
||||
hasP2P: p2p !== "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -244,13 +223,16 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const scheduleMessage = this.services.appLifecycle.isReloadingScheduled()
|
||||
? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n`
|
||||
: "";
|
||||
const { message } = statusLineLabel();
|
||||
const { message, parts, statusBarParts, hasP2P } = statusLineLabel();
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
const status = scheduleMessage + this.statusLog.value;
|
||||
const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`;
|
||||
const skipPart = fileStatusIcon === "" ? [] : [{ key: "file-skip", text: fileStatusIcon }];
|
||||
return {
|
||||
message: `${message}${fileStatusIcon}`,
|
||||
status,
|
||||
parts: [...parts, ...skipPart],
|
||||
statusBarParts: hasP2P ? statusBarParts : [...statusBarParts, ...skipPart],
|
||||
};
|
||||
});
|
||||
this.statusBarLabels = statusBarLabels;
|
||||
@@ -391,7 +373,8 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1);
|
||||
}
|
||||
|
||||
this.statusBar?.setText(newMsg.split("\n")[0]);
|
||||
const { parts, statusBarParts } = this.statusBarLabels.value;
|
||||
this.statusBarRenderer?.render(statusBarParts);
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.setCssStyles({ display: this.settings?.showStatusOnEditor ? "" : "none" });
|
||||
}
|
||||
@@ -409,7 +392,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const recentLogs = recent.reverse().join("\n");
|
||||
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
|
||||
}
|
||||
if (isDirty("newMsg", newMsg)) this.statusLine!.innerText = newMsg;
|
||||
if (isDirty("newMsg", newMsg)) this.statusLineRenderer?.render(parts);
|
||||
if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog;
|
||||
} else {
|
||||
// const root = activeDocument.documentElement;
|
||||
@@ -505,6 +488,16 @@ ${stringifyYaml(info)}
|
||||
this.statusBar = this.services.API.addStatusBarItem();
|
||||
this.statusBar?.addClass("syncstatusbar");
|
||||
}
|
||||
if (this.statusLine) {
|
||||
const renderer = createStatusBarRenderer(this.statusLine);
|
||||
this.statusLineRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
}
|
||||
if (this.statusBar) {
|
||||
const renderer = createStatusBarRenderer(this.statusBar);
|
||||
this.statusBarRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
}
|
||||
this.adjustStatusDivPosition();
|
||||
this._log("Log module loaded", LOG_LEVEL_INFO);
|
||||
this._log("Verbose log", LOG_LEVEL_VERBOSE);
|
||||
|
||||
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
settingTab!: ObsidianLiveSyncSettingTab;
|
||||
|
||||
_everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
async _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
||||
this.settingTab.reloadAllSettings(true);
|
||||
await this.settingTab.loadSettings();
|
||||
this.plugin.addSettingTab(this.settingTab);
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
||||
|
||||
return Promise.resolve(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
openSetting() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const settingTabState = vi.hoisted(() => ({
|
||||
callOrder: [] as string[],
|
||||
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
|
||||
loadSettings: vi.fn<() => Promise<void>>(),
|
||||
}));
|
||||
|
||||
const eventHubState = vi.hoisted(() => ({
|
||||
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
|
||||
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
|
||||
reloadAllSettings(skipUpdate?: boolean) {
|
||||
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
|
||||
settingTabState.reloadAllSettings(skipUpdate);
|
||||
async loadSettings() {
|
||||
await settingTabState.loadSettings();
|
||||
settingTabState.callOrder.push("settings-loaded");
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -65,7 +65,7 @@ function createModuleHarness() {
|
||||
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
settingTabState.callOrder.length = 0;
|
||||
settingTabState.reloadAllSettings.mockClear();
|
||||
settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
|
||||
eventHubState.onEvent.mockClear();
|
||||
});
|
||||
|
||||
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
});
|
||||
|
||||
it("seeds the setting editor without requesting a render before registration", async () => {
|
||||
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
|
||||
const { initialisationHandler, settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
let finishRead!: () => void;
|
||||
settingTabState.loadSettings.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishRead = resolve;
|
||||
})
|
||||
);
|
||||
const handler = settingsLoadedHandler() ?? initialisationHandler();
|
||||
|
||||
expect(handler).toBeTypeOf("function");
|
||||
await handler!();
|
||||
const loading = handler!();
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
finishRead();
|
||||
await loading;
|
||||
|
||||
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
|
||||
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
|
||||
expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
|
||||
expect(settingTabState.callOrder).toEqual(["settings-loaded", "add-setting-tab"]);
|
||||
});
|
||||
it("does not register an editor when reading device values fails", async () => {
|
||||
const { settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
const failure = new Error("device storage unavailable");
|
||||
settingTabState.loadSettings.mockRejectedValueOnce(failure);
|
||||
await expect(settingsLoadedHandler()!()).rejects.toBe(failure);
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
import type { DisposableReactiveValue } from "./StatusBarDisplay.ts";
|
||||
import type { StatusBarPart } from "./StatusBarRenderer.ts";
|
||||
import {
|
||||
formatRemoteActivityStatusLabel,
|
||||
REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
type RemoteActivityStatus,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
|
||||
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
|
||||
|
||||
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined {
|
||||
const count = source.value.couchDB?.pull?.checkedChanges;
|
||||
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Briefly displays changing CouchDB pull-feed comparison counts.
|
||||
*
|
||||
* Unrelated replication-stat changes do not extend the linger or redisplay an
|
||||
* expired count. A positive initial snapshot is displayed to late subscribers.
|
||||
*/
|
||||
export function createFastForwardCheckedChangesDisplay(
|
||||
source: ReactiveValue<ReplicationStatics>,
|
||||
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
|
||||
): DisposableReactiveValue<number | undefined> {
|
||||
const displayed = reactiveSource<number | undefined>(undefined);
|
||||
const linger = Math.max(0, lingerMs);
|
||||
let lastObserved = readPositiveCheckedChanges(source);
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(clearTimer);
|
||||
clearTimer = undefined;
|
||||
};
|
||||
const scheduleClear = () => {
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
if (readPositiveCheckedChanges(source) !== lastObserved) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
displayed.value = undefined;
|
||||
}, linger);
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const nextCount = readPositiveCheckedChanges(source);
|
||||
if (nextCount === lastObserved) return;
|
||||
lastObserved = nextCount;
|
||||
cancelClear();
|
||||
if (nextCount === undefined) {
|
||||
displayed.value = undefined;
|
||||
return;
|
||||
}
|
||||
displayed.value = nextCount;
|
||||
scheduleClear();
|
||||
};
|
||||
|
||||
if (lastObserved !== undefined) {
|
||||
displayed.value = lastObserved;
|
||||
scheduleClear();
|
||||
}
|
||||
source.onChanged(update);
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return displayed.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
displayed.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
displayed.offChanged(handler);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
source.offChanged(update);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ReplicationStatusProjection = {
|
||||
icon: string;
|
||||
message: string;
|
||||
parts: StatusBarPart[];
|
||||
};
|
||||
|
||||
function remainingSequenceLabel(
|
||||
key: string,
|
||||
lastSyncSeq: number,
|
||||
maxSeq: number
|
||||
): { text: string; part?: StatusBarPart } {
|
||||
if (lastSyncSeq === 0) return { text: "" };
|
||||
if (lastSyncSeq >= maxSeq) return { text: " (LIVE)", part: { key, text: " (LIVE)" } };
|
||||
const remaining = maxSeq - lastSyncSeq;
|
||||
return {
|
||||
text: " (" + remaining + ")",
|
||||
part: { key, text: " (", value: remaining, suffix: ")" },
|
||||
};
|
||||
}
|
||||
|
||||
/** Projects the existing replication labels into reusable numeric status items. */
|
||||
export function projectReplicationStatusLabel(
|
||||
stats: ReplicationStatics,
|
||||
networkStatus: RemoteActivityStatus,
|
||||
fastForwardCount?: number
|
||||
): ReplicationStatusProjection {
|
||||
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
|
||||
const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
|
||||
let pushRemaining: { text: string; part?: StatusBarPart } = { text: "" };
|
||||
let pullRemaining: { text: string; part?: StatusBarPart } = { text: "" };
|
||||
let icon: string;
|
||||
switch (stats.syncStatus) {
|
||||
case "CLOSED":
|
||||
case "COMPLETED":
|
||||
case "NOT_CONNECTED":
|
||||
icon = "⏹";
|
||||
break;
|
||||
case "STARTED":
|
||||
icon = "🌀";
|
||||
break;
|
||||
case "PAUSED":
|
||||
icon = "💤";
|
||||
break;
|
||||
case "CONNECTED":
|
||||
case "JOURNAL_SEND":
|
||||
case "JOURNAL_RECEIVE":
|
||||
icon = stats.syncStatus === "JOURNAL_SEND" ? "📦↑" : stats.syncStatus === "JOURNAL_RECEIVE" ? "📦↓" : "⚡";
|
||||
pushRemaining = remainingSequenceLabel("push-remaining", lastSyncPushSeq, maxPushSeq);
|
||||
pullRemaining = remainingSequenceLabel("pull-remaining", lastSyncPullSeq, maxPullSeq);
|
||||
break;
|
||||
case "ERRORED":
|
||||
icon = "⚠";
|
||||
break;
|
||||
default:
|
||||
icon = "?";
|
||||
}
|
||||
|
||||
const couchDB = stats.couchDB;
|
||||
const isFastForwardChecking =
|
||||
stats.syncStatus !== "ERRORED" &&
|
||||
couchDB !== undefined &&
|
||||
couchDB.phase !== "idle" &&
|
||||
(couchDB.pull?.checking === true || couchDB.push?.checking === true);
|
||||
if (isFastForwardChecking) icon = "⏩";
|
||||
|
||||
const visibleFastForwardCount =
|
||||
couchDB !== undefined &&
|
||||
typeof fastForwardCount === "number" &&
|
||||
Number.isFinite(fastForwardCount) &&
|
||||
fastForwardCount > 0
|
||||
? fastForwardCount
|
||||
: undefined;
|
||||
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
|
||||
|
||||
const parts: StatusBarPart[] = [];
|
||||
if (networkStatus.trackedRequestCount > 0) {
|
||||
parts.push({
|
||||
key: "network",
|
||||
text:
|
||||
(networkStatus.remoteOperationCount > 0 ? `${REMOTE_OPERATION_ACTIVITY_ICON} ` : "") +
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
value: networkStatus.trackedRequestCount,
|
||||
suffix: " ",
|
||||
});
|
||||
} else if (networkActivity !== "") {
|
||||
parts.push({ key: "network", text: networkActivity });
|
||||
}
|
||||
parts.push({ key: "sync", text: "Sync: " + icon });
|
||||
if (visibleFastForwardCount !== undefined) {
|
||||
parts.push({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: visibleFastForwardCount,
|
||||
suffix: ")",
|
||||
title: "Compared CouchDB feed entries",
|
||||
});
|
||||
}
|
||||
parts.push({ key: "sent", text: " ↑ ", value: sent });
|
||||
if (pushRemaining.part !== undefined) parts.push(pushRemaining.part);
|
||||
parts.push({ key: "arrived", text: " ↓ ", value: arrived });
|
||||
if (pullRemaining.part !== undefined) parts.push(pullRemaining.part);
|
||||
|
||||
return {
|
||||
icon,
|
||||
message:
|
||||
networkActivity +
|
||||
"Sync: " +
|
||||
icon +
|
||||
fastForwardLabel +
|
||||
" ↑ " +
|
||||
sent +
|
||||
pushRemaining.text +
|
||||
" ↓ " +
|
||||
arrived +
|
||||
pullRemaining.text,
|
||||
parts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
import {
|
||||
FAST_FORWARD_COUNT_LINGER_MS,
|
||||
createFastForwardCheckedChangesDisplay,
|
||||
projectReplicationStatusLabel,
|
||||
} from "./ReplicationStatusLabel.ts";
|
||||
|
||||
function couchDBProgress(
|
||||
phase: "idle" | "one-shot" | "catch-up" | "continuous",
|
||||
checkedChanges = 0,
|
||||
checking = false
|
||||
): NonNullable<ReplicationStatics["couchDB"]> {
|
||||
return {
|
||||
phase,
|
||||
pull: { checkedChanges, completedBatches: 0, checking },
|
||||
push: { checkedChanges: 0, completedBatches: 0, checking: false },
|
||||
};
|
||||
}
|
||||
|
||||
const noNetworkActivity = { remoteOperationCount: 0, trackedRequestCount: 0 };
|
||||
|
||||
function stats(overrides: Partial<ReplicationStatics> = {}): ReplicationStatics {
|
||||
return {
|
||||
sent: 0,
|
||||
arrived: 0,
|
||||
maxPullSeq: 0,
|
||||
maxPushSeq: 0,
|
||||
lastSyncPullSeq: 0,
|
||||
lastSyncPushSeq: 0,
|
||||
syncStatus: "CONNECTED",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectReplicationStatusLabel", () => {
|
||||
it("exposes the remote-request count as a numeric status item", () => {
|
||||
const projection = projectReplicationStatusLabel(stats(), { remoteOperationCount: 1, trackedRequestCount: 12 });
|
||||
expect(projection.parts.find((part) => part.key === "network")).toMatchObject({
|
||||
text: "📲 🌐",
|
||||
value: 12,
|
||||
suffix: " ",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the connected label, directions, and remaining-sequence text", () => {
|
||||
const projection = projectReplicationStatusLabel(
|
||||
stats({
|
||||
sent: 4,
|
||||
arrived: 2,
|
||||
maxPullSeq: 20,
|
||||
maxPushSeq: 30,
|
||||
lastSyncPullSeq: 17,
|
||||
lastSyncPushSeq: 22,
|
||||
}),
|
||||
{ remoteOperationCount: 0, trackedRequestCount: 1 }
|
||||
);
|
||||
|
||||
expect(projection.message).toBe("🌐1 Sync: ⚡ ↑ 4 (8) ↓ 2 (3)");
|
||||
expect(projection.parts.map((part) => part.key)).toEqual([
|
||||
"network",
|
||||
"sync",
|
||||
"sent",
|
||||
"push-remaining",
|
||||
"arrived",
|
||||
"pull-remaining",
|
||||
]);
|
||||
expect(projection.parts[2]).toMatchObject({ key: "sent", text: " ↑ ", value: 4 });
|
||||
expect(projection.parts[3]).toMatchObject({ key: "push-remaining", text: " (", value: 8, suffix: ")" });
|
||||
expect(projection.parts[4]).toMatchObject({ key: "arrived", text: " ↓ ", value: 2 });
|
||||
expect(projection.parts[5]).toMatchObject({ key: "pull-remaining", text: " (", value: 3, suffix: ")" });
|
||||
});
|
||||
|
||||
it("keeps the existing status icons when CouchDB is idle or not present", () => {
|
||||
const statuses = [
|
||||
["CLOSED", "⏹"],
|
||||
["COMPLETED", "⏹"],
|
||||
["NOT_CONNECTED", "⏹"],
|
||||
["STARTED", "🌀"],
|
||||
["PAUSED", "💤"],
|
||||
["CONNECTED", "⚡"],
|
||||
["JOURNAL_SEND", "📦↑"],
|
||||
["JOURNAL_RECEIVE", "📦↓"],
|
||||
["ERRORED", "⚠"],
|
||||
] as const;
|
||||
|
||||
for (const [syncStatus, icon] of statuses) {
|
||||
const idleWithCount = projectReplicationStatusLabel(
|
||||
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
|
||||
noNetworkActivity,
|
||||
19
|
||||
);
|
||||
expect(idleWithCount.icon).toBe(icon);
|
||||
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true);
|
||||
|
||||
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19);
|
||||
expect(withoutCouchDB.icon).toBe(icon);
|
||||
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
}
|
||||
|
||||
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
|
||||
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false);
|
||||
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
|
||||
});
|
||||
|
||||
it("shows fast-forward checking with zero arrivals, transferred data, and the final idle count", () => {
|
||||
const checking = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "PAUSED",
|
||||
arrived: 0,
|
||||
couchDB: couchDBProgress("catch-up", 0, true),
|
||||
}),
|
||||
noNetworkActivity
|
||||
);
|
||||
expect(checking.icon).toBe("⏩");
|
||||
expect(checking.parts[2]).toMatchObject({ key: "arrived", value: 0 });
|
||||
|
||||
const transferred = projectReplicationStatusLabel(
|
||||
stats({
|
||||
arrived: 7,
|
||||
couchDB: couchDBProgress("continuous", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
);
|
||||
expect(transferred.icon).toBe("⚡");
|
||||
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
|
||||
expect(transferred.parts[1]).toMatchObject({
|
||||
key: "ff",
|
||||
text: " (⏩ ",
|
||||
value: 1200,
|
||||
suffix: ")",
|
||||
});
|
||||
|
||||
const idle = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "CONNECTED",
|
||||
arrived: 7,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
}),
|
||||
noNetworkActivity,
|
||||
1200
|
||||
);
|
||||
expect(idle.icon).toBe("⚡");
|
||||
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
|
||||
});
|
||||
|
||||
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
|
||||
const projection = projectReplicationStatusLabel(
|
||||
stats({
|
||||
syncStatus: "ERRORED",
|
||||
couchDB: {
|
||||
...couchDBProgress("one-shot", 2, true),
|
||||
push: { checkedChanges: 0, completedBatches: 0, checking: true },
|
||||
},
|
||||
}),
|
||||
noNetworkActivity
|
||||
);
|
||||
|
||||
expect(projection.icon).toBe("⚠");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFastForwardCheckedChangesDisplay", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the initial snapshot, expires unchanged counts, and only reappears for new progress", () => {
|
||||
const source = reactiveSource(
|
||||
stats({
|
||||
couchDB: couchDBProgress("catch-up", 1200, true),
|
||||
})
|
||||
);
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBe(1200);
|
||||
vi.advanceTimersByTime(1000);
|
||||
source.value = stats({
|
||||
sent: 3,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
expect(display.value).toBe(1200);
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
|
||||
expect(display.value).toBe(1200);
|
||||
|
||||
source.value = stats({
|
||||
sent: 4,
|
||||
couchDB: couchDBProgress("idle", 1200, false),
|
||||
});
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats({
|
||||
syncStatus: "PAUSED",
|
||||
couchDB: couchDBProgress("continuous", 1200, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("continuous", 1201, true),
|
||||
});
|
||||
expect(display.value).toBe(1201);
|
||||
|
||||
source.value = stats({
|
||||
couchDB: couchDBProgress("one-shot", 0, true),
|
||||
});
|
||||
expect(display.value).toBeUndefined();
|
||||
|
||||
source.value = stats();
|
||||
expect(display.value).toBeUndefined();
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("clears its timer and subscription when disposed", () => {
|
||||
const source = reactiveSource(stats({ couchDB: couchDBProgress("catch-up", 5, true) }));
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
|
||||
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
|
||||
|
||||
expect(display.value).toBe(5);
|
||||
});
|
||||
|
||||
it("does not display progress for providers without CouchDB replication state", () => {
|
||||
const source = reactiveSource(stats());
|
||||
const display = createFastForwardCheckedChangesDisplay(source);
|
||||
|
||||
expect(display.value).toBeUndefined();
|
||||
source.value = stats({ couchDB: { phase: "continuous" } });
|
||||
expect(display.value).toBeUndefined();
|
||||
display.dispose();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export function createAdvancedSettingSpecGroups({
|
||||
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
|
||||
},
|
||||
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
|
||||
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
|
||||
{
|
||||
key: "pathNormalizationVersion",
|
||||
holdValue: true,
|
||||
|
||||
+8
-6
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
||||
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
@@ -459,7 +461,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(runtime.pageCleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => {
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -468,7 +470,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -482,7 +484,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds translated catalogue names when the display language changes externally", () => {
|
||||
it("rebuilds translated catalogue names when the display language changes externally", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -491,12 +493,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.displayLanguage = "ja";
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => {
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -507,7 +509,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
tab.editingSettings.usePowerUserMode = true;
|
||||
tab.core.settings.usePowerUserMode = true;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
|
||||
expect(configureAnchor).toBeTypeOf("function");
|
||||
let acceptExternalSetting: (() => void) | undefined;
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type PageFunctions,
|
||||
type UpdateFunction,
|
||||
} from "./SettingPane.ts";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
|
||||
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
selectedScreen = "";
|
||||
|
||||
private localConfigurationPassphrase = "";
|
||||
private localSettingsReadVersion = 0;
|
||||
|
||||
_editingSettings?: AllSettings;
|
||||
// Buffered Settings for editing
|
||||
get editingSettings(): AllSettings {
|
||||
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
||||
if (key == "deviceAndVaultName") {
|
||||
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
return await Promise.resolve();
|
||||
await this.services.setting.saveDeviceAndVaultName();
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
appliedConfigurationEncryption.configPassphrase
|
||||
);
|
||||
|
||||
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
|
||||
this.localSettingsReadVersion++;
|
||||
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
|
||||
}
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
|
||||
}
|
||||
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
|
||||
private async readLocalSettings(): Promise<boolean> {
|
||||
const version = ++this.localSettingsReadVersion;
|
||||
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
|
||||
if (version !== this.localSettingsReadVersion) return false;
|
||||
this.localConfigurationPassphrase = passphrase;
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
if (await this.readLocalSettings()) this.reloadAllSettings(true);
|
||||
}
|
||||
|
||||
reloadAllLocalSettings() {
|
||||
const ret = { ...OnDialogSettingsDefault };
|
||||
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
|
||||
ret.configPassphrase = this.localConfigurationPassphrase;
|
||||
ret.preset = "";
|
||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||
return ret;
|
||||
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.plugin = plugin;
|
||||
Setting.env = this;
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||
this.requestReload();
|
||||
void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
|
||||
});
|
||||
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
|
||||
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
|
||||
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
);
|
||||
}
|
||||
|
||||
requestReload() {
|
||||
async requestReload(): Promise<void> {
|
||||
if (!(await this.readLocalSettings())) return;
|
||||
const nativeTabIsShown =
|
||||
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
||||
if (this.isShown || nativeTabIsShown) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
@@ -76,8 +76,6 @@ beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
loggerMocks.Logger.mockClear();
|
||||
vi.mocked($msg).mockClear();
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
suspendAllSync,
|
||||
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
},
|
||||
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
return { tab, saveSettingData };
|
||||
}
|
||||
|
||||
it("awaits the stored passphrase before initialising the editing buffer", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
tab.resetEditingSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
expect(tab.initialSettings).toBeUndefined();
|
||||
resolveRead("stored passphrase");
|
||||
await loading;
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("preserves the loaded settings when device storage cannot be read", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
|
||||
await tab.loadSettings();
|
||||
const failure = new Error("device storage unavailable");
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
|
||||
await expect(tab.requestReload()).rejects.toBe(failure);
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.loadSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
|
||||
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.editingSettings.configPassphrase = "new passphrase";
|
||||
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
|
||||
resolveRead("old passphrase");
|
||||
await loading;
|
||||
tab.reloadAllSettings();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
|
||||
});
|
||||
|
||||
it("loads the imperative fallback without a SettingPage runtime export", () => {
|
||||
const { tab } = createSettingsTab();
|
||||
|
||||
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
it("saves a custom configuration passphrase edit through the setting service", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
const changeConfigurationEncryption = vi.fn(async () => {
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase");
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
|
||||
});
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
const store = "LOCALSTORAGE";
|
||||
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = store;
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await tab.saveSettings(["configPassphrase"]);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).toHaveBeenCalledOnce();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
|
||||
});
|
||||
|
||||
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.initialSettings!.configPassphrase = "previous passphrase";
|
||||
tab.editingSettings.configPassphrase = "passphrase A";
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
|
||||
|
||||
const apply = tab.saveSettings(["configPassphrase"]);
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
|
||||
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.editingSettings.configPassphraseStore = editedStore;
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
|
||||
};
|
||||
|
||||
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
|
||||
|
||||
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
|
||||
"Memory cache",
|
||||
@@ -56,6 +56,7 @@ describe("paneAdvanced", () => {
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
@@ -67,6 +68,7 @@ describe("paneAdvanced", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { Menu, type ButtonComponent } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
|
||||
paneEl: HTMLElement,
|
||||
{ addPanel }: PageFunctions
|
||||
): void {
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Joined devices"))
|
||||
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
|
||||
.addButton((button) =>
|
||||
button.setButtonText($msg("Review devices")).onClick(async () => {
|
||||
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
if (result.status === "unavailable" || result.value === "unavailable") {
|
||||
this.services.API.addLog(
|
||||
$msg(
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
{
|
||||
/* E2EE */
|
||||
const E2EEInitialProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
buttonClasses: [] as string[],
|
||||
clickHandlers: [] as Array<() => Promise<void> | void>,
|
||||
clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
|
||||
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
|
||||
settingClasses: [] as string[],
|
||||
}));
|
||||
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
},
|
||||
};
|
||||
|
||||
setName() {
|
||||
settingName = "";
|
||||
|
||||
setName(name: string) {
|
||||
this.settingName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
}
|
||||
|
||||
addButton(callback: (button: unknown) => void) {
|
||||
const setting = this.settingName;
|
||||
const button = {
|
||||
buttonEl: {
|
||||
classList: {
|
||||
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
},
|
||||
onClick(callback: () => Promise<void> | void) {
|
||||
runtime.clickHandlers.push(callback);
|
||||
runtime.clickHandlers.push({ setting, run: callback });
|
||||
return this;
|
||||
},
|
||||
setButtonText() {
|
||||
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
|
||||
syncActivatedRemoteSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
|
||||
|
||||
function createPanelElement(): HTMLElement {
|
||||
@@ -124,6 +129,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("paneRemoteConfig", () => {
|
||||
it("opens joined devices through the shared UI command", async () => {
|
||||
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
|
||||
const host = {
|
||||
editingSettings: { remoteConfigurations: {} },
|
||||
services: { UI: { commands: { invoke } } },
|
||||
};
|
||||
const addPanel = vi.fn(() => ({ then: vi.fn() }));
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
|
||||
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
|
||||
});
|
||||
|
||||
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
|
||||
const callbacks: Array<() => unknown> = [];
|
||||
const lifetimeComponent = {
|
||||
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
|
||||
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
|
||||
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(host.editingSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
|
||||
|
||||
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
.setName($msg("Synchronising files"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files."
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
||||
)
|
||||
);
|
||||
const syncFilesControl = mount(MultipleRegExpControl, {
|
||||
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
|
||||
const nonSyncFilesSetting = new Setting(paneEl)
|
||||
.setName($msg("Non-Synchronising files"))
|
||||
.setDesc(
|
||||
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
|
||||
);
|
||||
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||
|
||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: nonSyncFilesSetting.controlEl,
|
||||
|
||||
@@ -171,20 +171,18 @@ export function paneSyncSettings(
|
||||
}
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
paneEl,
|
||||
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
|
||||
undefined,
|
||||
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
|
||||
).then((paneEl) => {
|
||||
new Setting(paneEl).autoWireToggle("batchSave");
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
|
||||
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
|
||||
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
|
||||
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
|
||||
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
|
||||
|
||||
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
autoWireDropDown(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireToggle(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { paneSyncSettings } from "./PaneSyncSettings";
|
||||
|
||||
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
|
||||
const callbacks = new Map<string, (value: string) => Promise<void>>();
|
||||
const panels = new Map<string, OnUpdateFunc | undefined>();
|
||||
const tab = {
|
||||
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
|
||||
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
|
||||
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
|
||||
isConfiguredAs(key: string, value: unknown) {
|
||||
if (key === "syncMode") {
|
||||
return (
|
||||
(this.editingSettings.liveSync
|
||||
? "LIVESYNC"
|
||||
: this.editingSettings.periodicReplication
|
||||
? "PERIODIC"
|
||||
: "ONEVENTS") === value
|
||||
);
|
||||
}
|
||||
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
|
||||
},
|
||||
saveSettings: vi.fn(async () => {}),
|
||||
requestCatalogueRefresh: vi.fn(),
|
||||
};
|
||||
const functions = {
|
||||
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
|
||||
then(callback: (element: HTMLElement) => void) {
|
||||
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
|
||||
panels.set(title, onUpdate);
|
||||
callback({} as HTMLElement);
|
||||
},
|
||||
}),
|
||||
} as unknown as PageFunctions;
|
||||
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
|
||||
return { tab, callbacks, panels };
|
||||
}
|
||||
|
||||
function visible(key: string): boolean {
|
||||
const control = controls.get(key);
|
||||
if (!control) throw new Error(`Missing setting control: ${key}`);
|
||||
return control.onUpdate?.().visibility !== false;
|
||||
}
|
||||
|
||||
beforeEach(() => controls.clear());
|
||||
|
||||
describe("mode-specific database batching settings", () => {
|
||||
it.each([
|
||||
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
|
||||
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
|
||||
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
|
||||
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
|
||||
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
|
||||
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
|
||||
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
|
||||
expect(visible("batchSave")).toBe(!settings.liveSync);
|
||||
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
|
||||
expect(visible("batchSaveMinimumDelay")).toBe(delays);
|
||||
expect(visible("batchSaveMaximumDelay")).toBe(delays);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, false],
|
||||
[false, true],
|
||||
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
|
||||
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
|
||||
const switchMode = callbacks.get("syncMode")!;
|
||||
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
|
||||
await switchMode(mode);
|
||||
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
|
||||
}
|
||||
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const rangeMessages = {
|
||||
};
|
||||
|
||||
describe("Advanced setting specifications", () => {
|
||||
it("lists only the ten controls currently exposed by the Advanced page", () => {
|
||||
it("lists only the eleven controls currently exposed by the Advanced page", () => {
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
|
||||
|
||||
expect(groups.map(({ heading }) => heading)).toEqual([
|
||||
@@ -30,6 +30,7 @@ describe("Advanced setting specifications", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
@@ -40,6 +41,17 @@ describe("Advanced setting specifications", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes strict content checking as an ungated Advanced toggle", () => {
|
||||
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => false })
|
||||
.find(({ heading }) => heading === "Local Database Tweak")
|
||||
?.items.find(({ key }) => key === "strictContentCheckOnStartup");
|
||||
if (!spec) throw new Error("Strict content checking is not exposed on the Advanced page");
|
||||
|
||||
expect(spec.control).toEqual({ type: "toggle" });
|
||||
expect(spec.visible).toBeUndefined();
|
||||
expect(spec.disabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
|
||||
let couchDB = false;
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { SetupManager, UserMode } from "./SetupManager";
|
||||
|
||||
class TestSettingService extends SettingService<ServiceContext> {
|
||||
protected setItem(_key: string, _value: string): void {}
|
||||
protected getItem(_key: string): string {
|
||||
return "";
|
||||
}
|
||||
protected deleteItem(_key: string): void {}
|
||||
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
|
||||
|
||||
function createSetupManager() {
|
||||
const setting = new TestSettingService(new ServiceContext(), {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
APIService: {
|
||||
getSystemVaultName: vi.fn(() => "vault"),
|
||||
getAppID: vi.fn(() => "app"),
|
||||
@@ -172,10 +169,12 @@ describe("SetupManager", () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
|
||||
const newVaultSettings = createNewVaultSettings();
|
||||
|
||||
await manager.onOnboard(UserMode.NewUser);
|
||||
|
||||
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
|
||||
expect(newVaultSettings.customChunkSize).toBe(0);
|
||||
expect(configureManually).toHaveBeenCalledWith(newVaultSettings, UserMode.NewUser);
|
||||
});
|
||||
|
||||
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
|
||||
@@ -487,8 +486,15 @@ describe("SetupManager", () => {
|
||||
expect(applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
|
||||
it.each([
|
||||
{ importedSize: 0, expectedSize: 0 },
|
||||
{ importedSize: undefined, expectedSize: 60 },
|
||||
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
@@ -507,6 +513,8 @@ describe("SetupManager", () => {
|
||||
},
|
||||
activeConfigurationId: "archive",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
|
||||
else imported.customChunkSize = importedSize;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
@@ -514,6 +522,7 @@ describe("SetupManager", () => {
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
@@ -523,6 +532,7 @@ describe("SetupManager", () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
@@ -554,6 +564,7 @@ describe("SetupManager", () => {
|
||||
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(60);
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
expect(current.activeConfigurationId).not.toBe("existing");
|
||||
@@ -662,6 +673,7 @@ describe("SetupManager", () => {
|
||||
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(0);
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
|
||||
expect(current.activeConfigurationId).not.toBe("");
|
||||
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
|
||||
|
||||
@@ -52,26 +52,48 @@
|
||||
const context = getDialogContext();
|
||||
let error = $state("");
|
||||
let connectionPathResetNotice = $state(false);
|
||||
let peerNameReady = $state(false);
|
||||
let mounted = false;
|
||||
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const initialData = getInitialData?.();
|
||||
connectionProbe = initialData?.connectionProbe;
|
||||
const initialSettings = initialData?.settings;
|
||||
if (initialSettings) {
|
||||
syncSetting = pickP2PSyncSettings(initialSettings);
|
||||
}
|
||||
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
|
||||
const savedPeerName = initialSettings?.P2P_DevicePeerName;
|
||||
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
|
||||
if (initialPeerName !== "") {
|
||||
return;
|
||||
}
|
||||
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
|
||||
if (cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
|
||||
peerNameReady = true;
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}
|
||||
void context.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME).then(
|
||||
(cachedPeerName) => {
|
||||
if (mounted) {
|
||||
if (typeof cachedPeerName === "string" && cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName;
|
||||
}
|
||||
peerNameReady = true;
|
||||
}
|
||||
},
|
||||
(loadError: unknown) => {
|
||||
if (mounted) {
|
||||
error = loadError instanceof Error ? loadError.message : String(loadError);
|
||||
peerNameReady = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
});
|
||||
function generateSetting() {
|
||||
const connSetting: P2PSyncSetting = {
|
||||
@@ -193,6 +215,7 @@
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
if (!peerNameReady) return;
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
@@ -207,6 +230,7 @@
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
if (!peerNameReady) return;
|
||||
error = validateManagedTurnSettings(syncSetting) ?? "";
|
||||
if (error) return;
|
||||
const setting = pickP2PSyncSettings(generateSetting());
|
||||
@@ -217,6 +241,7 @@
|
||||
}
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
peerNameReady &&
|
||||
syncSetting.P2P_relays.trim() !== "" &&
|
||||
syncSetting.P2P_roomID.trim() !== "" &&
|
||||
syncSetting.P2P_passphrase.trim() !== "" &&
|
||||
@@ -291,6 +316,7 @@
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
disabled={!peerNameReady}
|
||||
bind:value={syncSetting.P2P_DevicePeerName}
|
||||
/>
|
||||
</InputRow>
|
||||
@@ -385,7 +411,11 @@
|
||||
disabled={!canProceed}
|
||||
commit={() => checkAndCommit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
|
||||
<Decision
|
||||
title={translateMessage("Continue anyway")}
|
||||
disabled={!peerNameReady}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -10,6 +10,15 @@ export type DisposableReactiveValue<T> = ReactiveValue<T> & {
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export type PaddedCounterLabel = DisposableReactiveValue<string> & {
|
||||
readonly count: ReactiveValue<number | undefined>;
|
||||
};
|
||||
|
||||
export type ChunkFetchCounterLabel = DisposableReactiveValue<string> & {
|
||||
readonly initialCount: ReactiveValue<number | undefined>;
|
||||
readonly retryingCount: ReactiveValue<number | undefined>;
|
||||
};
|
||||
|
||||
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
|
||||
return {
|
||||
get value() {
|
||||
@@ -92,9 +101,10 @@ export function createPaddedCounterLabel(
|
||||
source: ReactiveValue<number>,
|
||||
mark: string,
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
|
||||
): DisposableReactiveValue<string> {
|
||||
): PaddedCounterLabel {
|
||||
const linger = Math.max(0, inactiveLingerMs);
|
||||
const formatted = reactiveSource("");
|
||||
const displayedCount = reactiveSource<number | undefined>(undefined);
|
||||
let maximumLength = 1;
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
@@ -114,22 +124,39 @@ export function createPaddedCounterLabel(
|
||||
cancelClear();
|
||||
const count = source.value;
|
||||
formatted.value = format(count);
|
||||
displayedCount.value = count;
|
||||
if (count !== 0) return;
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
displayedCount.value = undefined;
|
||||
maximumLength = 1;
|
||||
}, linger);
|
||||
};
|
||||
|
||||
source.onChanged(update);
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
source.offChanged(update);
|
||||
});
|
||||
return {
|
||||
get value() {
|
||||
return display.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
display.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
display.offChanged(handler);
|
||||
},
|
||||
count: displayedCount,
|
||||
dispose() {
|
||||
display.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,7 +165,7 @@ export function createPaddedCounterLabel(
|
||||
*/
|
||||
export function createChunkFetchCounterLabel(
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
||||
): DisposableReactiveValue<string> {
|
||||
): ChunkFetchCounterLabel {
|
||||
const initialCount = reactiveSource(0);
|
||||
const retryingCount = reactiveSource(0);
|
||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
||||
@@ -168,7 +195,7 @@ export function createChunkFetchCounterLabel(
|
||||
source.onChanged(updateCounts);
|
||||
updateCounts();
|
||||
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
source.offChanged(updateCounts);
|
||||
@@ -177,4 +204,20 @@ export function createChunkFetchCounterLabel(
|
||||
initialLabel.dispose();
|
||||
retryingLabel.dispose();
|
||||
});
|
||||
return {
|
||||
get value() {
|
||||
return display.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
display.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
display.offChanged(handler);
|
||||
},
|
||||
initialCount: initialLabel.count,
|
||||
retryingCount: retryingLabel.count,
|
||||
dispose() {
|
||||
display.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -111,6 +111,24 @@ describe("createPaddedCounterLabel", () => {
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("exposes the numeric value while retaining zero through the existing linger", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📥");
|
||||
|
||||
expect(display.count.value).toBeUndefined();
|
||||
source.value = 9;
|
||||
expect(display.count.value).toBe(9);
|
||||
source.value = 0;
|
||||
expect(display.count.value).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
|
||||
expect(display.count.value).toBe(0);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.count.value).toBeUndefined();
|
||||
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels the pending clear when counter activity resumes", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📄");
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
const INACTIVE_CLASS = "livesync-stat-inactive";
|
||||
|
||||
export type StatusBarPart = {
|
||||
key: string;
|
||||
text: string;
|
||||
value?: number;
|
||||
suffix?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type StatusBarRenderer = {
|
||||
render(parts: readonly StatusBarPart[]): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
type RenderedItem = {
|
||||
element: HTMLSpanElement;
|
||||
prefix: Text;
|
||||
value: HTMLSpanElement;
|
||||
valueText: Text;
|
||||
suffix: Text;
|
||||
widestValueLength: number;
|
||||
};
|
||||
|
||||
export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer {
|
||||
const items = new Map<string, RenderedItem>();
|
||||
let disposed = false;
|
||||
|
||||
const setText = (node: Text, text: string) => {
|
||||
if (node.data !== text) node.data = text;
|
||||
};
|
||||
|
||||
const setHidden = (element: HTMLElement, hidden: boolean) => {
|
||||
if (element.hidden !== hidden) element.hidden = hidden;
|
||||
};
|
||||
|
||||
const setInactive = (element: HTMLElement, inactive: boolean) => {
|
||||
if (element.classList.contains(INACTIVE_CLASS) === inactive) return;
|
||||
element.classList.toggle(INACTIVE_CLASS, inactive);
|
||||
if (inactive) element.setAttribute("aria-hidden", "true");
|
||||
else element.removeAttribute("aria-hidden");
|
||||
};
|
||||
|
||||
const setWidth = (element: HTMLElement, width: string) => {
|
||||
if (element.style.getPropertyValue("--livesync-stat-width") !== width) {
|
||||
element.setCssProps({ "--livesync-stat-width": width });
|
||||
}
|
||||
};
|
||||
|
||||
const createItem = (key: string): RenderedItem => {
|
||||
const document = parent.ownerDocument;
|
||||
const element = parent.createSpan();
|
||||
element.className = "livesync-stat-item livesync-stat-" + key;
|
||||
|
||||
const prefix = document.createTextNode("");
|
||||
const value = element.createSpan();
|
||||
value.className = "livesync-stat-value";
|
||||
const valueText = document.createTextNode("");
|
||||
value.appendChild(valueText);
|
||||
value.hidden = true;
|
||||
const suffix = document.createTextNode("");
|
||||
|
||||
element.appendChild(prefix);
|
||||
element.appendChild(value);
|
||||
element.appendChild(suffix);
|
||||
|
||||
const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 };
|
||||
items.set(key, item);
|
||||
return item;
|
||||
};
|
||||
|
||||
const updateItem = (item: RenderedItem, part: StatusBarPart) => {
|
||||
setText(item.prefix, part.text);
|
||||
const title = part.title ?? "";
|
||||
if (item.element.title !== title) item.element.title = title;
|
||||
|
||||
if (part.value === undefined) {
|
||||
setText(item.valueText, "");
|
||||
setText(item.suffix, "");
|
||||
setHidden(item.value, true);
|
||||
setWidth(item.value, "");
|
||||
item.widestValueLength = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedValue = String(part.value);
|
||||
setText(item.valueText, formattedValue);
|
||||
setText(item.suffix, part.suffix ?? "");
|
||||
setHidden(item.value, false);
|
||||
|
||||
if (formattedValue.length > item.widestValueLength) {
|
||||
item.widestValueLength = formattedValue.length;
|
||||
const width = item.widestValueLength + "ch";
|
||||
setWidth(item.value, width);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => {
|
||||
const desiredOrder = visibleItems.map((item) => item.element);
|
||||
const visibleElements = new Set(desiredOrder);
|
||||
const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement =>
|
||||
visibleElements.has(node as HTMLSpanElement)
|
||||
);
|
||||
if (
|
||||
currentOrder.length === desiredOrder.length &&
|
||||
currentOrder.every((node, index) => node === desiredOrder[index])
|
||||
)
|
||||
return;
|
||||
|
||||
let reference: ChildNode | null = currentOrder[0] ?? null;
|
||||
for (const element of desiredOrder) {
|
||||
if (element !== reference) parent.insertBefore(element, reference);
|
||||
const childNodes = Array.from(parent.childNodes);
|
||||
const elementIndex = childNodes.indexOf(element);
|
||||
reference =
|
||||
childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
render(parts) {
|
||||
if (disposed) return;
|
||||
|
||||
const visibleItems: RenderedItem[] = [];
|
||||
const visibleKeys = new Set<string>();
|
||||
for (const part of parts) {
|
||||
let item = items.get(part.key);
|
||||
if (!item) item = createItem(part.key);
|
||||
if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0;
|
||||
updateItem(item, part);
|
||||
setInactive(item.element, false);
|
||||
visibleItems.push(item);
|
||||
visibleKeys.add(part.key);
|
||||
}
|
||||
|
||||
for (const [key, item] of items) {
|
||||
if (visibleKeys.has(key)) continue;
|
||||
setInactive(item.element, true);
|
||||
}
|
||||
|
||||
reorderIfNeeded(visibleItems);
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const item of items.values()) {
|
||||
if (item.element.parentNode === parent) parent.removeChild(item.element);
|
||||
}
|
||||
items.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createStatusBarRenderer, type StatusBarPart } from "./StatusBarRenderer.ts";
|
||||
|
||||
type TestNode = TestElement | TestTextNode;
|
||||
|
||||
class TestDocument {
|
||||
mutations = 0;
|
||||
|
||||
createElement(tagName: string): TestElement {
|
||||
return new TestElement(this, tagName);
|
||||
}
|
||||
|
||||
createTextNode(data: string): TestTextNode {
|
||||
return new TestTextNode(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
class TestTextNode {
|
||||
readonly nodeType = 3;
|
||||
parentNode: TestElement | undefined;
|
||||
private currentData: string;
|
||||
|
||||
constructor(
|
||||
private readonly document: TestDocument,
|
||||
data: string
|
||||
) {
|
||||
this.currentData = data;
|
||||
}
|
||||
|
||||
get data(): string {
|
||||
return this.currentData;
|
||||
}
|
||||
|
||||
set data(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentData = value;
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.currentData;
|
||||
}
|
||||
}
|
||||
|
||||
class TestStyle {
|
||||
private properties = new Map<string, string>();
|
||||
|
||||
constructor(private readonly document: TestDocument) {}
|
||||
|
||||
getPropertyValue(name: string): string {
|
||||
return this.properties.get(name) ?? "";
|
||||
}
|
||||
|
||||
setProperty(name: string, value: string) {
|
||||
this.document.mutations++;
|
||||
this.properties.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
class TestElement {
|
||||
readonly nodeType = 1;
|
||||
readonly ownerDocument: Document;
|
||||
readonly childNodes: TestNode[] = [];
|
||||
parentNode: TestElement | undefined;
|
||||
readonly style: TestStyle;
|
||||
private currentClassName = "";
|
||||
private currentHidden = false;
|
||||
private currentTitle = "";
|
||||
private attributes = new Map<string, string>();
|
||||
readonly classList = {
|
||||
contains: (token: string): boolean => this.currentClassName.split(" ").includes(token),
|
||||
toggle: (token: string, force: boolean): boolean => {
|
||||
const tokens = this.currentClassName.split(" ").filter(Boolean);
|
||||
const present = tokens.includes(token);
|
||||
if (present !== force) {
|
||||
this.className = force
|
||||
? [...tokens, token].join(" ")
|
||||
: tokens.filter((item) => item !== token).join(" ");
|
||||
}
|
||||
return force;
|
||||
},
|
||||
};
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string) {
|
||||
this.document.mutations++;
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
|
||||
removeAttribute(name: string) {
|
||||
if (this.attributes.delete(name)) this.document.mutations++;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly document: TestDocument,
|
||||
readonly tagName: string
|
||||
) {
|
||||
this.ownerDocument = document as unknown as Document;
|
||||
this.style = new TestStyle(document);
|
||||
}
|
||||
|
||||
get className(): string {
|
||||
return this.currentClassName;
|
||||
}
|
||||
|
||||
set className(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentClassName = value;
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.currentHidden;
|
||||
}
|
||||
|
||||
set hidden(value: boolean) {
|
||||
this.document.mutations++;
|
||||
this.currentHidden = value;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.currentTitle;
|
||||
}
|
||||
|
||||
set title(value: string) {
|
||||
this.document.mutations++;
|
||||
this.currentTitle = value;
|
||||
}
|
||||
|
||||
get children(): TestElement[] {
|
||||
return this.childNodes.filter((node): node is TestElement => node instanceof TestElement);
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.childNodes.map((node) => node.textContent ?? "").join("");
|
||||
}
|
||||
|
||||
createSpan(): TestElement {
|
||||
return this.appendChild(this.document.createElement("span"));
|
||||
}
|
||||
|
||||
setCssProps(properties: Record<string, string>) {
|
||||
for (const [name, value] of Object.entries(properties)) this.style.setProperty(name, value);
|
||||
}
|
||||
|
||||
appendChild<T extends TestNode>(node: T): T {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
this.childNodes.push(node);
|
||||
node.parentNode = this;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
|
||||
insertBefore<T extends TestNode>(node: T, reference: TestNode | null): T {
|
||||
if (node === reference) return node;
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
const index = reference ? this.childNodes.indexOf(reference) : -1;
|
||||
if (index < 0) {
|
||||
this.childNodes.push(node);
|
||||
} else {
|
||||
this.childNodes.splice(index, 0, node);
|
||||
}
|
||||
node.parentNode = this;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
|
||||
removeChild<T extends TestNode>(node: T): T {
|
||||
const index = this.childNodes.indexOf(node);
|
||||
if (index < 0) throw new Error("Node is not a child");
|
||||
this.childNodes.splice(index, 1);
|
||||
node.parentNode = undefined;
|
||||
this.document.mutations++;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const document = new TestDocument();
|
||||
const root = new TestElement(document, "div");
|
||||
return { document, root, parent: root as unknown as HTMLElement };
|
||||
}
|
||||
|
||||
function findItem(root: TestElement, key: string): TestElement {
|
||||
const item = root.children.find((child) => child.className.split(" ").includes("livesync-stat-" + key));
|
||||
if (!item) throw new Error("Missing item: " + key);
|
||||
return item;
|
||||
}
|
||||
|
||||
function findValue(item: TestElement): TestElement {
|
||||
const value = item.children.find((child) => child.className.split(" ").includes("livesync-stat-value"));
|
||||
if (!value) throw new Error("Missing numeric value");
|
||||
return value;
|
||||
}
|
||||
|
||||
function visibleItems(root: TestElement): TestElement[] {
|
||||
return root.children.filter(
|
||||
(child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item")
|
||||
);
|
||||
}
|
||||
|
||||
describe("createStatusBarRenderer", () => {
|
||||
it("renders complete text safely and gives each item an independent class hook", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([
|
||||
{ key: "sync", text: "<sync>" },
|
||||
{ key: "queue", text: " (queued: ", value: 2, suffix: ")" },
|
||||
]);
|
||||
|
||||
const sync = findItem(root, "sync");
|
||||
const queue = findItem(root, "queue");
|
||||
expect(sync.className.split(" ")).toEqual(["livesync-stat-item", "livesync-stat-sync"]);
|
||||
expect(sync.textContent).toBe("<sync>");
|
||||
expect(queue.className.split(" ")).toContain("livesync-stat-queue");
|
||||
expect(queue.textContent).toBe(" (queued: 2)");
|
||||
expect(findValue(queue).className).toBe("livesync-stat-value");
|
||||
|
||||
renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]);
|
||||
expect(sync.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||
expect(sync.hidden).toBe(false);
|
||||
expect(sync.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(queue.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||
expect(queue.textContent).toBe(" (queued: 3)");
|
||||
renderer.render([{ key: "queue", text: "Queue idle" }]);
|
||||
expect(queue.textContent).toBe("Queue idle");
|
||||
expect(findValue(queue).hidden).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("does not mutate the DOM when the same parts are rendered again", () => {
|
||||
const { document, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
const parts: StatusBarPart[] = [
|
||||
{ key: "pending", text: " pending ", value: 7, suffix: " files", title: "Pending" },
|
||||
];
|
||||
|
||||
renderer.render(parts);
|
||||
const mutations = document.mutations;
|
||||
renderer.render(parts);
|
||||
|
||||
expect(document.mutations).toBe(mutations);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("updates values while retaining the widest visible numeric width", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 9, suffix: ")" }]);
|
||||
const item = findItem(root, "count");
|
||||
const value = findValue(item);
|
||||
expect(item.textContent).toBe("(9)");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 123, suffix: ")" }]);
|
||||
expect(value.textContent).toBe("123");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
|
||||
|
||||
renderer.render([{ key: "count", text: "(", value: 4, suffix: ")" }]);
|
||||
expect(value.textContent).toBe("4");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch");
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("retains omitted content and width, then resets width when the same item returns", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
|
||||
renderer.render([{ key: "count", text: " ", value: 1234 }]);
|
||||
const item = findItem(root, "count");
|
||||
const value = findValue(item);
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([]);
|
||||
expect(item.classList.contains("livesync-stat-inactive")).toBe(true);
|
||||
expect(item.hidden).toBe(false);
|
||||
expect(item.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.textContent).toBe(" 1234");
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([{ key: "count", text: " ", value: 5 }]);
|
||||
expect(findItem(root, "count")).toBe(item);
|
||||
expect(item.classList.contains("livesync-stat-inactive")).toBe(false);
|
||||
expect(item.getAttribute("aria-hidden")).toBeNull();
|
||||
expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch");
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => {
|
||||
const { document, root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
const first = { key: "first", text: "first " };
|
||||
const count = { key: "count", text: "count ", value: 1234 };
|
||||
const last = { key: "last", text: "last" };
|
||||
renderer.render([first, count, last]);
|
||||
const initialOrder = [...root.children];
|
||||
const omitted = findItem(root, "count");
|
||||
|
||||
renderer.render([first, last]);
|
||||
expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]);
|
||||
expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]);
|
||||
const mutations = document.mutations;
|
||||
renderer.render([first, last]);
|
||||
expect(document.mutations).toBe(mutations);
|
||||
|
||||
const added = { key: "added", text: "added " };
|
||||
renderer.render([first, added, last]);
|
||||
expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]);
|
||||
expect(omitted.textContent).toBe("count 1234");
|
||||
expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch");
|
||||
|
||||
renderer.render([first, count, added, last]);
|
||||
expect(findItem(root, "count")).toBe(omitted);
|
||||
expect(visibleItems(root)).toEqual(root.children);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("reorders existing nodes without replacing them", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
renderer.render([
|
||||
{ key: "first", text: "first " },
|
||||
{ key: "second", text: "second " },
|
||||
]);
|
||||
const first = findItem(root, "first");
|
||||
const second = findItem(root, "second");
|
||||
|
||||
renderer.render([
|
||||
{ key: "second", text: "second " },
|
||||
{ key: "first", text: "first " },
|
||||
]);
|
||||
|
||||
expect(visibleItems(root)).toEqual([second, first]);
|
||||
expect(findItem(root, "first")).toBe(first);
|
||||
expect(findItem(root, "second")).toBe(second);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it("removes its nodes on dispose and ignores later renders", () => {
|
||||
const { root, parent } = createFixture();
|
||||
const externalNode = new TestElement(new TestDocument(), "strong");
|
||||
root.appendChild(externalNode);
|
||||
const renderer = createStatusBarRenderer(parent);
|
||||
renderer.render([{ key: "sync", text: "sync" }]);
|
||||
const item = findItem(root, "sync");
|
||||
|
||||
renderer.dispose();
|
||||
const childCountAfterDispose = root.childNodes.length;
|
||||
renderer.dispose();
|
||||
renderer.render([{ key: "later", text: "later" }]);
|
||||
|
||||
expect(item.parentNode).toBeUndefined();
|
||||
expect(root.childNodes).toEqual([externalNode]);
|
||||
expect(root.childNodes).toHaveLength(childCountAfterDispose);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,22 @@ import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker
|
||||
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
||||
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
|
||||
const OBSIDIAN_PLATFORMS = new Set([
|
||||
"android-app",
|
||||
"ios",
|
||||
"macos",
|
||||
"mobile-app",
|
||||
"mobile",
|
||||
"safari",
|
||||
"desktop",
|
||||
"desktop-app",
|
||||
"unknown-obsidian",
|
||||
]);
|
||||
|
||||
function isObsidianPlatform(platform: string): boolean {
|
||||
return OBSIDIAN_PLATFORMS.has(platform);
|
||||
}
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule {
|
||||
async _onLiveSyncReady() {
|
||||
if (!(await this.core.services.appLifecycle.onLayoutReady())) return false;
|
||||
@@ -44,13 +60,20 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
}
|
||||
}
|
||||
// Ordinary start-up may continue when individual files could not be
|
||||
// processed. Explicit Fetch and Rebuild flows retain the strict default.
|
||||
// processed. Explicit Fetch and Rebuild flows remain strict.
|
||||
const { ignoreSuspending = false, continueOnFileFailure = true } = this.core.startupDatabaseOptions;
|
||||
// Only ordinary Obsidian start-up honours the opt-out. Keep other hosts
|
||||
// and explicit operation modes strict.
|
||||
const strictContentCheck =
|
||||
!isObsidianPlatform(this.services.API.getPlatform()) ||
|
||||
this.settings.strictContentCheckOnStartup === true ||
|
||||
this.core.startupDatabaseOptions.continueOnFileFailure === false;
|
||||
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(
|
||||
false,
|
||||
false,
|
||||
ignoreSuspending,
|
||||
continueOnFileFailure
|
||||
continueOnFileFailure,
|
||||
{ strictContentCheck }
|
||||
);
|
||||
if (initialisationResult === VaultScanResults.FAILED) {
|
||||
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
|
||||
@@ -90,9 +90,11 @@ describe("ModuleLiveSyncMain", () => {
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
@@ -102,7 +104,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: false });
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
@@ -120,6 +122,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
services: { appLifecycle },
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
appLifecycle,
|
||||
control: { applySettings: vi.fn(async () => undefined) },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
@@ -152,6 +155,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
@@ -163,6 +167,96 @@ describe("ModuleLiveSyncMain", () => {
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false, { strictContentCheck: true });
|
||||
});
|
||||
|
||||
it("passes the strict content check setting for ordinary Obsidian start-up", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: {},
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: true,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
|
||||
});
|
||||
|
||||
it.each(["server", "browser"])(
|
||||
"keeps the strict content check on non-Obsidian host start-up (%s)",
|
||||
async (platform) => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: true },
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => platform },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps explicit recovery startup options strict when the setting is off", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: {
|
||||
ignoreSuspending: false,
|
||||
continueOnFileFailure: false,
|
||||
},
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, false, { strictContentCheck: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import { $msg as translate } from "@/common/translation";
|
||||
import type {
|
||||
JoinedDeviceRow,
|
||||
JoinedDevicesDialogOptions,
|
||||
JoinedDevicesView,
|
||||
} from "@/serviceFeatures/joinedDevicesDialogTypes";
|
||||
|
||||
const { setResult, getInitialData }: GuestDialogProps<"close", JoinedDevicesDialogOptions> = $props();
|
||||
let options = $state<JoinedDevicesDialogOptions>();
|
||||
let view = $state<JoinedDevicesView>();
|
||||
let isRefreshing = $state(false);
|
||||
let confirmingId = $state<string>();
|
||||
let forgettingId = $state<string>();
|
||||
let statusMessage = $state("");
|
||||
let errorMessage = $state("");
|
||||
let disposed = false;
|
||||
const busy = $derived(isRefreshing || forgettingId !== undefined);
|
||||
const title = translate("Joined devices");
|
||||
|
||||
onMount(() => {
|
||||
options = getInitialData?.();
|
||||
if (!options) {
|
||||
errorMessage = translate(
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
void refreshView();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disposed = true;
|
||||
options?.cancel();
|
||||
});
|
||||
|
||||
async function refreshView(afterForget = false) {
|
||||
const dialogOptions = options;
|
||||
if (!dialogOptions || disposed || isRefreshing || (forgettingId !== undefined && !afterForget)) return;
|
||||
|
||||
isRefreshing = true;
|
||||
errorMessage = "";
|
||||
if (!afterForget) {
|
||||
statusMessage = "";
|
||||
confirmingId = undefined;
|
||||
}
|
||||
try {
|
||||
const refreshedView = await dialogOptions.refresh();
|
||||
if (disposed) return;
|
||||
view = refreshedView;
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
errorMessage = translate(
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConfirmation(device: JoinedDeviceRow) {
|
||||
if (busy || device.self) return;
|
||||
confirmingId = confirmingId === device.id ? undefined : device.id;
|
||||
errorMessage = "";
|
||||
statusMessage = "";
|
||||
}
|
||||
|
||||
async function forgetDevice(device: JoinedDeviceRow) {
|
||||
const dialogOptions = options;
|
||||
if (!dialogOptions || disposed || busy || device.self || confirmingId !== device.id) return;
|
||||
|
||||
forgettingId = device.id;
|
||||
confirmingId = undefined;
|
||||
errorMessage = "";
|
||||
statusMessage = "";
|
||||
try {
|
||||
const result = await dialogOptions.forget(device.id);
|
||||
if (disposed) return;
|
||||
|
||||
if (result === "forgotten") {
|
||||
statusMessage = translate("The device record was forgotten.");
|
||||
await refreshView(true);
|
||||
} else if (result === "not-found") {
|
||||
statusMessage = translate("The device record was already absent.");
|
||||
await refreshView(true);
|
||||
} else if (result === "conflict") {
|
||||
errorMessage = translate(
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again."
|
||||
);
|
||||
} else {
|
||||
errorMessage = translate(
|
||||
"This remote does not support conditional updates, so the device record could not be removed."
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
errorMessage = translate(
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) forgettingId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLastConnected(timestamp: number | undefined) {
|
||||
if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) return translate("Not recorded");
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return translate("Not recorded");
|
||||
return date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader {title} />
|
||||
<div class="joined-devices-dialog">
|
||||
<p>
|
||||
{translate("This list shows device information recorded by the remote. It does not show whether a device is online now.")}
|
||||
</p>
|
||||
|
||||
<InfoNote
|
||||
message="Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable."
|
||||
/>
|
||||
|
||||
{#if view}
|
||||
{#if !view.milestonePresent}
|
||||
<p class="state-message">{translate("This remote does not currently have a record of joined devices.")}</p>
|
||||
{/if}
|
||||
{#if view.cleaned}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if view.locked}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="error-message" role="alert">{errorMessage}</p>
|
||||
{/if}
|
||||
{#if statusMessage}
|
||||
<p class="status-message" role="status">{statusMessage}</p>
|
||||
{/if}
|
||||
{#if isRefreshing && !view}
|
||||
<p role="status">{translate("Loading joined devices…")}</p>
|
||||
{/if}
|
||||
|
||||
{#if view}
|
||||
{#if view.devices.length > 0}
|
||||
<ul class="device-list">
|
||||
{#each view.devices as device (device.id)}
|
||||
<li class="device-card">
|
||||
<div class="device-heading">
|
||||
<div class="device-identity">
|
||||
{#if device.self}
|
||||
<span class="device-badge">{translate("This device")}</span>
|
||||
{/if}
|
||||
<code class="device-id">{device.id}</code>
|
||||
</div>
|
||||
<div class="device-badges">
|
||||
<span class="device-badge">
|
||||
{device.accepted ? translate("Accepted") : translate("Not accepted")}
|
||||
</span>
|
||||
{#if device.lockedOut}
|
||||
<span class="device-badge device-badge-warning">{translate("Locked out")}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if device.self && device.lockedOut}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This device has been locked out. Use the existing recovery process to join again."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<dl class="device-details">
|
||||
<div>
|
||||
<dt>{translate("Platform")}</dt>
|
||||
<dd>{device.platform || translate("Unknown platform")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("Plug-in version")}</dt>
|
||||
<dd>{device.pluginVersion || translate("Not recorded")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("App version")}</dt>
|
||||
<dd>{device.appVersion || translate("Not recorded")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("Last recorded")}</dt>
|
||||
<dd>{formatLastConnected(device.lastConnected)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if !device.self}
|
||||
{#if confirmingId === device.id}
|
||||
<div class="forget-confirmation">
|
||||
<p><strong>{translate("Forget this device record?")}</strong></p>
|
||||
<p>
|
||||
{translate("This removes only this device's record from the remote.")}
|
||||
</p>
|
||||
<p>
|
||||
{translate(
|
||||
"Forgetting the record does not delete Vault files or revoke credentials."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{view.locked
|
||||
? translate(
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again."
|
||||
)
|
||||
: translate("This remote is unlocked. A device that returns can register again.")}
|
||||
</p>
|
||||
<div class="device-actions">
|
||||
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
|
||||
{translate("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
class="mod-warning"
|
||||
disabled={busy}
|
||||
onclick={() => void forgetDevice(device)}
|
||||
>
|
||||
{translate("Forget device")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="device-actions">
|
||||
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
|
||||
{forgettingId === device.id
|
||||
? translate("Forgetting…")
|
||||
: translate("Forget device")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if !isRefreshing}
|
||||
<p>{translate("No joined devices are recorded on this remote.")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="dialog-controls">
|
||||
<button disabled={busy} onclick={() => void refreshView()}>{translate("Refresh")}</button>
|
||||
<button onclick={() => setResult("close")}>{translate("Close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.joined-devices-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.state-message,
|
||||
.forget-confirmation {
|
||||
border-left: 3px solid var(--interactive-accent);
|
||||
padding: var(--size-4-2);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
gap: var(--size-4-2);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: var(--size-4-3);
|
||||
}
|
||||
|
||||
.device-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-identity,
|
||||
.device-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-id {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-secondary);
|
||||
padding: 0 var(--size-4-2);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.device-badge-warning {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: var(--size-4-2) var(--size-4-4);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-details > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.forget-confirmation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-actions,
|
||||
.dialog-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.device-card {
|
||||
padding: var(--size-4-2);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
@@ -11,7 +13,6 @@ import {
|
||||
ObsidianTweakValueService,
|
||||
ObsidianTestService,
|
||||
ObsidianDatabaseEventService,
|
||||
ObsidianConfigService,
|
||||
ObsidianKeyValueDBService,
|
||||
ObsidianControlService,
|
||||
} from "./ObsidianServices";
|
||||
@@ -46,6 +47,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
const setting = new ObsidianSettingService(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
});
|
||||
const appLifecycle = new ObsidianAppLifecycleService(context, {
|
||||
settingService: setting,
|
||||
@@ -85,10 +87,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
databaseEvents: databaseEvents,
|
||||
vault: vault,
|
||||
});
|
||||
const config = new ObsidianConfigService(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const config = setting.config;
|
||||
const replicator = new ObsidianReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
|
||||
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
|
||||
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
|
||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
@@ -58,10 +57,7 @@ export class ObsidianReplicatorService extends InjectableReplicatorService<Obsid
|
||||
super(context, withSleepPreference(dependencies));
|
||||
}
|
||||
|
||||
async runBoundedLocalApplicationActivity<T>(
|
||||
task: () => T | PromiseLike<T>,
|
||||
options?: ActivityOptions
|
||||
): Promise<T> {
|
||||
async runBoundedLocalApplicationActivity<T>(task: () => T | PromiseLike<T>, options?: ActivityOptions): Promise<T> {
|
||||
this.boundedLocalApplicationActivityCount.value++;
|
||||
try {
|
||||
return this.dependencies.activityRunner
|
||||
@@ -84,7 +80,6 @@ export class ObsidianConflictService extends InjectableConflictService<ObsidianS
|
||||
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
|
||||
// InjectableTestService
|
||||
export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {}
|
||||
export class ObsidianConfigService extends ConfigServiceBrowserCompat<ObsidianServiceContext> {}
|
||||
|
||||
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
||||
@@ -27,19 +26,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
protected setItem(key: string, value: string) {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.setItem(key, value);
|
||||
}
|
||||
protected getItem(key: string): string {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.getItem(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string): void {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
compatGlobal.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return await this.context.liveSyncPlugin.saveData(data);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
||||
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
|
||||
|
||||
function createSettingService(
|
||||
vaultName: string,
|
||||
deviceValues: MemoryValueStore
|
||||
): ObsidianSettingService<ObsidianServiceContext> {
|
||||
const context = createServiceContext() as unknown as ObsidianServiceContext;
|
||||
const APIService = {
|
||||
addLog: () => undefined,
|
||||
getSystemVaultName: () => vaultName,
|
||||
} as unknown as IAPIService;
|
||||
return new ObsidianSettingService(context, { APIService, deviceValues });
|
||||
}
|
||||
|
||||
describe("normaliseObsidianSettingsData", () => {
|
||||
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
|
||||
@@ -12,4 +29,26 @@ describe("normaliseObsidianSettingsData", () => {
|
||||
|
||||
expect(normaliseObsidianSettingsData(settings)).toBe(settings);
|
||||
});
|
||||
|
||||
it("keeps injected device values independent from each Vault-scoped configuration store", async () => {
|
||||
const firstValues = new MemoryValueStore();
|
||||
const secondValues = new MemoryValueStore();
|
||||
const firstSetting = createSettingService("first-vault", firstValues);
|
||||
const secondSetting = createSettingService("second-vault", secondValues);
|
||||
|
||||
await firstSetting.deviceValues.setValue("shared-key", "device value");
|
||||
await firstSetting.config.setValue("shared-key", "first config");
|
||||
await secondSetting.config.setValue("shared-key", "second config");
|
||||
|
||||
expect(firstSetting.deviceValues).toBe(firstValues);
|
||||
expect(secondSetting.deviceValues).toBe(secondValues);
|
||||
expect(firstSetting.deviceValues).not.toBe(secondSetting.deviceValues);
|
||||
expect(firstSetting.config).not.toBe(firstValues);
|
||||
expect(firstSetting.config).not.toBe(secondSetting.config);
|
||||
await expect(firstSetting.deviceValues.getValue("shared-key")).resolves.toBe("device value");
|
||||
await expect(firstSetting.config.getValue("shared-key")).resolves.toBe("first config");
|
||||
await expect(secondSetting.config.getValue("shared-key")).resolves.toBe("second config");
|
||||
await expect(firstValues.getValue("first-vault-shared-key")).resolves.toBe("first config");
|
||||
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
@@ -8,7 +8,7 @@ import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy
|
||||
import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
config: IConfigService;
|
||||
replicator: ReplicatorService<T>;
|
||||
APIService: IAPIService;
|
||||
control: IControlService;
|
||||
|
||||
@@ -44,17 +44,17 @@ export class CompatibilityReviewController {
|
||||
return this._initialised;
|
||||
}
|
||||
|
||||
private readAcknowledgedVersion(): string | null {
|
||||
private async readAcknowledgedVersion(): Promise<string | null> {
|
||||
const setting = this.core.services.setting;
|
||||
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
||||
const currentMarker = await this.core.services.config.getValue(DATABASE_COMPATIBILITY_VERSION_KEY, "");
|
||||
if (currentMarker) return currentMarker;
|
||||
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
|
||||
const legacyMarker = await setting.deviceValues.getValue(legacyKey, "");
|
||||
if (!legacyMarker) return null;
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
return legacyMarker;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class CompatibilityReviewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acknowledgedVersion = this.readAcknowledgedVersion();
|
||||
const acknowledgedVersion = await this.readAcknowledgedVersion();
|
||||
const evaluation = evaluateCompatibilityPause({
|
||||
acknowledgedVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
@@ -85,7 +85,7 @@ export class CompatibilityReviewController {
|
||||
|
||||
this.pause = evaluation.pause;
|
||||
if (evaluation.initialiseAcknowledgedVersion) {
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
this._initialised = true;
|
||||
return true;
|
||||
}
|
||||
@@ -115,9 +115,9 @@ export class CompatibilityReviewController {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
await this.core.services.control.applySettings();
|
||||
|
||||
@@ -33,13 +33,32 @@ function createFixture(
|
||||
} = {}
|
||||
) {
|
||||
const local = new Map<string, string>();
|
||||
const deviceValues = new Map<string, string>();
|
||||
if (options.marker !== undefined && options.marker !== null) {
|
||||
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
|
||||
}
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
deviceValues.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
local.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
local.delete(key);
|
||||
}),
|
||||
};
|
||||
const deviceValueStore = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => deviceValues.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
deviceValues.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
deviceValues.delete(key);
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
versionUpFlash: options.versionUpFlash ?? "",
|
||||
isConfigured: options.isConfigured ?? true,
|
||||
@@ -49,15 +68,13 @@ function createFixture(
|
||||
const setting = {
|
||||
currentSettings: () => settings,
|
||||
getSettingsMigrationState: () => migrationState(options.migration),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
|
||||
deleteDeviceLocalConfig: (key: string) => local.delete(key),
|
||||
deviceValues: deviceValueStore,
|
||||
saveSettingData,
|
||||
};
|
||||
const core = {
|
||||
services: {
|
||||
setting,
|
||||
config,
|
||||
vault: { getVaultName: () => "Test Vault" },
|
||||
control: { applySettings },
|
||||
},
|
||||
@@ -69,7 +86,18 @@ function createFixture(
|
||||
clearReminder: vi.fn(),
|
||||
};
|
||||
const controller = new CompatibilityReviewController(core, ui, 12);
|
||||
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
|
||||
return {
|
||||
controller,
|
||||
ui,
|
||||
local,
|
||||
deviceValues,
|
||||
config,
|
||||
deviceValueStore,
|
||||
legacyKey,
|
||||
settings,
|
||||
saveSettingData,
|
||||
applySettings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compatibility review controller", () => {
|
||||
@@ -196,10 +224,96 @@ describe("compatibility review controller", () => {
|
||||
await fixture.controller.initialise();
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
});
|
||||
|
||||
it("saves the scoped marker before deleting the legacy device value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const initialising = fixture.controller.initialise();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.deviceValueStore.delete).not.toHaveBeenCalled();
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
|
||||
finishSave();
|
||||
await initialising;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.initialised).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a failed marker read as an absent value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
fixture.config.getValue.mockRejectedValueOnce(new Error("marker read failed"));
|
||||
|
||||
await expect(fixture.controller.initialise()).rejects.toThrow("marker read failed");
|
||||
|
||||
expect(fixture.deviceValueStore.getValue).not.toHaveBeenCalled();
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the compatibility pause blocked until its async marker write succeeds", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const reviewing = fixture.controller.openReview();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
finishSave();
|
||||
await reviewing;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a storage-failed acknowledgement blocked and retryable", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
fixture.config.setValue.mockRejectedValueOnce(new Error("marker write failed"));
|
||||
|
||||
await expect(fixture.controller.openReview()).rejects.toThrow("marker write failed");
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JoinedDevice, JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { JoinedDevicesView } from "./joinedDevicesDialogTypes";
|
||||
|
||||
/** Compare recognised SemVer values; unknown development labels have no ordering. */
|
||||
export function comparePluginVersions(left: string, right: string): number | undefined {
|
||||
const parse = (value: string) => {
|
||||
const match =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
||||
value
|
||||
);
|
||||
if (!match) return undefined;
|
||||
const prerelease = match[4]?.split(".");
|
||||
if (prerelease?.some((part) => /^0\d+$/.test(part))) return undefined;
|
||||
return { core: match.slice(1, 4).map(BigInt), prerelease };
|
||||
};
|
||||
const a = parse(left);
|
||||
const b = parse(right);
|
||||
if (!a || !b) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
|
||||
}
|
||||
if (!a.prerelease || !b.prerelease) return a.prerelease ? -1 : b.prerelease ? 1 : 0;
|
||||
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
||||
const x = a.prerelease[i];
|
||||
const y = b.prerelease[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
if (x === y) continue;
|
||||
const xn = /^\d+$/.test(x);
|
||||
const yn = /^\d+$/.test(y);
|
||||
if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1;
|
||||
if (xn !== yn) return xn ? -1 : 1;
|
||||
return x > y ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function joinedDevicesView(
|
||||
snapshot: JoinedDevicesSnapshot,
|
||||
self: Omit<JoinedDevice, "accepted">
|
||||
): JoinedDevicesView {
|
||||
const ownRecord = snapshot.devices.find((device) => device.id === self.id);
|
||||
const ownDevice: JoinedDevice = { ...ownRecord, ...self, accepted: ownRecord?.accepted ?? false };
|
||||
return {
|
||||
...snapshot,
|
||||
devices: [ownDevice, ...snapshot.devices.filter((device) => device.id !== self.id)].map((device) => ({
|
||||
...device,
|
||||
self: device.id === self.id,
|
||||
lockedOut: snapshot.locked && !device.accepted,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function differentDeviceVersions(snapshot: JoinedDevicesSnapshot, ownId: string, ownVersion: string) {
|
||||
const newer = new Set<string>();
|
||||
const older = new Set<string>();
|
||||
for (const device of snapshot.devices) {
|
||||
if (device.id === ownId || !device.pluginVersion) continue;
|
||||
const comparison = comparePluginVersions(device.pluginVersion, ownVersion);
|
||||
if (comparison !== undefined && comparison > 0) newer.add(device.pluginVersion);
|
||||
if (comparison !== undefined && comparison < 0) older.add(device.pluginVersion);
|
||||
}
|
||||
return { newer: [...newer].sort(), older: [...older].sort() };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { comparePluginVersions, differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
|
||||
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const empty: JoinedDevicesSnapshot = { milestonePresent: false, locked: false, cleaned: false, devices: [] };
|
||||
|
||||
describe("joined device presentation", () => {
|
||||
it("always shows this device first, including before its remote record exists", () => {
|
||||
const view = joinedDevicesView(
|
||||
{ ...empty, devices: [{ id: "another", accepted: true }] },
|
||||
{ id: "self", platform: "ios", pluginVersion: "1.0.39" }
|
||||
);
|
||||
expect(view.devices.map((device) => device.id)).toEqual(["self", "another"]);
|
||||
expect(view.devices[0]).toMatchObject({ self: true, accepted: false, lockedOut: false, platform: "ios" });
|
||||
});
|
||||
it("keeps the remote last-recorded date and acceptance while showing current local versions", () => {
|
||||
const view = joinedDevicesView(
|
||||
{
|
||||
...empty,
|
||||
milestonePresent: true,
|
||||
locked: true,
|
||||
devices: [
|
||||
{ id: "other", accepted: true },
|
||||
{ id: "self", accepted: false, lastConnected: 123, pluginVersion: "1.0.0" },
|
||||
],
|
||||
},
|
||||
{ id: "self", pluginVersion: "1.1.0" }
|
||||
);
|
||||
expect(view.devices[0]).toMatchObject({
|
||||
self: true,
|
||||
lockedOut: true,
|
||||
lastConnected: 123,
|
||||
pluginVersion: "1.1.0",
|
||||
});
|
||||
expect(view.devices[1].lockedOut).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version advisories", () => {
|
||||
it.each([
|
||||
["1.10.0", "1.9.9", 1],
|
||||
["1.0.0", "1.0.0-rc.1", 1],
|
||||
["1.0.0-beta.2", "1.0.0-beta.10", -1],
|
||||
["1.0.0-1", "1.0.0-alpha", -1],
|
||||
["1.0.0-alpha", "1.0.0-alpha.1", -1],
|
||||
["1.0.0+first", "1.0.0+second", 0],
|
||||
["99999999999999999.0.0", "99999999999999998.0.0", 1],
|
||||
])("compares %s with %s", (a, b, result) => expect(comparePluginVersions(a, b)).toBe(result));
|
||||
it.each(["", "development", "1.0", "01.0.0", "1.0.0-01", "v1.0.0"])(
|
||||
"does not order unknown version %s",
|
||||
(version) => {
|
||||
expect(comparePluginVersions(version, "1.0.0")).toBeUndefined();
|
||||
expect(comparePluginVersions("1.0.0", version)).toBeUndefined();
|
||||
}
|
||||
);
|
||||
it("excludes this device and unknown versions, and deduplicates versions", () => {
|
||||
const snapshot = {
|
||||
...empty,
|
||||
devices: [
|
||||
{ id: "self", accepted: false, pluginVersion: "9.0.0" },
|
||||
{ id: "a", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "b", accepted: false, pluginVersion: "2.0.0" },
|
||||
{ id: "c", accepted: false, pluginVersion: "0.9.0" },
|
||||
{ id: "d", accepted: true, pluginVersion: "custom" },
|
||||
],
|
||||
};
|
||||
expect(differentDeviceVersions(snapshot, "self", "1.0.0")).toEqual({ newer: ["2.0.0"], older: ["0.9.0"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { declareUICommand } from "@vrtmrz/livesync-commonlib/ui-commands";
|
||||
|
||||
export const OPEN_JOINED_DEVICES = declareUICommand<void, "closed" | "already-open" | "unavailable">(
|
||||
"livesync:joined-devices"
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ForgetJoinedDeviceResult, JoinedDevice } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export interface JoinedDeviceRow extends JoinedDevice {
|
||||
readonly self: boolean;
|
||||
readonly lockedOut: boolean;
|
||||
}
|
||||
|
||||
export interface JoinedDevicesView {
|
||||
readonly milestonePresent: boolean;
|
||||
readonly locked: boolean;
|
||||
readonly cleaned: boolean;
|
||||
readonly devices: readonly JoinedDeviceRow[];
|
||||
}
|
||||
|
||||
export interface JoinedDevicesDialogOptions {
|
||||
refresh(): Promise<JoinedDevicesView>;
|
||||
forget(id: string): Promise<ForgetJoinedDeviceResult>;
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -63,28 +63,27 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
}
|
||||
|
||||
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
|
||||
if (!saved) return undefined;
|
||||
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
|
||||
try {
|
||||
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
|
||||
if (stage1) {
|
||||
const remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
if (remembered) return remembered;
|
||||
}
|
||||
if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
} catch {
|
||||
// Clear below; the saved choice is optional and can be rebuilt by asking again.
|
||||
}
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
if (remembered) return remembered;
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
}
|
||||
|
||||
async function confirmTranslatedChoice(
|
||||
@@ -104,7 +103,7 @@ async function confirmTranslatedChoice(
|
||||
export async function askSimpleFetchMode(
|
||||
host: NecessaryServices<"UI" | "setting", never>
|
||||
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
|
||||
const remembered = getRememberedSimpleFetchMode(host);
|
||||
const remembered = await getRememberedSimpleFetchMode(host);
|
||||
if (remembered) return remembered;
|
||||
|
||||
const msg = $msg(
|
||||
@@ -169,7 +168,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -198,7 +197,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -234,18 +233,18 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return undefined;
|
||||
}
|
||||
const result = await askSimpleFetchMode(host);
|
||||
if (result === "cancelled") {
|
||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
if (result === "aborted") {
|
||||
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +255,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
|
||||
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
|
||||
@@ -295,7 +294,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
}
|
||||
await host.serviceModules.rebuilder.finishRebuild();
|
||||
await cleanupFlag();
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -10,19 +10,26 @@ const KEEP_LOCAL = "Keep local files even if not on remote";
|
||||
function createHost(savedChoice?: string) {
|
||||
const smallConfig = new Map<string, string>();
|
||||
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
};
|
||||
const confirmWithMessage =
|
||||
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
|
||||
const host = {
|
||||
services: {
|
||||
UI: { confirm: { confirmWithMessage } },
|
||||
setting: {
|
||||
getSmallConfig: (key: string) => smallConfig.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => smallConfig.set(key, value),
|
||||
deleteSmallConfig: (key: string) => smallConfig.delete(key),
|
||||
config,
|
||||
},
|
||||
},
|
||||
} as unknown as NecessaryServices<"UI" | "setting", never>;
|
||||
return { host, confirmWithMessage, smallConfig };
|
||||
return { host, confirmWithMessage, smallConfig, config };
|
||||
}
|
||||
|
||||
afterEach(() => setLang("def"));
|
||||
@@ -50,6 +57,75 @@ describe("Fast Setup translated choices", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the saved choice before returning it to the fetch workflow", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost();
|
||||
let markSaveStarted!: () => void;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
markSaveStarted = resolve;
|
||||
});
|
||||
let finishSave!: () => void;
|
||||
const save = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
config.setValue.mockImplementation(async (key, value) => {
|
||||
markSaveStarted();
|
||||
await save;
|
||||
smallConfig.set(key, value);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await saveStarted;
|
||||
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
finishSave();
|
||||
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
|
||||
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
|
||||
stage1: REMOTE_WINS,
|
||||
stage2: KEEP_LOCAL,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
|
||||
const { host, confirmWithMessage, config } = createHost();
|
||||
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
|
||||
|
||||
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for an invalid optional choice to be deleted before asking again", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deletion = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
config.delete.mockImplementation(async (key) => {
|
||||
markDeleteStarted();
|
||||
await deletion;
|
||||
smallConfig.delete(key);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await deleteStarted;
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
|
||||
|
||||
finishDelete();
|
||||
await expect(asking).resolves.toBe("cancelled");
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
|
||||
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
|
||||
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
|
||||
|
||||
@@ -107,13 +107,15 @@ const createSettingServiceMock = () => {
|
||||
}),
|
||||
suspendAllSync: vi.fn(() => Promise.resolve()),
|
||||
suspendExtraSync: vi.fn(() => Promise.resolve()),
|
||||
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""),
|
||||
setSmallConfig: vi.fn((key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
deleteSmallConfig: vi.fn((key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
config: {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -785,7 +787,7 @@ describe("Red Flag Feature", () => {
|
||||
);
|
||||
|
||||
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -830,7 +832,7 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
||||
@@ -892,7 +894,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
||||
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("should return false and cleanup when quick flow is cancelled", async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface IncompleteDocumentsDependencies {
|
||||
readonly localDatabase: Pick<LiveSyncLocalDB, "findAllNormalDocs" | "getDBEntryFromMeta">;
|
||||
readonly getPath: StartupPathReader;
|
||||
readonly isTargetFile: (path: string) => Promise<boolean>;
|
||||
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "getFileStub">;
|
||||
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "statHidden" | "getFileStub">;
|
||||
readonly fileHandler: Pick<IFileHandler, "storeFileToDB">;
|
||||
readonly keyValueDB: Pick<KeyValueDatabase, "get" | "set">;
|
||||
readonly noticeGroups: NoticeGroups;
|
||||
@@ -94,16 +94,11 @@ export async function checkIncompleteDocuments(
|
||||
}
|
||||
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
|
||||
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await dependencies.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
const storageStat = await dependencies.storageAccess.statHidden(path);
|
||||
if (!storageStat || storageStat.type !== "file") {
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
const sizeOnStorage = storageFileContent.byteLength;
|
||||
let sizeOnStorage = storageStat.size;
|
||||
const recordedSize = doc.size;
|
||||
const docBlob = readAsBlob(doc);
|
||||
const actualSize = docBlob.size;
|
||||
@@ -113,6 +108,19 @@ export async function checkIncompleteDocuments(
|
||||
sizeOnStorage !== recordedSize ||
|
||||
isConflicted
|
||||
) {
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await dependencies.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
sizeOnStorage = storageFileContent.byteLength;
|
||||
// The file may have changed since stat, so recheck using the bytes read.
|
||||
if (recordedSize === actualSize && sizeOnStorage === actualSize && !isConflicted) {
|
||||
continue;
|
||||
}
|
||||
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
|
||||
errorFiles.push({
|
||||
path,
|
||||
@@ -125,6 +133,17 @@ export async function checkIncompleteDocuments(
|
||||
Logger(
|
||||
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
|
||||
);
|
||||
} else {
|
||||
const currentStat = await dependencies.storageAccess.statHidden(path);
|
||||
if (
|
||||
!currentStat ||
|
||||
currentStat.type !== "file" ||
|
||||
currentStat.size !== storageStat.size ||
|
||||
currentStat.mtime !== storageStat.mtime ||
|
||||
currentStat.ctime !== storageStat.ctime
|
||||
) {
|
||||
throw new Error("Storage changed during the incomplete document check");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errorFiles.length == 0) {
|
||||
|
||||
@@ -99,6 +99,7 @@ function createDependencies(findAllNormalDocs: FindAllNormalDocs = noDocuments,
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
storageAccess: {
|
||||
readHiddenFileBinary,
|
||||
statHidden: vi.fn(async (path: string) => getFixture(path)?.stub.stat ?? null),
|
||||
getFileStub,
|
||||
},
|
||||
fileHandler: { storeFileToDB },
|
||||
@@ -157,6 +158,74 @@ describe("checkIncompleteDocuments", () => {
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("does not read healthy storage contents only to determine their size", async () => {
|
||||
const healthy = documentFixture("healthy.md", { storageContent: "abc" });
|
||||
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("does not mark a scan complete when storage changes during the size check", async () => {
|
||||
const healthy = documentFixture("changing.md", { storageContent: "abc" });
|
||||
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
||||
vi.mocked(fixture.dependencies.storageAccess.statHidden)
|
||||
.mockResolvedValueOnce(healthy.stub.stat)
|
||||
.mockResolvedValueOnce({ ...healthy.stub.stat, mtime: 3 });
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).rejects.toThrow("Storage changed");
|
||||
|
||||
expect(fixture.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer repair when all sizes agree after reading storage", async () => {
|
||||
const healthy = documentFixture("settled.md", { storageContent: "abc" });
|
||||
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
||||
vi.mocked(fixture.dependencies.storageAccess.statHidden).mockResolvedValueOnce({ ...healthy.stub.stat, size: 4 });
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("settled.md");
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("uses the bytes read from storage when a size mismatch remains", async () => {
|
||||
const mismatched = documentFixture("changing.md", { recordedSize: 3, storageContent: "abcde" });
|
||||
const fixture = createDependencies(documentsFrom([mismatched]), [mismatched]);
|
||||
vi.mocked(fixture.dependencies.storageAccess.statHidden).mockResolvedValueOnce({
|
||||
...mismatched.stub.stat,
|
||||
size: 4,
|
||||
});
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("changing.md");
|
||||
expect(fixture.askSelectStringDialogue).toHaveBeenCalledOnce();
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(
|
||||
expect.stringContaining("changing.md (M: 3, A: 3, S: 5)")
|
||||
);
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reads conflicted storage for the existing repair dialogue", async () => {
|
||||
const conflicted = documentFixture("conflicted.md", { storageContent: "abc", conflicts: ["2-other"] });
|
||||
const fixture = createDependencies(documentsFrom([conflicted]), [conflicted]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("conflicted.md");
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("repairs a recoverable document when FIX is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
NODEINFO_DOCID,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type EntryNodeInfo,
|
||||
type RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS, type JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
||||
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "@/common/replicatorConfigurationIdentity";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
|
||||
import { differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
|
||||
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
|
||||
|
||||
type JoinedDevicesHost = NecessaryServices<
|
||||
"API" | "UI" | "setting" | "database" | "replicator" | "replication" | "appLifecycle",
|
||||
never
|
||||
>;
|
||||
const noticeGroup = "joined-device-versions";
|
||||
|
||||
function remoteIdentity(settings: RemoteDBSettings): string | undefined {
|
||||
if (settings.remoteType === REMOTE_COUCHDB && settings.couchDB_URI && settings.couchDB_DBNAME) {
|
||||
return getCouchDBReplicatorConfigurationIdentity(settings);
|
||||
}
|
||||
if (settings.remoteType === REMOTE_MINIO && settings.endpoint && settings.bucket) {
|
||||
return getObjectStorageReplicatorConfigurationIdentity(settings);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Own the dialogue, advisory Notices, and their per-session state for one host. */
|
||||
export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: ObsidianNoticeGroups): void {
|
||||
const { services } = host;
|
||||
let disposed = false;
|
||||
let active: AbortController | undefined;
|
||||
let checking = false;
|
||||
let lastCheck: { identity: string; at: number } | undefined;
|
||||
const notified = new Map<string, Set<string>>();
|
||||
const available = () =>
|
||||
!disposed &&
|
||||
services.database.isDatabaseReady() &&
|
||||
remoteIdentity(services.setting.currentSettings()) !== undefined;
|
||||
|
||||
const notify = (snapshot: JoinedDevicesSnapshot, ownId: string, identity: string) => {
|
||||
if (disposed || identity !== remoteIdentity(services.setting.currentSettings())) return;
|
||||
const version = services.API.getPluginVersion();
|
||||
const differences = differentDeviceVersions(snapshot, ownId, version);
|
||||
const seen = notified.get(identity) ?? new Set<string>();
|
||||
notified.set(identity, seen);
|
||||
let updated = false;
|
||||
for (const direction of ["newer", "older"] as const) {
|
||||
const versions = differences[direction];
|
||||
if (versions.length === 0) continue;
|
||||
const key = JSON.stringify([version, direction, versions]);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
notices.setItem(noticeGroup, direction, {
|
||||
message:
|
||||
direction === "newer"
|
||||
? $msg("Newer Self-hosted LiveSync versions detected: ${versions}.", {
|
||||
versions: versions.join(", "),
|
||||
})
|
||||
: $msg("Older Self-hosted LiveSync versions detected: ${versions}.", {
|
||||
versions: versions.join(", "),
|
||||
}),
|
||||
});
|
||||
updated = true;
|
||||
}
|
||||
if (!updated) return;
|
||||
notices.removeItem(noticeGroup, "review");
|
||||
notices.setItem(noticeGroup, "review", {
|
||||
message: $msg("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended."),
|
||||
action: {
|
||||
label: $msg("Review devices"),
|
||||
onSelect: () => {
|
||||
if (!disposed && identity === remoteIdentity(services.setting.currentSettings())) {
|
||||
void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
notices.finish(noticeGroup, { durationMs: 15000 });
|
||||
};
|
||||
|
||||
const createOperations = (controller: AbortController): JoinedDevicesDialogOptions => {
|
||||
const settings = { ...services.setting.currentSettings() };
|
||||
const identity = remoteIdentity(settings);
|
||||
const database = services.database.localDatabase;
|
||||
const assertCurrent = () => {
|
||||
if (
|
||||
disposed ||
|
||||
controller.signal.aborted ||
|
||||
!identity ||
|
||||
identity !== remoteIdentity(services.setting.currentSettings()) ||
|
||||
!services.database.isDatabaseReady() ||
|
||||
services.database.localDatabase !== database
|
||||
) {
|
||||
throw new Error("Joined devices are no longer available for this dialogue");
|
||||
}
|
||||
};
|
||||
const ownId = async () => {
|
||||
assertCurrent();
|
||||
const info = await database.localDatabase.get<EntryNodeInfo>(NODEINFO_DOCID);
|
||||
assertCurrent();
|
||||
if (typeof info.nodeid !== "string" || !info.nodeid) throw new Error("Device identity is unavailable");
|
||||
return info.nodeid;
|
||||
};
|
||||
return {
|
||||
refresh: async () => {
|
||||
const id = await ownId();
|
||||
const resource = await services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
assertCurrent();
|
||||
const snapshot = await resource.read();
|
||||
assertCurrent();
|
||||
notify(snapshot, id, identity!);
|
||||
return joinedDevicesView(snapshot, {
|
||||
id,
|
||||
platform: services.API.getPlatform(),
|
||||
pluginVersion: services.API.getPluginVersion(),
|
||||
appVersion: services.API.getAppVersion(),
|
||||
});
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
},
|
||||
forget: async (id) => {
|
||||
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
|
||||
const resource = await services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
assertCurrent();
|
||||
const result = await resource.forget(id);
|
||||
assertCurrent();
|
||||
return result;
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
},
|
||||
cancel: () => controller.abort(),
|
||||
};
|
||||
};
|
||||
|
||||
const unregister = services.UI.commands.register(OPEN_JOINED_DEVICES, async () => {
|
||||
if (!available()) return "unavailable";
|
||||
if (active) return "already-open";
|
||||
const controller = new AbortController();
|
||||
active = controller;
|
||||
try {
|
||||
await services.UI.dialogManager.open<"close", JoinedDevicesDialogOptions>(
|
||||
JoinedDevicesDialog,
|
||||
createOperations(controller)
|
||||
);
|
||||
} catch {
|
||||
// The host may close its dialogues during unload.
|
||||
} finally {
|
||||
controller.abort();
|
||||
if (active === controller) active = undefined;
|
||||
}
|
||||
return "closed";
|
||||
});
|
||||
|
||||
const checkVersions = () => {
|
||||
if (!available() || checking) return;
|
||||
const identity = remoteIdentity(services.setting.currentSettings())!;
|
||||
if (lastCheck?.identity === identity && Date.now() - lastCheck.at < 60000) return;
|
||||
lastCheck = { identity, at: Date.now() };
|
||||
checking = true;
|
||||
const controller = new AbortController();
|
||||
void Promise.resolve()
|
||||
.then(() => createOperations(controller).refresh())
|
||||
.catch(() => {
|
||||
// Advisory inspection never changes the outcome of synchronisation.
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort();
|
||||
checking = false;
|
||||
});
|
||||
};
|
||||
const stopBeforeReplicate = services.replication.onBeforeReplicate.addHandler(() => {
|
||||
checkVersions();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "joined-devices",
|
||||
name: $msg("Review devices"),
|
||||
icon: "monitor-smartphone",
|
||||
checkCallback: (checking) => {
|
||||
if (!available()) return false;
|
||||
if (!checking) void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
disposed = true;
|
||||
active?.abort();
|
||||
unregister();
|
||||
stopBeforeReplicate();
|
||||
notices.hide(noticeGroup);
|
||||
notified.clear();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UICommandRegistry } from "@vrtmrz/livesync-commonlib/ui-commands";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
|
||||
vi.mock("@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string, values: Record<string, string> = {}) =>
|
||||
message.replace(/\$\{([^}]+)\}/g, (placeholder, key: string) => values[key] ?? placeholder),
|
||||
}));
|
||||
import { useJoinedDevicesFeature } from "./useJoinedDevices";
|
||||
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
|
||||
|
||||
function fixture() {
|
||||
const settings = {
|
||||
...createNewVaultSettings(),
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
};
|
||||
const initialise: Array<() => Promise<boolean>> = [];
|
||||
const unload: Array<() => Promise<boolean>> = [];
|
||||
const before: Array<() => Promise<boolean>> = [];
|
||||
const stopBefore = vi.fn();
|
||||
const commands = new UICommandRegistry();
|
||||
const read = vi.fn<() => Promise<JoinedDevicesSnapshot>>(async () => ({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true, pluginVersion: "2.0.0" }],
|
||||
}));
|
||||
const forget = vi.fn(async (_id: string) => "forgotten" as const);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const createResource = vi.fn(async () => ({ read, forget, dispose }));
|
||||
let close!: () => void;
|
||||
const dialog = vi.fn(
|
||||
(_component: unknown, _options: JoinedDevicesDialogOptions) =>
|
||||
new Promise<void>((resolve) => {
|
||||
close = resolve;
|
||||
})
|
||||
);
|
||||
const notices = { setItem: vi.fn(), removeItem: vi.fn(), finish: vi.fn(), hide: vi.fn() };
|
||||
const palette: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = [];
|
||||
const database = { localDatabase: { get: vi.fn(async () => ({ nodeid: "self" })) } };
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
getPluginVersion: () => "1.0.0",
|
||||
getAppVersion: () => "1.9.0",
|
||||
getPlatform: () => "desktop",
|
||||
addCommand: (command: (typeof palette)[number]) => palette.push(command),
|
||||
},
|
||||
UI: { commands, dialogManager: { open: dialog } },
|
||||
setting: { currentSettings: () => settings },
|
||||
database: { isDatabaseReady: () => true, localDatabase: database },
|
||||
replicator: { createRemoteResource: createResource },
|
||||
replication: {
|
||||
onBeforeReplicate: {
|
||||
addHandler: (handler: (typeof before)[number]) => {
|
||||
before.push(handler);
|
||||
return stopBefore;
|
||||
},
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
onInitialise: { addHandler: (handler: (typeof initialise)[number]) => initialise.push(handler) },
|
||||
onUnload: { addHandler: (handler: (typeof unload)[number]) => unload.push(handler) },
|
||||
},
|
||||
},
|
||||
serviceModules: {},
|
||||
} as unknown as Parameters<typeof useJoinedDevicesFeature>[0];
|
||||
useJoinedDevicesFeature(host, notices as unknown as Parameters<typeof useJoinedDevicesFeature>[1]);
|
||||
return {
|
||||
settings,
|
||||
initialise,
|
||||
unload,
|
||||
before,
|
||||
stopBefore,
|
||||
commands,
|
||||
read,
|
||||
forget,
|
||||
dispose,
|
||||
createResource,
|
||||
dialog,
|
||||
notices,
|
||||
palette,
|
||||
database,
|
||||
close: () => close(),
|
||||
};
|
||||
}
|
||||
|
||||
async function open(data: ReturnType<typeof fixture>) {
|
||||
const pending = data.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
const options = data.dialog.mock.calls[0][1];
|
||||
return { options, pending };
|
||||
}
|
||||
|
||||
describe("joined devices service feature", () => {
|
||||
it("opens one dialogue through the command and disposes each completed read", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({
|
||||
status: "handled",
|
||||
value: "already-open",
|
||||
});
|
||||
expect((await options.refresh()).devices[0]).toMatchObject({ id: "self", self: true });
|
||||
expect(data.dispose).toHaveBeenCalledTimes(1);
|
||||
data.close();
|
||||
expect(await pending).toEqual({ status: "handled", value: "closed" });
|
||||
await expect(options.refresh()).rejects.toThrow();
|
||||
});
|
||||
it("prevents self removal and fences an old dialogue after a remote switch", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
await expect(options.forget("self")).rejects.toThrow();
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
await expect(options.forget("other")).rejects.toThrow();
|
||||
expect(data.forget).not.toHaveBeenCalled();
|
||||
expect(data.createResource).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("discards a stale read, releases the resource, and emits no stale Notice", async () => {
|
||||
const data = fixture();
|
||||
let resolve!: (value: JoinedDevicesSnapshot) => void;
|
||||
data.read.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const reading = options.refresh();
|
||||
await vi.waitFor(() => expect(data.read).toHaveBeenCalled());
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
resolve({ milestonePresent: true, locked: false, cleaned: false, devices: [] });
|
||||
await expect(reading).rejects.toThrow();
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
expect(data.notices.setItem).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it.each(["dialogue cancellation", "remote switch", "unload"] as const)(
|
||||
"does not send a pending forget operation after %s",
|
||||
async (invalidation) => {
|
||||
const data = fixture();
|
||||
const resource = { read: data.read, forget: data.forget, dispose: data.dispose };
|
||||
let resolve!: (value: typeof resource) => void;
|
||||
data.createResource.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const forgetting = options.forget("other");
|
||||
const rejected = expect(forgetting).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(data.createResource).toHaveBeenCalledOnce());
|
||||
|
||||
if (invalidation === "dialogue cancellation") options.cancel();
|
||||
if (invalidation === "remote switch") data.settings.couchDB_DBNAME = "new-vault";
|
||||
if (invalidation === "unload") for (const unload of data.unload) await unload();
|
||||
resolve(resource);
|
||||
|
||||
await rejected;
|
||||
expect(data.forget).not.toHaveBeenCalled();
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
}
|
||||
);
|
||||
it("discards a completed forget result after the dialogue closes and releases its resource", async () => {
|
||||
const data = fixture();
|
||||
let resolve!: (value: "forgotten") => void;
|
||||
data.forget.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const forgetting = options.forget("other");
|
||||
const rejected = expect(forgetting).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(data.forget).toHaveBeenCalledWith("other"));
|
||||
data.close();
|
||||
await pending;
|
||||
resolve("forgotten");
|
||||
await rejected;
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("propagates a failed forget operation after releasing its resource", async () => {
|
||||
const data = fixture();
|
||||
const failure = new Error("conditional write failed");
|
||||
data.forget.mockRejectedValueOnce(failure);
|
||||
const { options, pending } = await open(data);
|
||||
await expect(options.forget("other")).rejects.toBe(failure);
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("shares one review action across newer and older device versions", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "newer-a", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "newer-b", accepted: true, pluginVersion: "3.0.0" },
|
||||
{ id: "older-a", accepted: true, pluginVersion: "0.9.0" },
|
||||
{ id: "older-b", accepted: true, pluginVersion: "0.8.0" },
|
||||
{ id: "older-duplicate", accepted: true, pluginVersion: "0.8.0" },
|
||||
],
|
||||
});
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
const items = data.notices.setItem.mock.calls.map(([, , item]) => item);
|
||||
expect(items.filter((item) => item.action)).toHaveLength(1);
|
||||
const messages = items.map((item) => item.message).join("\n");
|
||||
expect(messages).toContain("Self-hosted LiveSync");
|
||||
expect(messages).toMatch(/Newer.*2\.0\.0, 3\.0\.0/);
|
||||
expect(messages).toMatch(/Older.*0\.8\.0, 0\.9\.0/);
|
||||
expect(messages.match(/0\.8\.0/g)).toHaveLength(1);
|
||||
expect(messages.match(/Reviewing and aligning/g)).toHaveLength(1);
|
||||
expect(items[items.length - 1]?.action?.label).toBe("Review devices");
|
||||
expect(data.notices.finish).toHaveBeenCalledExactlyOnceWith("joined-device-versions", {
|
||||
durationMs: 15000,
|
||||
});
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("keeps the shared action last when another version direction arrives", async () => {
|
||||
const data = fixture();
|
||||
const visible = new Map<string, { message: string; action?: unknown }>();
|
||||
data.notices.setItem.mockImplementation((_group, key, item) => visible.set(key, item));
|
||||
data.notices.removeItem.mockImplementation((_group, key) => visible.delete(key));
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["newer", "review"]);
|
||||
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "older", accepted: true, pluginVersion: "0.9.0" },
|
||||
],
|
||||
});
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["newer", "older", "review"]);
|
||||
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
|
||||
|
||||
visible.clear();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "older", accepted: true, pluginVersion: "0.8.0" },
|
||||
],
|
||||
});
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["older", "review"]);
|
||||
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("does not show a review action when no different version is reported", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true, pluginVersion: "1.0.0" }],
|
||||
});
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
expect(data.notices.setItem).not.toHaveBeenCalled();
|
||||
expect(data.notices.removeItem).not.toHaveBeenCalled();
|
||||
expect(data.notices.finish).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("deduplicates advisory Notices and fences their actions after configuration changes", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
await options.refresh();
|
||||
expect(data.notices.setItem).toHaveBeenCalledTimes(2);
|
||||
const actions = data.notices.setItem.mock.calls.map(([, , item]) => item.action).filter(Boolean);
|
||||
expect(actions).toHaveLength(1);
|
||||
const action = actions[0];
|
||||
data.close();
|
||||
await pending;
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
action.onSelect();
|
||||
expect(data.dialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("keeps inspection failures out of the replication result and unregisters on unload", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockRejectedValue(new Error("unavailable"));
|
||||
expect(await data.before[0]()).toBe(true);
|
||||
await vi.waitFor(() => expect(data.dispose).toHaveBeenCalledOnce());
|
||||
expect(await data.before[0]()).toBe(true);
|
||||
expect(data.read).toHaveBeenCalledTimes(1);
|
||||
for (const unload of data.unload) await unload();
|
||||
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({ status: "unavailable" });
|
||||
expect(data.stopBefore).toHaveBeenCalledOnce();
|
||||
expect(data.notices.hide).toHaveBeenCalled();
|
||||
});
|
||||
it("uses the same UI command from the command palette", async () => {
|
||||
const data = fixture();
|
||||
for (const initialise of data.initialise) await initialise();
|
||||
expect(data.palette[0].checkCallback(true)).toBe(true);
|
||||
expect(data.dialog).not.toHaveBeenCalled();
|
||||
data.palette[0].checkCallback(false);
|
||||
expect(data.dialog).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
});
|
||||
});
|
||||
@@ -69,9 +69,9 @@ export function useReviewHarness(
|
||||
await createFileContentHashBenchmarkOperations(activeWindow.performance),
|
||||
activeWindow.performance
|
||||
),
|
||||
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value),
|
||||
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
readContinuation: () => services.config.getValue(REVIEW_HARNESS_STATE_KEY, ""),
|
||||
writeContinuation: (value) => services.config.setValue(REVIEW_HARNESS_STATE_KEY, value),
|
||||
deleteContinuation: () => services.config.delete(REVIEW_HARNESS_STATE_KEY),
|
||||
restart: () => services.appLifecycle.performRestart(),
|
||||
reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE),
|
||||
copyText: async (value) => {
|
||||
@@ -94,14 +94,14 @@ export function useReviewHarness(
|
||||
let registered = false;
|
||||
let openAfterLayout = false;
|
||||
|
||||
services.appLifecycle.onSettingLoaded.addHandler(() => {
|
||||
services.appLifecycle.onSettingLoaded.addHandler(async () => {
|
||||
if (!continuationConsumed) {
|
||||
await controller.consumeContinuation();
|
||||
continuationConsumed = true;
|
||||
controller.consumeContinuation();
|
||||
}
|
||||
const snapshot = controller.snapshot();
|
||||
openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null;
|
||||
if (!services.setting.currentSettings().enableDebugTools || registered) return Promise.resolve(true);
|
||||
if (!services.setting.currentSettings().enableDebugTools || registered) return true;
|
||||
|
||||
registered = true;
|
||||
services.API.registerWindow(
|
||||
@@ -115,7 +115,7 @@ export function useReviewHarness(
|
||||
void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS);
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||
|
||||
@@ -31,6 +31,15 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
const layoutReadyHandlers: Array<() => Promise<boolean>> = [];
|
||||
const local = new Map<string, string>();
|
||||
if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation);
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
local.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
local.delete(key);
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
enableDebugTools: options.enableDebugTools ?? true,
|
||||
liveSync: false,
|
||||
@@ -63,10 +72,8 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
requiresSyncReview: true,
|
||||
reviewReasons: [],
|
||||
}),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
deleteSmallConfig: (key: string) => local.delete(key),
|
||||
},
|
||||
config,
|
||||
appLifecycle: {
|
||||
onSettingLoaded: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
|
||||
@@ -106,6 +113,7 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
api,
|
||||
settings,
|
||||
local,
|
||||
config,
|
||||
settingLoadedHandlers,
|
||||
layoutReadyHandlers,
|
||||
compatibilityReview,
|
||||
@@ -144,6 +152,74 @@ describe("Review Harness composition", () => {
|
||||
expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS);
|
||||
});
|
||||
|
||||
it("holds the setting-loaded lifecycle until continuation deletion has completed", async () => {
|
||||
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||
const continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: `compatibility-review-${requestedAt}`,
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
});
|
||||
const fixture = createFixture({ continuation });
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deleting = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
fixture.config.delete.mockImplementation(async (key) => {
|
||||
markDeleteStarted();
|
||||
await deleting;
|
||||
fixture.local.delete(key);
|
||||
});
|
||||
|
||||
const loading = fixture.settingLoadedHandlers[0]();
|
||||
await deleteStarted;
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||
|
||||
finishDelete();
|
||||
await loading;
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("can retry one-shot continuation consumption after a deletion failure", async () => {
|
||||
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||
const continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: `compatibility-review-${requestedAt}`,
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
});
|
||||
const fixture = createFixture({ continuation });
|
||||
fixture.config.delete
|
||||
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||
.mockImplementation(async (key) => {
|
||||
fixture.local.delete(key);
|
||||
});
|
||||
|
||||
await expect(fixture.settingLoadedHandlers[0]()).rejects.toThrow("continuation delete failed");
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.settingLoadedHandlers[0]();
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the actual compatibility controller as the guided review boundary", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
|
||||
Reference in new issue
Block a user