mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-24 01:53:20 +00:00
Merge current main and Maintenance keys into settings localisation
This commit is contained in:
commit
d346bb27ab
138 files changed
+12767
-1856
No files matched your search
@@ -66,9 +66,8 @@ export class CompatibilityReviewController {
|
||||
|
||||
// An existing unconfigured Vault cannot replicate, so a database
|
||||
// compatibility pause would only compete with onboarding and persist
|
||||
// a misleading sync warning. Do not acknowledge the missing marker:
|
||||
// activation on a later start must evaluate the same state again.
|
||||
// Genuinely new Vaults still initialise their marker below.
|
||||
// a misleading sync warning. Its next configured start evaluates any
|
||||
// known compatibility reasons before initialising an absent marker.
|
||||
if (settings.isConfigured !== true && migrationState?.isNewVault !== true) {
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
|
||||
@@ -103,15 +103,43 @@ describe("compatibility review controller", () => {
|
||||
fixture.settings.isConfigured = true;
|
||||
await expect(fixture.controller.initialise()).resolves.toBe(true);
|
||||
|
||||
expect(fixture.controller.pendingPause?.reasons).toContainEqual({
|
||||
source: "database-version",
|
||||
state: "missing",
|
||||
currentVersion: 12,
|
||||
resumable: true,
|
||||
});
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.settings.versionUpFlash).toBe("");
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a configured Vault with no device marker without a review or settings changes", async () => {
|
||||
const fixture = createFixture({ marker: null });
|
||||
const previousSettings = { ...fixture.settings };
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.settings).toEqual(previousSettings);
|
||||
expect(fixture.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
|
||||
expect(fixture.ui.showReminder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an already persisted review when the device marker is absent", async () => {
|
||||
const fixture = createFixture({ marker: null, versionUpFlash: COMPATIBILITY_PAUSE_SETTING_MESSAGE });
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
|
||||
expect(fixture.controller.pendingPause?.reasons).toEqual([
|
||||
{
|
||||
source: "legacy-review",
|
||||
message: COMPATIBILITY_PAUSE_SETTING_MESSAGE,
|
||||
resumable: true,
|
||||
},
|
||||
]);
|
||||
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
|
||||
expect(fixture.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
|
||||
|
||||
@@ -19,9 +19,6 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
|
||||
if (reason.state === "downgrade") {
|
||||
return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`;
|
||||
}
|
||||
if (reason.state === "missing") {
|
||||
return `- No previously acknowledged internal database version was found for this existing Vault. This can happen when a Vault is copied or restored, or when it is opened with a new Obsidian profile. This installation uses version **${reason.currentVersion}**. An empty local database does not mean that it is safe to resume automatically.`;
|
||||
}
|
||||
return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`;
|
||||
}
|
||||
if (reason.source === "settings-schema") {
|
||||
|
||||
@@ -23,13 +23,13 @@ const resumablePause: CompatibilityPause = {
|
||||
};
|
||||
|
||||
describe("Obsidian compatibility review", () => {
|
||||
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
|
||||
it("explains an invalid device-local acknowledgement", () => {
|
||||
const pause: CompatibilityPause = {
|
||||
resumable: true,
|
||||
reasons: [
|
||||
{
|
||||
source: "database-version",
|
||||
state: "missing",
|
||||
state: "invalid",
|
||||
currentVersion: 12,
|
||||
resumable: true,
|
||||
},
|
||||
@@ -37,9 +37,8 @@ describe("Obsidian compatibility review", () => {
|
||||
};
|
||||
|
||||
const details = compatibilityReviewDetailsMarkdown(pause);
|
||||
expect(details).toContain("copied or restored");
|
||||
expect(details).toContain("new Obsidian profile");
|
||||
expect(details).toContain("does not mean that it is safe to resume automatically");
|
||||
expect(details).toContain("saved internal database version marker is invalid");
|
||||
expect(details).toContain("version **12**");
|
||||
});
|
||||
|
||||
it("offers the generic resume action in a vertical action dialogue", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { assessRemoteFeatureDocument, describeRemoteFeatureRejection } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
SYNCINFO_ID,
|
||||
VER,
|
||||
VERSIONING_DOCID,
|
||||
type AnyEntry,
|
||||
type EntryDoc,
|
||||
type EntryLeaf,
|
||||
@@ -107,8 +108,15 @@ export class ReplicateResultProcessor {
|
||||
}
|
||||
public resume() {
|
||||
this._suspended = false;
|
||||
this.continueHeldDocuments();
|
||||
}
|
||||
/**
|
||||
* Continue the queued documents which were held, for example while the application was not ready.
|
||||
* An explicit suspension, by `suspend()` or by the settings, remains in effect.
|
||||
*/
|
||||
public continueHeldDocuments() {
|
||||
this.updateProcessingActivity();
|
||||
fireAndForget(() => this.runProcessQueue());
|
||||
this.triggerProcessQueue();
|
||||
}
|
||||
|
||||
// Whether the processing is suspended
|
||||
@@ -274,13 +282,14 @@ export class ReplicateResultProcessor {
|
||||
this.log(`Processed chunk: ${shortenId(change._id)}`, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
if (change.type == "versioninfo") {
|
||||
if (change._id === VERSIONING_DOCID || change.type === "versioninfo") {
|
||||
this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
|
||||
if (change.version > VER) {
|
||||
const assessment = assessRemoteFeatureDocument(change);
|
||||
if (assessment.status !== "supported" && assessment.status !== "older-generation") {
|
||||
// Fence and retire the active publication through its owner.
|
||||
this.context.requestActiveReplicatorRetirement();
|
||||
this.log(
|
||||
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
|
||||
`${describeRemoteFeatureRejection(assessment)} Update Self-hosted LiveSync before synchronising.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { VER, type EntryDoc, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
VERSIONING_DOCID,
|
||||
type EntryDoc,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
isValidFilenameInAndroid,
|
||||
isValidFilenameInWidows,
|
||||
@@ -37,6 +42,10 @@ type SetupOptions = {
|
||||
isValidPath?: (path: string) => boolean;
|
||||
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
|
||||
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
|
||||
getSnapshot?: (key: string) => Promise<unknown>;
|
||||
localVersionInfo?: unknown;
|
||||
isTargetFile?: (path: string) => Promise<boolean>;
|
||||
databaseId?: string;
|
||||
};
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
@@ -47,6 +56,9 @@ function setup(options: SetupOptions = {}) {
|
||||
const isReady = vi.fn(() => options.applicationReady ?? true);
|
||||
const isValidPath = vi.fn(options.isValidPath ?? (() => true));
|
||||
const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" }));
|
||||
const localPhysicalDatabase = {
|
||||
...(options.databaseId ? { id: vi.fn(async () => options.databaseId) } : {}),
|
||||
} as PouchDB.Database<EntryDoc>;
|
||||
const core = {
|
||||
services: {
|
||||
appLifecycle: { isReady, isSuspended: () => false },
|
||||
@@ -62,14 +74,21 @@ function setup(options: SetupOptions = {}) {
|
||||
},
|
||||
replicator: { onCloseActiveReplication, runBoundedLocalApplicationActivity },
|
||||
vault: {
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
isTargetFile: vi.fn(options.isTargetFile ?? (async () => true)),
|
||||
isFileSizeTooLarge: vi.fn(() => false),
|
||||
isValidPath,
|
||||
},
|
||||
},
|
||||
kvDB: { set: setSnapshot },
|
||||
kvDB: { set: setSnapshot, get: vi.fn(options.getSnapshot ?? (async () => undefined)) },
|
||||
localDatabase: {
|
||||
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
||||
localDatabase: localPhysicalDatabase,
|
||||
getRaw: vi.fn(async (id: string) => {
|
||||
if (id === VERSIONING_DOCID) {
|
||||
if (options.localVersionInfo === undefined) throw { status: 404 };
|
||||
return options.localVersionInfo;
|
||||
}
|
||||
return { _id: id, _rev: "1-test" };
|
||||
}),
|
||||
getDBEntryFromMeta,
|
||||
},
|
||||
};
|
||||
@@ -88,6 +107,9 @@ function setup(options: SetupOptions = {}) {
|
||||
} as never);
|
||||
return {
|
||||
getDBEntryFromMeta,
|
||||
isTargetFile: core.services.vault.isTargetFile,
|
||||
localPhysicalDatabase,
|
||||
localDatabase: core.localDatabase,
|
||||
isReady,
|
||||
isValidPath,
|
||||
onCloseActiveReplication,
|
||||
@@ -98,6 +120,34 @@ function setup(options: SetupOptions = {}) {
|
||||
}
|
||||
|
||||
describe("ReplicateResultProcessor", () => {
|
||||
it("does not add a permanent application block when snapshot recovery fails", async () => {
|
||||
const { processor } = setup({
|
||||
getSnapshot: async () => {
|
||||
throw new Error("KV unavailable");
|
||||
},
|
||||
});
|
||||
await expect(processor.restoreFromSnapshotOnce()).rejects.toThrow("KV unavailable");
|
||||
expect(processor.isSuspended).toBe(false);
|
||||
});
|
||||
|
||||
it("restores pending notes without retaining a past feature rejection in KV", async () => {
|
||||
const { processor, processSynchroniseResult, onCloseActiveReplication } = setup({
|
||||
databaseId: "same-database",
|
||||
getSnapshot: async () => ({
|
||||
databaseId: "same-database",
|
||||
invalidControlObserved: true,
|
||||
observedFeatures: ["future-format-v7"],
|
||||
observedGeneration: 14,
|
||||
queued: [note("recovered-note")],
|
||||
processing: [],
|
||||
}),
|
||||
});
|
||||
await processor.restoreFromSnapshotOnce();
|
||||
expect(processor.isSuspended).toBe(false);
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
expect(onCloseActiveReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Windows", isValidFilenameInWidows],
|
||||
["Android", isValidFilenameInAndroid],
|
||||
@@ -145,9 +195,9 @@ describe("ReplicateResultProcessor", () => {
|
||||
});
|
||||
}
|
||||
expect(processSynchroniseResult).toHaveBeenCalledTimes(11);
|
||||
expect(processSynchroniseResult.mock.calls.some(([entry]) =>
|
||||
(entry as { _id: string })._id === "unrelated-queue"
|
||||
)).toBe(true);
|
||||
expect(
|
||||
processSynchroniseResult.mock.calls.some(([entry]) => (entry as { _id: string })._id === "unrelated-queue")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("suspends result application while the application is not ready", () => {
|
||||
@@ -157,6 +207,35 @@ describe("ReplicateResultProcessor", () => {
|
||||
expect(isReady).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies documents held before readiness once the application becomes ready", async () => {
|
||||
const { isReady, processor, processSynchroniseResult } = setup({ applicationReady: false });
|
||||
processor.enqueueAll([note("held")]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||
expect(processor["_queuedChanges"]).toHaveLength(1);
|
||||
|
||||
isReady.mockReturnValue(true);
|
||||
processor.continueHeldDocuments();
|
||||
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
expect(processor["_queuedChanges"]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps an explicit suspension when the application becomes ready", async () => {
|
||||
const { isReady, processor, processSynchroniseResult } = setup({ applicationReady: false });
|
||||
processor.suspend();
|
||||
processor.enqueueAll([note("held")]);
|
||||
|
||||
isReady.mockReturnValue(true);
|
||||
processor.continueHeldDocuments();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(processSynchroniseResult).not.toHaveBeenCalled();
|
||||
expect(processor["_queuedChanges"]).toHaveLength(1);
|
||||
|
||||
processor.resume();
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("applies results in remediation mode, which never reports readiness", () => {
|
||||
const { processor } = setup({
|
||||
applicationReady: false,
|
||||
@@ -199,10 +278,10 @@ describe("ReplicateResultProcessor", () => {
|
||||
it("retires active ownership when a newer remote version is observed", async () => {
|
||||
const { onCloseActiveReplication, processor } = setup();
|
||||
const versionInfo = {
|
||||
_id: "versioninfo",
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "1-test",
|
||||
type: "versioninfo",
|
||||
version: VER + 1,
|
||||
version: REMOTE_FEATURE_GENERATION + 1,
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
|
||||
processor.enqueueAll([versionInfo]);
|
||||
@@ -210,6 +289,64 @@ describe("ReplicateResultProcessor", () => {
|
||||
await vi.waitFor(() => expect(onCloseActiveReplication).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("continues applying documents after restoring a legacy local version document", async () => {
|
||||
const { onCloseActiveReplication, processor, processSynchroniseResult } = setup({
|
||||
localVersionInfo: {
|
||||
_id: VERSIONING_DOCID,
|
||||
type: "versioninfo",
|
||||
version: 11,
|
||||
},
|
||||
});
|
||||
|
||||
await processor.restoreFromSnapshotOnce();
|
||||
processor.enqueueAll([note("legacy-database-note")]);
|
||||
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
expect(processor.isSuspended).toBe(false);
|
||||
expect(onCloseActiveReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues when a newly received feature is supported", async () => {
|
||||
const { onCloseActiveReplication, processor, processSynchroniseResult } = setup();
|
||||
const versionInfo = {
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "2-supported",
|
||||
type: "versioninfo",
|
||||
version: REMOTE_FEATURE_GENERATION,
|
||||
used_features: [ENCRYPTED_INTERNAL_METADATA_FEATURE],
|
||||
} as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
|
||||
processor.enqueueAll([versionInfo, note("supported-update")]);
|
||||
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
expect(onCloseActiveReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unknown feature identifiers and requests Replicator retirement", () => {
|
||||
const logger = vi.fn();
|
||||
setGlobalLogFunction(logger);
|
||||
try {
|
||||
const { processor, onCloseActiveReplication } = setup();
|
||||
processor.enqueueAll([
|
||||
{
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "1-unknown",
|
||||
type: "versioninfo",
|
||||
version: REMOTE_FEATURE_GENERATION,
|
||||
used_features: ["future-format-v7"],
|
||||
} as PouchDB.Core.ExistingDocument<EntryDoc>,
|
||||
]);
|
||||
expect(onCloseActiveReplication).toHaveBeenCalledOnce();
|
||||
expect(logger).toHaveBeenCalledWith(
|
||||
expect.stringContaining("future-format-v7"),
|
||||
LOG_LEVEL_NOTICE,
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
setGlobalLogFunction(defaultLogger);
|
||||
}
|
||||
});
|
||||
|
||||
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
|
||||
const documents = [
|
||||
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
VERSIONING_DOCID,
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
@@ -7,12 +11,27 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
assessRemoteFeatureDocument,
|
||||
describeRemoteFeatureRejection,
|
||||
type ReplicatorInstance,
|
||||
type ReplicationFailureRequest,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
|
||||
async function canInterpretCleanupDatabase(db: PouchDB.Database<EntryDoc>): Promise<boolean> {
|
||||
try {
|
||||
const assessment = assessRemoteFeatureDocument(await db.get(VERSIONING_DOCID));
|
||||
if (assessment.status === "supported" || assessment.status === "older-generation") return true;
|
||||
Logger(`Database cleanup cancelled: ${describeRemoteFeatureRejection(assessment)}`, LOG_LEVEL_NOTICE);
|
||||
} catch (error) {
|
||||
Logger("Database cleanup cancelled: feature compatibility could not be checked.", LOG_LEVEL_NOTICE);
|
||||
Logger(error, LOG_LEVEL_INFO);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type CentralCompatibilityRecoveryServices = Pick<
|
||||
LiveSyncBaseCore["services"],
|
||||
@@ -60,6 +79,7 @@ export function createCentralCompatibilityRecovery(context: CentralCompatibility
|
||||
) {
|
||||
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
if (!(await canInterpretCleanupDatabase(context.getLocalDatabase().localDatabase))) return;
|
||||
const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { VERSIONING_DOCID, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
@@ -24,6 +24,49 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
|
||||
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||
|
||||
describe("central compatibility recovery", () => {
|
||||
it("does not count chunks for cleanup when local feature requirements are unknown", async () => {
|
||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage },
|
||||
getLocalDatabase: () => ({
|
||||
localDatabase: {
|
||||
get: vi.fn(async (id: string) => ({
|
||||
_id: id,
|
||||
type: "versioninfo",
|
||||
version: 13,
|
||||
used_features: ["future-format-v7"],
|
||||
})),
|
||||
},
|
||||
}),
|
||||
services: { replicator: {} },
|
||||
} as never);
|
||||
|
||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
||||
|
||||
expect(chunkMocks.purgeUnreferencedChunks).not.toHaveBeenCalled();
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows cleanup counting for a legacy local version document", async () => {
|
||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage },
|
||||
getLocalDatabase: () => ({
|
||||
localDatabase: {
|
||||
get: vi.fn(async (id: string) => ({ _id: id, type: "versioninfo", version: 11 })),
|
||||
},
|
||||
}),
|
||||
services: { replicator: {} },
|
||||
} as never);
|
||||
|
||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
||||
|
||||
expect(chunkMocks.purgeUnreferencedChunks).toHaveBeenCalledWith(expect.anything(), true);
|
||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
|
||||
const setting = { customChunkSize: 0 };
|
||||
const preferredTweakValue = { customChunkSize: 60 };
|
||||
@@ -292,7 +335,9 @@ describe("central compatibility recovery", () => {
|
||||
});
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const openOneShotReplication = vi.fn(async () => true);
|
||||
const remoteDatabase = { close: vi.fn(async () => undefined) };
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
const close = vi.fn(async () => undefined);
|
||||
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), {
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })),
|
||||
@@ -303,7 +348,12 @@ describe("central compatibility recovery", () => {
|
||||
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(expectedContext)
|
||||
);
|
||||
const localDatabase = { localDatabase: {}, clearCaches: vi.fn() };
|
||||
const localDatabase = {
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 12 })),
|
||||
},
|
||||
clearCaches: vi.fn(),
|
||||
};
|
||||
const getLocalDatabase = vi.fn(() => localDatabase);
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||
@@ -335,7 +385,7 @@ describe("central compatibility recovery", () => {
|
||||
activityFinished.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||
expect(getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
expect(getLocalDatabase).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/servic
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { EVENT_APPLICATION_READY } from "@/common/events";
|
||||
import { createAutomaticReplicationTriggers } from "./automaticTriggers";
|
||||
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||
import { createOnlineReplicationPreflight, createSecuritySeedPreflight } from "./preflight";
|
||||
@@ -102,6 +103,9 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
||||
fireAndForget(() => resultProcessor.restoreFromSnapshotOnce());
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
// Commonlib emits this each time it establishes readiness. Documents held until then, such as those restored
|
||||
// from the snapshot or received during a fetch, continue from here.
|
||||
services.context.events.onEvent(EVENT_APPLICATION_READY, () => resultProcessor.continueHeldDocuments());
|
||||
services.appLifecycle.onSettingLoaded.addHandler(initialiseAutomaticReplicationTriggers);
|
||||
services.replication.parseSynchroniseResult.addHandler((documents) => {
|
||||
resultProcessor.enqueueAll(documents);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { VERSIONING_DOCID, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EVENT_APPLICATION_READY, eventHub } from "@/common/events";
|
||||
import { useReplicationFeature } from "./index";
|
||||
|
||||
type ParseHandler = (documents: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>;
|
||||
|
||||
function receivedNote(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
return {
|
||||
_id: id,
|
||||
_rev: "1-received",
|
||||
path: `${id}.md`,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 1,
|
||||
children: [],
|
||||
datatype: "plain",
|
||||
type: "plain",
|
||||
eden: {},
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
let applicationReady = false;
|
||||
const settings = {
|
||||
handleFilenameCaseSensitive: false,
|
||||
ignoreFiles: "",
|
||||
maxMTimeForReflectEvents: 0,
|
||||
suspendParseReplicationResult: false,
|
||||
syncIgnoreRegEx: "",
|
||||
syncInternalFiles: false,
|
||||
syncMaxSizeInMB: 0,
|
||||
syncOnlyRegEx: "",
|
||||
useIgnoreFiles: false,
|
||||
};
|
||||
const processSynchroniseResult = vi.fn(async () => true);
|
||||
const settingLoadedHandlers: (() => Promise<boolean>)[] = [];
|
||||
const context = createServiceContext();
|
||||
const keyValueDB = {
|
||||
get: vi.fn(async () => undefined),
|
||||
set: vi.fn(async () => undefined),
|
||||
};
|
||||
const localDatabase = {
|
||||
getRaw: vi.fn(async (id: string) => {
|
||||
if (id === VERSIONING_DOCID) throw { status: 404 };
|
||||
return { _id: id, _rev: "1-received" };
|
||||
}),
|
||||
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "received content" })),
|
||||
};
|
||||
let parseHandler: ParseHandler | undefined;
|
||||
const services = {
|
||||
API: { isMobile: vi.fn(() => false), isOnline: true },
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
isReady: () => applicationReady,
|
||||
isSuspended: vi.fn(() => false),
|
||||
onSettingLoaded: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
|
||||
},
|
||||
},
|
||||
context,
|
||||
database: { isDatabaseReady: vi.fn(() => true) },
|
||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
||||
keyValueDB: { kvDB: keyValueDB },
|
||||
localDatabase,
|
||||
path: { getPath: vi.fn((entry: { path: string }) => entry.path) },
|
||||
replication: {
|
||||
databaseQueueCount: { value: 0 },
|
||||
storageApplyingCount: { value: 0 },
|
||||
replicationResultCount: { value: 0 },
|
||||
onBeforeReplicate: { addHandler: vi.fn() },
|
||||
onPrepareCentralRemoteReplication: { addHandler: vi.fn() },
|
||||
onReplicationFailed: { addHandler: vi.fn() },
|
||||
parseSynchroniseResult: {
|
||||
addHandler: vi.fn((handler: ParseHandler) => {
|
||||
parseHandler = handler;
|
||||
}),
|
||||
},
|
||||
processOptionalSynchroniseResult: vi.fn(async () => false),
|
||||
processSynchroniseResult,
|
||||
processVirtualDocument: vi.fn(async () => false),
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
replicator: {
|
||||
createRemoteResource: vi.fn(async () => ({
|
||||
read: vi.fn(async () => new Uint8Array([1])),
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
||||
onCloseActiveReplication: vi.fn(async () => true),
|
||||
},
|
||||
setting: { currentSettings: vi.fn(() => settings) },
|
||||
tweakValue: {},
|
||||
vault: {
|
||||
isFileSizeTooLarge: vi.fn(() => false),
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
isValidPath: vi.fn(() => true),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
confirm: {},
|
||||
get localDatabase() {
|
||||
return localDatabase;
|
||||
},
|
||||
rebuilder: {},
|
||||
services,
|
||||
};
|
||||
|
||||
useReplicationFeature(core as never);
|
||||
|
||||
return {
|
||||
context,
|
||||
get applicationReady() {
|
||||
return applicationReady;
|
||||
},
|
||||
processSynchroniseResult,
|
||||
get parseHandler() {
|
||||
return parseHandler;
|
||||
},
|
||||
settingLoadedHandlers,
|
||||
setApplicationReady(value: boolean) {
|
||||
applicationReady = value;
|
||||
},
|
||||
settings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("received change readiness composition", () => {
|
||||
it("applies a queued received document once readiness is established and preserves explicit suspension", async () => {
|
||||
eventHub.offAll();
|
||||
const harness = setup();
|
||||
try {
|
||||
await harness.settingLoadedHandlers[0]();
|
||||
|
||||
await harness.parseHandler!([receivedNote("ready-note")]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(harness.processSynchroniseResult).not.toHaveBeenCalled();
|
||||
|
||||
harness.setApplicationReady(true);
|
||||
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
|
||||
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
|
||||
|
||||
await vi.waitFor(() => expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(1));
|
||||
|
||||
harness.settings.suspendParseReplicationResult = true;
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, harness.settings as never);
|
||||
harness.setApplicationReady(false);
|
||||
await harness.parseHandler!([receivedNote("suspended-note")]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(1);
|
||||
|
||||
harness.setApplicationReady(true);
|
||||
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
|
||||
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(1);
|
||||
|
||||
harness.settings.suspendParseReplicationResult = false;
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, harness.settings as never);
|
||||
|
||||
await vi.waitFor(() => expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(2));
|
||||
expect(harness.processSynchroniseResult).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ _id: "suspended-note", path: "suspended-note.md" })
|
||||
);
|
||||
} finally {
|
||||
eventHub.offAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { VERSIONING_DOCID, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { EVENT_APPLICATION_READY } from "@/common/events";
|
||||
import { useReplicationFeature } from "./index";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
|
||||
type BooleanHandler = (showMessage: boolean) => Promise<boolean>;
|
||||
type ParseHandler = (documents: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>;
|
||||
@@ -19,8 +22,14 @@ type SetupOptions = {
|
||||
};
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const defaultLocalDatabase = {
|
||||
localDatabase: {},
|
||||
getRaw: vi.fn(async () => {
|
||||
throw { status: 404 };
|
||||
}),
|
||||
};
|
||||
const {
|
||||
getLocalDatabase = () => ({}),
|
||||
getLocalDatabase = () => defaultLocalDatabase,
|
||||
keyValueDB = {
|
||||
kvDB: {
|
||||
get: vi.fn(async () => undefined),
|
||||
@@ -39,7 +48,7 @@ function setup(options: SetupOptions = {}) {
|
||||
API: { isMobile: vi.fn(() => false), isOnline: true },
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
isReady: true,
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
onSettingLoaded: { addHandler: vi.fn() },
|
||||
},
|
||||
@@ -48,6 +57,7 @@ function setup(options: SetupOptions = {}) {
|
||||
keyValueDB,
|
||||
path: { getPath: vi.fn((entry: { path: string }) => entry.path) },
|
||||
replication: {
|
||||
replicationResultCount: { value: 0 },
|
||||
onBeforeReplicate: {
|
||||
addHandler: vi.fn((handler: BooleanHandler, priority = 0) => {
|
||||
beforeReplicateHandlers.set(priority, handler);
|
||||
@@ -87,6 +97,7 @@ function setup(options: SetupOptions = {}) {
|
||||
return {
|
||||
beforeReplicateHandlers,
|
||||
centralRemoteHandlers,
|
||||
context: services.context,
|
||||
createRemoteResource,
|
||||
dispose,
|
||||
get parseHandler() {
|
||||
@@ -160,15 +171,31 @@ describe("replication serviceFeature composition", () => {
|
||||
expect(createRemoteResource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("continues held results when Commonlib establishes application readiness", () => {
|
||||
const continueHeldDocuments = vi
|
||||
.spyOn(ReplicateResultProcessor.prototype, "continueHeldDocuments")
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const { context } = setup();
|
||||
expect(continueHeldDocuments).not.toHaveBeenCalled();
|
||||
|
||||
context.events.emitEvent(EVENT_APPLICATION_READY);
|
||||
|
||||
expect(continueHeldDocuments).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
continueHeldDocuments.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("requests owner retirement without awaiting the transition from result application", async () => {
|
||||
const retirement = promiseWithResolvers<boolean>();
|
||||
const onCloseActiveReplication = vi.fn(() => retirement.promise);
|
||||
const harness = setup({ onCloseActiveReplication });
|
||||
const versionInfo = {
|
||||
_id: "versioninfo",
|
||||
_id: VERSIONING_DOCID,
|
||||
_rev: "1-test",
|
||||
type: "versioninfo",
|
||||
version: VER + 1,
|
||||
version: REMOTE_FEATURE_GENERATION + 1,
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
|
||||
expect(harness.parseHandler).toBeDefined();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupProtocol";
|
||||
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/common/types", () => {
|
||||
return {
|
||||
@@ -52,6 +54,35 @@ describe("setupObsidian/setupProtocol", () => {
|
||||
expect(setupManager.decodeQR).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ephemeral", "persistent"] as const)(
|
||||
"reconstructs the exact encrypted %s URI after protocol query decoding",
|
||||
async (mode) => {
|
||||
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
registerProtocolHandler: vi.fn(
|
||||
(_action: string, handler: (params: Record<string, string>) => Promise<void>) => {
|
||||
protocolHandler = handler;
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const setupManager = { onUseSetupURI: vi.fn(async () => true), decodeQR: vi.fn() } as any;
|
||||
registerSetupProtocolHandler(host, vi.fn(), setupManager);
|
||||
|
||||
const generated = await encodeTimeBoundSetupURI(DEFAULT_SETTINGS, "protocol-passphrase", { mode });
|
||||
const decodedQuery = new URL(generated.uri.trim()).searchParams.get("settings");
|
||||
expect(decodedQuery?.startsWith("%$")).toBe(true);
|
||||
await protocolHandler!({ settings: decodedQuery! });
|
||||
const reconstructed = setupManager.onUseSetupURI.mock.calls[0][1] as string;
|
||||
expect(reconstructed.replace("mock-config://", "obsidian://setuplivesync?settings=")).toBe(
|
||||
generated.uri.trim()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("registerSetupProtocolHandler should route settingsQR payload to decodeQR", async () => {
|
||||
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
|
||||
const host = {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import {
|
||||
encodeTimeBoundSetupURI,
|
||||
getTimeBoundSetupURIUsableUntil,
|
||||
isTimeBoundSetupURIUsableNow,
|
||||
type TimeBoundSetupURIMode,
|
||||
} from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
@@ -16,32 +21,89 @@ export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise<s
|
||||
);
|
||||
}
|
||||
|
||||
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
|
||||
const encryptingPassphrase = await askEncryptingPassphrase(host);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const encryptedURI = await encodeSettingsToSetupURI(
|
||||
host.services.setting.currentSettings(),
|
||||
encryptingPassphrase,
|
||||
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
|
||||
true
|
||||
);
|
||||
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
||||
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
function formatWindowEnd(usableUntil: number): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}).format(new Date(usableUntil));
|
||||
}
|
||||
|
||||
async function askSetupURIMode(
|
||||
host: SetupFeatureHost
|
||||
): Promise<{ mode: TimeBoundSetupURIMode; usableUntil: number | null } | false> {
|
||||
let usableUntil: number | null = null;
|
||||
try {
|
||||
usableUntil = getTimeBoundSetupURIUsableUntil();
|
||||
} catch {
|
||||
// Compatible generation remains available when the device clock is invalid.
|
||||
}
|
||||
const timeBound = "Time-bound";
|
||||
const compatible = "Compatible (no time limit)";
|
||||
const cancel = "Cancel";
|
||||
const buttons = usableUntil === null ? [compatible, cancel] : [timeBound, compatible, cancel];
|
||||
const message =
|
||||
usableUntil === null
|
||||
? "Time-bound Setup URIs require a valid device clock. Compatible URIs have no time limit and work with older clients."
|
||||
: `Time-bound Setup URIs can be opened until ${formatWindowEnd(usableUntil)}. This is the end of the current fixed seven-day UTC window, not seven days from now. Compatible URIs have no time limit and work with older clients.`;
|
||||
const selected = await host.services.UI.confirm.askSelectStringDialogue(message, buttons, {
|
||||
title: "Setup URI availability",
|
||||
defaultAction: usableUntil === null ? compatible : timeBound,
|
||||
});
|
||||
if (selected === timeBound && usableUntil !== null) return { mode: "ephemeral", usableUntil };
|
||||
if (selected === compatible) return { mode: "persistent", usableUntil: null };
|
||||
return false;
|
||||
}
|
||||
|
||||
async function generateAndCopySetupURI(
|
||||
host: SetupFeatureHost,
|
||||
log: LogFunction,
|
||||
removeProperties: (keyof ObsidianLiveSyncSettings)[],
|
||||
skipDefaultValue: boolean
|
||||
) {
|
||||
const passphrase = await askEncryptingPassphrase(host);
|
||||
if (passphrase === false) return;
|
||||
while (true) {
|
||||
const choice = await askSetupURIMode(host);
|
||||
if (choice === false) return;
|
||||
let result;
|
||||
try {
|
||||
result = await encodeTimeBoundSetupURI(host.services.setting.currentSettings(), passphrase, {
|
||||
mode: choice.mode,
|
||||
removeProperties,
|
||||
skipDefaultValue,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
choice.mode === "ephemeral" &&
|
||||
error instanceof Error &&
|
||||
error.message === "Setup URI window changed during generation"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (result.usableUntil !== choice.usableUntil || !isTimeBoundSetupURIUsableNow(result.usableUntil)) {
|
||||
continue;
|
||||
}
|
||||
if (await host.services.UI.promptCopyToClipboard("Setup URI", result.uri)) {
|
||||
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
|
||||
await generateAndCopySetupURI(host, log, stripExtra ? ["pluginSyncExtendedSetting"] : [], true);
|
||||
}
|
||||
|
||||
export async function copySetupURIFull(host: SetupFeatureHost, log: LogFunction) {
|
||||
const encryptingPassphrase = await askEncryptingPassphrase(host);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const encryptedURI = await encodeSettingsToSetupURI(
|
||||
host.services.setting.currentSettings(),
|
||||
encryptingPassphrase,
|
||||
[],
|
||||
false
|
||||
);
|
||||
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
||||
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await generateAndCopySetupURI(host, log, [], false);
|
||||
}
|
||||
|
||||
export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) {
|
||||
|
||||
@@ -1,119 +1,203 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
encodeTimeBoundSetupURI,
|
||||
getTimeBoundSetupURIUsableUntil,
|
||||
isTimeBoundSetupURIUsableNow,
|
||||
} from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { askEncryptingPassphrase, copySetupURI, copySetupURIFull, useSetupURIFeature } from "./setupUri";
|
||||
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
|
||||
return {
|
||||
encodeSettingsToSetupURI: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@vrtmrz/livesync-commonlib/setup-uri", () => ({
|
||||
encodeTimeBoundSetupURI: vi.fn(),
|
||||
getTimeBoundSetupURIUsableUntil: vi.fn(),
|
||||
isTimeBoundSetupURIUsableNow: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("setupObsidian/setupUri", () => {
|
||||
const usableUntil = Date.parse("2026-10-01T00:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValue(usableUntil);
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "obsidian://setup-time-bound ", usableUntil });
|
||||
vi.mocked(isTimeBoundSetupURIUsableNow).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("askEncryptingPassphrase should delegate to confirm.askString", async () => {
|
||||
const askString = vi.fn(() => "secret");
|
||||
const host = {
|
||||
services: {
|
||||
UI: {
|
||||
confirm: {
|
||||
askString,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
it("uses the existing password prompt", async () => {
|
||||
const askString = vi.fn(async () => "secret");
|
||||
const host = { services: { UI: { confirm: { askString } } } } as any;
|
||||
|
||||
const result = await askEncryptingPassphrase(host);
|
||||
expect(result).toBe("secret");
|
||||
expect(askString).toHaveBeenCalled();
|
||||
await expect(askEncryptingPassphrase(host)).resolves.toBe("secret");
|
||||
expect(askString).toHaveBeenCalledWith(
|
||||
"Encrypt your settings",
|
||||
"The passphrase to encrypt the setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("copySetupURI should return early when user cancels passphrase", async () => {
|
||||
const promptCopyToClipboard = vi.fn();
|
||||
const host = {
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => ({ foo: "bar" })),
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => false),
|
||||
},
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const log = vi.fn();
|
||||
|
||||
await copySetupURI(host, log);
|
||||
|
||||
expect(encodeSettingsToSetupURI).not.toHaveBeenCalled();
|
||||
expect(promptCopyToClipboard).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copySetupURI should encode with short mode by default", async () => {
|
||||
const promptCopyToClipboard = vi.fn(() => true);
|
||||
it("shows the exact Time-bound end at selection and uses it by default", async () => {
|
||||
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
|
||||
const promptCopyToClipboard = vi.fn(async () => true);
|
||||
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
|
||||
const host = {
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => currentSettings),
|
||||
},
|
||||
setting: { currentSettings: vi.fn(() => currentSettings) },
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass"),
|
||||
},
|
||||
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const log = vi.fn();
|
||||
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://value" as any);
|
||||
|
||||
await copySetupURI(host, log);
|
||||
|
||||
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(
|
||||
currentSettings,
|
||||
"pass",
|
||||
["pluginSyncExtendedSetting"],
|
||||
true
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
expect.stringContaining("2026"),
|
||||
["Time-bound", "Compatible (no time limit)", "Cancel"],
|
||||
{
|
||||
title: "Setup URI availability",
|
||||
defaultAction: "Time-bound",
|
||||
}
|
||||
);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://value");
|
||||
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
|
||||
mode: "ephemeral",
|
||||
removeProperties: ["pluginSyncExtendedSetting"],
|
||||
skipDefaultValue: true,
|
||||
});
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-time-bound ");
|
||||
expect(log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copySetupURIFull should encode with full mode", async () => {
|
||||
const promptCopyToClipboard = vi.fn(() => true);
|
||||
it("selects old-reader-compatible output without a time limit and preserves full export settings", async () => {
|
||||
const promptCopyToClipboard = vi.fn(async () => true);
|
||||
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
|
||||
const host = {
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => currentSettings),
|
||||
},
|
||||
setting: { currentSettings: vi.fn(() => currentSettings) },
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass-full"),
|
||||
askString: vi.fn(async () => "pass"),
|
||||
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
|
||||
},
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const log = vi.fn();
|
||||
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://full" as any);
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({
|
||||
uri: "obsidian://setup-compatible ",
|
||||
usableUntil: null,
|
||||
});
|
||||
|
||||
await copySetupURIFull(host, log);
|
||||
|
||||
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(currentSettings, "pass-full", [], false);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://full");
|
||||
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
|
||||
mode: "persistent",
|
||||
removeProperties: [],
|
||||
skipDefaultValue: false,
|
||||
});
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-compatible ");
|
||||
expect(log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Compatible available when the clock is invalid", async () => {
|
||||
vi.mocked(getTimeBoundSetupURIUsableUntil).mockImplementation(() => {
|
||||
throw new Error("Invalid Setup URI clock");
|
||||
});
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
|
||||
const askSelectStringDialogue = vi.fn(async () => "Compatible (no time limit)");
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: {
|
||||
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
|
||||
promptCopyToClipboard: vi.fn(async () => false),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
expect.stringContaining("valid device clock"),
|
||||
["Compatible (no time limit)", "Cancel"],
|
||||
expect.objectContaining({ defaultAction: "Compatible (no time limit)" })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not generate after password or mode cancellation", async () => {
|
||||
const askString = vi.fn(async (): Promise<string | false> => false);
|
||||
const askSelectStringDialogue = vi.fn(async () => "Cancel");
|
||||
const promptCopyToClipboard = vi.fn();
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: { confirm: { askString, askSelectStringDialogue }, promptCopyToClipboard },
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
askString.mockResolvedValueOnce("pass");
|
||||
await copySetupURI(host, vi.fn());
|
||||
expect(encodeTimeBoundSetupURI).not.toHaveBeenCalled();
|
||||
expect(promptCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an empty passphrase distinct from cancelling the existing prompt", async () => {
|
||||
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
|
||||
const promptCopyToClipboard = vi.fn(async () => false);
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(async () => ""),
|
||||
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
|
||||
},
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
|
||||
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith({}, "", expect.objectContaining({ mode: "persistent" }));
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "compatible");
|
||||
});
|
||||
|
||||
it("asks again if the window changes after selection", async () => {
|
||||
const nextUntil = usableUntil + 604_800_000;
|
||||
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValueOnce(usableUntil).mockReturnValueOnce(nextUntil);
|
||||
vi.mocked(encodeTimeBoundSetupURI)
|
||||
.mockResolvedValueOnce({ uri: "old-window", usableUntil: nextUntil })
|
||||
.mockResolvedValueOnce({ uri: "new-window", usableUntil: nextUntil });
|
||||
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
|
||||
const promptCopyToClipboard = vi.fn(async () => false);
|
||||
const host = {
|
||||
services: {
|
||||
setting: { currentSettings: vi.fn(() => ({})) },
|
||||
UI: {
|
||||
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
|
||||
promptCopyToClipboard,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await copySetupURI(host, vi.fn());
|
||||
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledTimes(2);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledTimes(1);
|
||||
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "new-window");
|
||||
});
|
||||
|
||||
it("useSetupURIFeature should register onLoaded handler that wires commands and event", async () => {
|
||||
const addHandler = vi.fn();
|
||||
const addCommand = vi.fn();
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
runReviewHarnessVaultRoundTrip,
|
||||
} from "@/features/ReviewHarness/reviewHarnessVaultFixture";
|
||||
import type { CompatibilityReviewController } from "./compatibilityReview";
|
||||
import { runReviewHarnessIdBenchmark } from "@/features/ReviewHarness/reviewHarnessIdBenchmark";
|
||||
import { createIdBenchmarkOperations } from "@/features/ReviewHarness/reviewHarnessIdBenchmarkRuntime";
|
||||
|
||||
async function runVaultRoundTrip(plugin: ObsidianLiveSyncPlugin): Promise<ReviewHarnessScenarioResult> {
|
||||
const vault = plugin.app.vault;
|
||||
@@ -58,6 +60,8 @@ export function useReviewHarness(
|
||||
getCompatibilityPause: () => compatibilityReview.pendingPause,
|
||||
openCompatibilityReview: () => compatibilityReview.openReview(),
|
||||
runVaultRoundTrip: () => runVaultRoundTrip(plugin),
|
||||
runIdBenchmark: async () =>
|
||||
runReviewHarnessIdBenchmark(await createIdBenchmarkOperations(), 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),
|
||||
|
||||
Reference in new issue
Block a user