mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-08 18:32:30 +00:00
Merge current main to document the Spanish translation update
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.30-cli",
|
||||
"version": "1.0.32-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -628,7 +628,7 @@ export async function startP2pRelay(): Promise<void> {
|
||||
//TODO: port mapping should be configurable.
|
||||
"4000:7777",
|
||||
"--tmpfs",
|
||||
"/app/strfry-db:rw,size=256m",
|
||||
"/app/strfry-db:rw,size=256m,mode=1777",
|
||||
"--entrypoint",
|
||||
"sh",
|
||||
P2P_RELAY_IMAGE,
|
||||
|
||||
@@ -13,7 +13,11 @@ export async function initSettingsFile(settingsFile: string): Promise<void> {
|
||||
* Generate a full setup URI from a settings file via the Commonlib package API.
|
||||
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
|
||||
*/
|
||||
export async function generateSetupUriFromSettings(settingsFile: string, setupPassphrase: string): Promise<string> {
|
||||
export async function generateSetupUriFromSettings(
|
||||
settingsFile: string,
|
||||
setupPassphrase: string,
|
||||
preserveRemoteSettings = false
|
||||
): Promise<string> {
|
||||
const script = [
|
||||
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
|
||||
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
|
||||
@@ -21,13 +25,17 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
|
||||
" const settingsPath = process.env.SETTINGS_FILE;",
|
||||
" const passphrase = process.env.SETUP_PASSPHRASE;",
|
||||
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
...(preserveRemoteSettings
|
||||
? []
|
||||
: [
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
]),
|
||||
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
|
||||
" process.stdout.write(uri.trim());",
|
||||
"})();",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { assert } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
||||
import {
|
||||
initSettingsFile,
|
||||
applyP2pSettings,
|
||||
applyP2pTestTweaks,
|
||||
generateSetupUriFromSettings,
|
||||
} from "./helpers/settings.ts";
|
||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||
import {
|
||||
discoverPeer,
|
||||
@@ -9,10 +14,10 @@ import {
|
||||
maybeStartCoturn,
|
||||
stopCoturnIfStarted,
|
||||
} from "./helpers/p2p.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { getOptimalLoopbackIp } from "./helpers/net.ts";
|
||||
|
||||
Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
Deno.test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => {
|
||||
const loopbackIp = await getOptimalLoopbackIp();
|
||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||
|
||||
@@ -32,14 +37,18 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
const hostSettings = workDir.join("settings-host.json");
|
||||
const clientVault = workDir.join("vault-sync");
|
||||
const clientSettings = workDir.join("settings-sync.json");
|
||||
const rejectedVault = workDir.join("vault-rejected");
|
||||
const rejectedSettings = workDir.join("settings-rejected.json");
|
||||
await Deno.mkdir(hostVault, { recursive: true });
|
||||
await Deno.mkdir(clientVault, { recursive: true });
|
||||
await Deno.mkdir(rejectedVault, { recursive: true });
|
||||
|
||||
const relayStarted = await maybeStartLocalRelay(relay);
|
||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||
try {
|
||||
await initSettingsFile(hostSettings);
|
||||
await initSettingsFile(clientSettings);
|
||||
await initSettingsFile(rejectedSettings);
|
||||
await applyP2pSettings(
|
||||
hostSettings,
|
||||
roomId,
|
||||
@@ -58,8 +67,52 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pSettings(
|
||||
rejectedSettings,
|
||||
roomId,
|
||||
passphrase,
|
||||
"self-hosted-livesync-cli-tests",
|
||||
relay,
|
||||
"~.*",
|
||||
turnServers
|
||||
);
|
||||
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
|
||||
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
|
||||
await applyP2pTestTweaks(rejectedSettings, "p2p-rejected-" + nonce, passphrase);
|
||||
for (const [vault, path, key, label] of [
|
||||
[hostVault, hostSettings, "ab".repeat(32), "host"],
|
||||
[clientVault, clientSettings, "ab".repeat(32), "client"],
|
||||
[rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"],
|
||||
]) {
|
||||
const settings = JSON.parse(await Deno.readTextFile(path));
|
||||
settings.idDerivationVersion = 1;
|
||||
settings.idDerivationKey = key;
|
||||
const sourcePath = workDir.join("setup-source-" + label + ".json");
|
||||
await Deno.writeTextFile(sourcePath, JSON.stringify(settings));
|
||||
const setupPassphrase = "independent-id-setup-passphrase";
|
||||
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
|
||||
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
|
||||
const persisted = JSON.parse(await Deno.readTextFile(path));
|
||||
assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
|
||||
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
|
||||
assert(
|
||||
typeof persisted.encryptedIdDerivationKey === "string" && persisted.encryptedIdDerivationKey.length > 0,
|
||||
"The CLI did not encrypt the saved ID key."
|
||||
);
|
||||
assert(persisted.P2P_Enabled === true, "The Setup URI disabled P2P.");
|
||||
assert(persisted.P2P_roomID === roomId, "The Setup URI changed the P2P room.");
|
||||
assert(persisted.P2P_relays === relay, "The Setup URI changed the P2P relay.");
|
||||
assert(persisted.remoteType === "ONLY_P2P", "The Setup URI changed the remote type.");
|
||||
}
|
||||
const notePath = "p2p/independent-id-note.md";
|
||||
await runCliWithInputOrFail(
|
||||
"A note transferred with the saved ID key.\n",
|
||||
clientVault,
|
||||
"--settings",
|
||||
clientSettings,
|
||||
"put",
|
||||
notePath
|
||||
);
|
||||
|
||||
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||
try {
|
||||
@@ -82,9 +135,32 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
|
||||
syncResult.code === 0,
|
||||
`p2p-sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
|
||||
);
|
||||
const rejectedPeer = await discoverPeer(rejectedVault, rejectedSettings, peersTimeout, hostPeerName);
|
||||
const rejectedSync = await runCli(
|
||||
rejectedVault,
|
||||
"--settings",
|
||||
rejectedSettings,
|
||||
"p2p-sync",
|
||||
rejectedPeer.id,
|
||||
String(syncTimeout)
|
||||
);
|
||||
assert(
|
||||
rejectedSync.code !== 0,
|
||||
`P2P accepted a different key for obfuscated document IDs.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
|
||||
);
|
||||
assert(
|
||||
rejectedSync.combined.includes("Tweak values are not matched"),
|
||||
`P2P failed before checking peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
|
||||
);
|
||||
} finally {
|
||||
await host.stop();
|
||||
}
|
||||
const received = sanitiseCatStdout(
|
||||
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", notePath)
|
||||
).trimEnd();
|
||||
assert(received === "A note transferred with the saved ID key.", "The host did not receive the keyed note.");
|
||||
const rejectedRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", notePath);
|
||||
assert(rejectedRead.code !== 0, "The rejected device received the keyed note.");
|
||||
} finally {
|
||||
await stopLocalRelayIfStarted(relayStarted);
|
||||
await stopCoturnIfStarted(coturnStarted);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.30-webapp",
|
||||
"version": "1.0.32-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.30-webpeer",
|
||||
"version": "1.0.32-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -204,6 +204,50 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Repair failed before the source was removed. Run inspection again before retrying.",
|
||||
"Connection settings": "Connection settings",
|
||||
"Saved connections": "Saved connections",
|
||||
"ID generation": "ID generation",
|
||||
"Keep current configuration": "Keep current configuration",
|
||||
"Set an ID key": "Set an ID key",
|
||||
"Current configuration: a saved ID key is used.": "Current configuration: a saved ID key is used.",
|
||||
"Current configuration: the saved ID key is retained while E2EE is off.":
|
||||
"Current configuration: the saved ID key is retained while E2EE is off.",
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.":
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.",
|
||||
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.":
|
||||
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.",
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.":
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.",
|
||||
Configured: "Configured",
|
||||
"The saved ID key is configured. Its source cannot be shown again.":
|
||||
"The saved ID key is configured. Its source cannot be shown again.",
|
||||
"Leave this input empty to keep the saved ID key.": "Leave this input empty to keep the saved ID key.",
|
||||
"Generate a random ID key": "Generate a random ID key",
|
||||
"How to set the ID key": "How to set the ID key",
|
||||
"Derive from current E2EE passphrase": "Derive from current E2EE passphrase",
|
||||
"Enter an ID source": "Enter an ID source",
|
||||
"Import an ID recovery code": "Import an ID recovery code",
|
||||
"ID source": "ID source",
|
||||
"ID recovery code": "ID recovery code",
|
||||
"Enter an ID recovery code": "Enter an ID recovery code",
|
||||
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.":
|
||||
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.",
|
||||
"Paste a tagged recovery code from an existing device to restore the same ID key.":
|
||||
"Paste a tagged recovery code from an existing device to restore the same ID key.",
|
||||
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.":
|
||||
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.",
|
||||
"Show current recovery code": "Show current recovery code",
|
||||
"Hide current recovery code": "Hide current recovery code",
|
||||
"Current ID recovery code": "Current ID recovery code",
|
||||
"Copy recovery code": "Copy recovery code",
|
||||
"Recovery code copied.": "Recovery code copied.",
|
||||
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.":
|
||||
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.",
|
||||
"The recovery code could not be copied. Select and copy the visible code instead.":
|
||||
"The recovery code could not be copied. Select and copy the visible code instead.",
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.":
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.",
|
||||
"An ID source is required to enable this option.": "An ID source is required to enable this option.",
|
||||
"The ID source or recovery code is invalid. Check it and try again.":
|
||||
"The ID source or recovery code is invalid. Check it and try again.",
|
||||
} as const;
|
||||
|
||||
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { usesEncryptedInternalMetadata } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
|
||||
|
||||
@@ -42,7 +43,10 @@ function projectHeaders(value: string): readonly (readonly [name: string, value:
|
||||
}
|
||||
|
||||
function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
return settings.encrypt
|
||||
return [
|
||||
settings.idDerivationVersion,
|
||||
settings.idDerivationKey,
|
||||
settings.encrypt
|
||||
? ([
|
||||
"encrypted",
|
||||
settings.passphrase,
|
||||
@@ -50,7 +54,8 @@ function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
settings.E2EEAlgorithm,
|
||||
settings.permitEmptyPassphrase,
|
||||
] as const)
|
||||
: (["plain"] as const);
|
||||
: (["plain"] as const),
|
||||
] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +82,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
|
||||
settings.useRequestAPI,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
usesEncryptedInternalMetadata(settings),
|
||||
settings.enableCompression,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@ describe("active Replicator configuration identity", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("replaces a connection when the independent ID key changes", () => {
|
||||
const first = configuredSettings({ idDerivationVersion: 1, idDerivationKey: "a".repeat(64) });
|
||||
const second = { ...first, idDerivationKey: "b".repeat(64) };
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(second)).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(first)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(second)).not.toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(first)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["couchDB_URI", "https://other.example.test/base"],
|
||||
["couchDB_DBNAME", "other-vault"],
|
||||
@@ -67,6 +78,22 @@ describe("active Replicator configuration identity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recreates the CouchDB connection when internal Metadata encryption becomes effective", () => {
|
||||
const active = configuredSettings({ usePathObfuscation: true, encryptInternalMetadata: false });
|
||||
const enabled = { ...active, encryptInternalMetadata: true };
|
||||
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(enabled)).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(active)
|
||||
);
|
||||
const inactive = { ...active, usePathObfuscation: false };
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...inactive, encryptInternalMetadata: true })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(inactive)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(enabled)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(active)
|
||||
);
|
||||
});
|
||||
|
||||
it("projects only the active CouchDB authentication mode", () => {
|
||||
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
|
||||
|
||||
@@ -80,6 +80,8 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
|
||||
pluginConfig.couchDB_USER = REDACTED;
|
||||
pluginConfig.passphrase = REDACTED;
|
||||
pluginConfig.encryptedPassphrase = REDACTED;
|
||||
pluginConfig.idDerivationKey = REDACTED;
|
||||
pluginConfig.encryptedIdDerivationKey = REDACTED;
|
||||
pluginConfig.encryptedCouchDBConnection = REDACTED;
|
||||
pluginConfig.accessKey = REDACTED;
|
||||
pluginConfig.secretKey = REDACTED;
|
||||
|
||||
@@ -10,6 +10,22 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
|
||||
}));
|
||||
|
||||
describe("TURN credentials in diagnostic reports", () => {
|
||||
it("redacts the derived ID key and its encrypted local wrapper", async () => {
|
||||
const key = "f3205cc41d24116d8c2484993c9d9a2e667373af338ba02f2ee71199adb82f2e";
|
||||
const wrapper = "encrypted-id-key-test-wrapper";
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
idDerivationVersion: 1 as const,
|
||||
idDerivationKey: key,
|
||||
encryptedIdDerivationKey: wrapper,
|
||||
};
|
||||
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
|
||||
const report = await generateReport(settings, core);
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(key);
|
||||
expect(text).not.toContain(wrapper);
|
||||
});
|
||||
|
||||
it("redacts provider tokens in all profiles and runtime credentials", async () => {
|
||||
const token = "private+token/with=symbols";
|
||||
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ObsidianLiveSyncSettings, SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||
import type {
|
||||
ReviewHarnessScenarioResult,
|
||||
ReviewHarnessScenarioStatus,
|
||||
} from "./reviewHarnessTypes";
|
||||
import type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
|
||||
|
||||
export type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
|
||||
|
||||
@@ -32,6 +29,14 @@ export const REVIEW_HARNESS_SCENARIOS = [
|
||||
mode: "automatic",
|
||||
access: "dedicated-vault-fixtures",
|
||||
},
|
||||
{
|
||||
id: "id-generation-performance",
|
||||
title: "ID generation performance",
|
||||
description:
|
||||
"Measures legacy and independent IDs with fixed in-memory inputs. Reports time per 1,000 IDs and per ID, key derivation time, and JavaScript heap samples where available. Keep Obsidian in the foreground.",
|
||||
mode: "automatic",
|
||||
access: "read-only",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const REVIEW_HARNESS_SCENARIO_IDS = REVIEW_HARNESS_SCENARIOS.map(({ id }) => id);
|
||||
@@ -114,7 +119,9 @@ const NEW_VAULT_RECOMMENDATION_KEYS = [
|
||||
"E2EEAlgorithm",
|
||||
] as const;
|
||||
|
||||
type LifecycleSettingKey = (typeof PRESERVED_SYNC_SETTING_KEYS)[number] | (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
|
||||
type LifecycleSettingKey =
|
||||
| (typeof PRESERVED_SYNC_SETTING_KEYS)[number]
|
||||
| (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
|
||||
type SettingsForLifecycleInspection = Partial<Pick<ObsidianLiveSyncSettings, LifecycleSettingKey>>;
|
||||
|
||||
export function inspectSettingsLifecycle(input: {
|
||||
@@ -130,9 +137,7 @@ export function inspectSettingsLifecycle(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter(
|
||||
(key) => typeof input.settings[key] !== "boolean"
|
||||
);
|
||||
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter((key) => typeof input.settings[key] !== "boolean");
|
||||
if (invalidSyncSettings.length > 0) {
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -205,6 +210,7 @@ export interface ReviewHarnessReportScenario {
|
||||
readonly mode: ReviewHarnessScenarioMode;
|
||||
readonly status: ReviewHarnessScenarioStatus;
|
||||
readonly detail: string;
|
||||
readonly observations?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ReviewHarnessReportInput {
|
||||
@@ -248,13 +254,15 @@ export function formatReviewHarnessReport(input: ReviewHarnessReportInput): stri
|
||||
);
|
||||
const scenarios = table(
|
||||
["Scenario", "Mode", "Status", "Detail"],
|
||||
input.scenarios.map(({ id, title, mode, status, detail }) => [
|
||||
`${title} (${id})`,
|
||||
mode,
|
||||
status,
|
||||
detail,
|
||||
])
|
||||
input.scenarios.map(({ id, title, mode, status, detail }) => [`${title} (${id})`, mode, status, detail])
|
||||
);
|
||||
const observations = input.scenarios
|
||||
.filter((scenario) => scenario.observations?.length)
|
||||
.map(
|
||||
({ title, observations }) =>
|
||||
`### ${title}\n\n${observations!.map((value) => `- ${tableCell(value)}`).join("\n")}`
|
||||
)
|
||||
.join("\n\n");
|
||||
return `## Self-hosted LiveSync Review Harness report
|
||||
|
||||
Generated at \`${tableCell(input.generatedAt)}\`.
|
||||
@@ -267,6 +275,8 @@ ${environment}
|
||||
|
||||
${scenarios}
|
||||
|
||||
${observations}
|
||||
|
||||
<details>
|
||||
<summary>Event transcript</summary>
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ describe("Review Harness contract", () => {
|
||||
"settings-lifecycle",
|
||||
"compatibility-review",
|
||||
"vault-round-trip",
|
||||
"id-generation-performance",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ReviewHarnessRuntime {
|
||||
getCompatibilityPause(): CompatibilityPause | undefined;
|
||||
openCompatibilityReview(): Promise<void>;
|
||||
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
readContinuation(): string | null;
|
||||
writeContinuation(value: string): void;
|
||||
deleteContinuation(): void;
|
||||
@@ -159,6 +160,8 @@ export class ReviewHarnessController {
|
||||
});
|
||||
} else if (id === "vault-round-trip") {
|
||||
result = await this.runtime.runVaultRoundTrip();
|
||||
} else if (id === "id-generation-performance") {
|
||||
result = await this.runtime.runIdBenchmark();
|
||||
} else {
|
||||
const inspection = this.inspectCompatibilityReview();
|
||||
result =
|
||||
@@ -206,10 +209,7 @@ export class ReviewHarnessController {
|
||||
detail: "The device-local compatibility review remains pending.",
|
||||
observations: inspection.observations,
|
||||
};
|
||||
this.record(
|
||||
"compatibility-review-updated",
|
||||
this.results["compatibility-review"].status
|
||||
);
|
||||
this.record("compatibility-review-updated", this.results["compatibility-review"].status);
|
||||
} catch (error) {
|
||||
this.setUnexpectedFailure("compatibility-review", error);
|
||||
} finally {
|
||||
@@ -259,6 +259,7 @@ export class ReviewHarnessController {
|
||||
mode,
|
||||
status: this.results[id].status,
|
||||
detail: this.results[id].detail,
|
||||
observations: this.results[id].observations,
|
||||
})),
|
||||
transcript: this.transcript,
|
||||
});
|
||||
|
||||
@@ -80,6 +80,11 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
detail: "The owned fixture tree was exercised and removed.",
|
||||
observations: [],
|
||||
})),
|
||||
runIdBenchmark: vi.fn(async () => ({
|
||||
status: "passed" as const,
|
||||
detail: "ID generation measurements completed.",
|
||||
observations: ["Chunk 256 B: 1000 IDs total=43.00 ms; per ID=0.0430 ms"],
|
||||
})),
|
||||
readContinuation() {
|
||||
return this.continuation;
|
||||
},
|
||||
@@ -150,6 +155,60 @@ describe("ReviewHarnessController", () => {
|
||||
expect(runtime.reportError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("runs ID measurements on request and includes their units in the copied report", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await controller.runAutomaticScenarios();
|
||||
expect(runtime.runIdBenchmark).not.toHaveBeenCalled();
|
||||
|
||||
await controller.runScenario("id-generation-performance");
|
||||
await controller.copyReport();
|
||||
|
||||
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
|
||||
expect(controller.snapshot().results["id-generation-performance"].status).toBe("passed");
|
||||
expect(vi.mocked(runtime.copyText).mock.calls[0][0]).toContain("1000 IDs total=43.00 ms; per ID=0.0430 ms");
|
||||
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
|
||||
expect(runtime.events).toEqual([]);
|
||||
expect(runtime.continuation).toBeNull();
|
||||
});
|
||||
|
||||
it("excludes an unexpected measurement error from the copied report", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.runIdBenchmark = vi.fn().mockRejectedValue(new Error("private measurement error"));
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await controller.runScenario("id-generation-performance");
|
||||
|
||||
expect(controller.snapshot().results["id-generation-performance"].status).toBe("failed");
|
||||
expect(controller.createReport()).not.toContain("private measurement error");
|
||||
expect(runtime.reportError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not overlap an ID measurement with another scenario", async () => {
|
||||
const runtime = createRuntime();
|
||||
let finish!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
runtime.runIdBenchmark = vi.fn(async () => {
|
||||
await pending;
|
||||
return { status: "passed" as const, detail: "Measured", observations: [] };
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const running = controller.runScenario("id-generation-performance");
|
||||
await controller.runScenario("id-generation-performance");
|
||||
await controller.runScenario("vault-round-trip");
|
||||
|
||||
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
|
||||
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().running).toBe(true);
|
||||
finish();
|
||||
await running;
|
||||
expect(controller.snapshot().running).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
@@ -167,9 +226,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||
status: "waiting-for-user",
|
||||
});
|
||||
expect(controller.snapshot().resumedRequestId).toBe(
|
||||
"compatibility-review-2026-07-18T11:59:00.000Z"
|
||||
);
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
});
|
||||
|
||||
it("does not copy rejected continuation values into the report", () => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||
|
||||
export interface IdBenchmarkOperations {
|
||||
deriveKey(): Promise<unknown>;
|
||||
chunkId(piece: string, independent: boolean): Promise<string>;
|
||||
documentId(path: string, independent: boolean): Promise<string>;
|
||||
}
|
||||
|
||||
type BenchmarkPerformance = Pick<Performance, "now"> & {
|
||||
readonly memory?: { readonly usedJSHeapSize: number };
|
||||
};
|
||||
|
||||
const ID_COUNT = 1000;
|
||||
const SAMPLES = 3;
|
||||
const BATCH_SIZE = 100;
|
||||
const WARMUP_COUNT = 32;
|
||||
|
||||
function readHeap(clock: BenchmarkPerformance): number | undefined {
|
||||
try {
|
||||
const bytes = clock.memory?.usedJSHeapSize;
|
||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function summary(samples: readonly number[]): string {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`;
|
||||
}
|
||||
|
||||
export async function runReviewHarnessIdBenchmark(
|
||||
operations: IdBenchmarkOperations,
|
||||
clock: BenchmarkPerformance = performance,
|
||||
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
): Promise<ReviewHarnessScenarioResult> {
|
||||
const before = readHeap(clock);
|
||||
let highest = before;
|
||||
const sampleHeap = () => {
|
||||
const value = readHeap(clock);
|
||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
||||
return value;
|
||||
};
|
||||
const observations = [
|
||||
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
|
||||
"Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.",
|
||||
];
|
||||
const derivationSamples: number[] = [];
|
||||
for (let sample = 0; sample < SAMPLES; sample++) {
|
||||
await yieldControl();
|
||||
const started = clock.now();
|
||||
await operations.deriveKey();
|
||||
derivationSamples.push(clock.now() - started);
|
||||
sampleHeap();
|
||||
}
|
||||
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
|
||||
|
||||
const cases = [
|
||||
...[256, 4096, 32768].map((bytes) => {
|
||||
const prefix = "r".repeat(bytes - 8);
|
||||
return {
|
||||
label: `Chunk IDs, ${bytes} B`,
|
||||
run: (i: number, independent: boolean) =>
|
||||
operations.chunkId(prefix + i.toString(36).padStart(8, "0"), independent),
|
||||
};
|
||||
}),
|
||||
{
|
||||
label: "Obfuscated document IDs",
|
||||
run: (i: number, independent: boolean) => operations.documentId(`benchmark/path-${i}.md`, independent),
|
||||
},
|
||||
];
|
||||
for (const scenario of cases) {
|
||||
const samples: [number[], number[]] = [[], []];
|
||||
for (let sample = 0; sample < SAMPLES; sample++) {
|
||||
for (const independent of sample % 2 === 0 ? [false, true] : [true, false]) {
|
||||
for (let i = 0; i < WARMUP_COUNT; i++) await scenario.run(i, independent);
|
||||
let elapsed = 0;
|
||||
for (let batch = 0; batch < ID_COUNT; batch += BATCH_SIZE) {
|
||||
await yieldControl();
|
||||
const started = clock.now();
|
||||
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
|
||||
elapsed += clock.now() - started;
|
||||
sampleHeap();
|
||||
}
|
||||
samples[independent ? 1 : 0].push(elapsed);
|
||||
}
|
||||
}
|
||||
for (const [index, values] of samples.entries()) {
|
||||
const median = [...values].sort((a, b) => a - b)[1];
|
||||
observations.push(
|
||||
`${scenario.label}, ${index === 0 ? "legacy" : "independent"}: ${ID_COUNT} IDs total ${summary(values)}; per ID=${(median / ID_COUNT).toFixed(4)} ms.`
|
||||
);
|
||||
}
|
||||
}
|
||||
const after = sampleHeap();
|
||||
if (highest === undefined) {
|
||||
observations.push("JavaScript heap: unavailable on this device.");
|
||||
} else {
|
||||
const mib = (bytes: number | undefined) =>
|
||||
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
||||
observations.push(
|
||||
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
|
||||
);
|
||||
}
|
||||
observations.push(
|
||||
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
||||
);
|
||||
return { status: "passed", detail: "ID generation measurements completed.", observations };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runReviewHarnessIdBenchmark, type IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
|
||||
|
||||
function fixture() {
|
||||
let elapsed = 0;
|
||||
let derivations = 0;
|
||||
const chunkCounts = [0, 0];
|
||||
const documentCounts = [0, 0];
|
||||
const chunkSizes = new Set<number>();
|
||||
const operations: IdBenchmarkOperations = {
|
||||
deriveKey: () => {
|
||||
derivations++;
|
||||
elapsed += 42;
|
||||
return Promise.resolve("private-derived-key");
|
||||
},
|
||||
chunkId: (piece, independent) => {
|
||||
chunkCounts[independent ? 1 : 0]++;
|
||||
chunkSizes.add(piece.length);
|
||||
elapsed += independent ? 2 : 1;
|
||||
return Promise.resolve("private-chunk-id");
|
||||
},
|
||||
documentId: (_path, independent) => {
|
||||
documentCounts[independent ? 1 : 0]++;
|
||||
elapsed += independent ? 4 : 3;
|
||||
return Promise.resolve("private-document-id");
|
||||
},
|
||||
};
|
||||
return {
|
||||
operations,
|
||||
now: () => elapsed,
|
||||
yieldControl: () => {
|
||||
elapsed += 100;
|
||||
return Promise.resolve();
|
||||
},
|
||||
counts: () => ({ derivations, chunkCounts, documentCounts, chunkSizes: [...chunkSizes] }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Review Harness ID measurements", () => {
|
||||
it("reports totals and per-ID timings separately, excluding warm-up and cooperative pauses", async () => {
|
||||
const f = fixture();
|
||||
const result = await runReviewHarnessIdBenchmark(f.operations, { now: f.now }, f.yieldControl);
|
||||
const report = result.observations.join("\n");
|
||||
|
||||
expect(result.status).toBe("passed");
|
||||
expect(report).toContain("1000 IDs total median=1000.00 ms; range=1000.00–1000.00 ms; per ID=1.0000 ms");
|
||||
expect(report).toContain("1000 IDs total median=2000.00 ms; range=2000.00–2000.00 ms; per ID=2.0000 ms");
|
||||
expect(report).toContain("Obfuscated document IDs, legacy: 1000 IDs total median=3000.00 ms");
|
||||
expect(report).toContain("Obfuscated document IDs, independent: 1000 IDs total median=4000.00 ms");
|
||||
expect(report).toContain("ID key derivation at save time: median=42.00 ms");
|
||||
expect(report).toContain("JavaScript heap: unavailable on this device.");
|
||||
expect(report).not.toContain("private-");
|
||||
expect(f.counts()).toEqual({
|
||||
derivations: 3,
|
||||
chunkCounts: [9288, 9288],
|
||||
documentCounts: [3096, 3096],
|
||||
chunkSizes: [256, 4096, 32768],
|
||||
});
|
||||
});
|
||||
|
||||
it("labels the highest sampled heap separately from total app RAM and allows a lower final sample", async () => {
|
||||
const f = fixture();
|
||||
let reads = 0;
|
||||
const clock = {
|
||||
now: f.now,
|
||||
get memory() {
|
||||
return { usedJSHeapSize: (reads++ === 0 ? 2 : reads === 2 ? 5 : 1) * 1048576 };
|
||||
},
|
||||
};
|
||||
const result = await runReviewHarnessIdBenchmark(f.operations, clock, f.yieldControl);
|
||||
|
||||
expect(result.observations).toContain(
|
||||
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
||||
);
|
||||
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
|
||||
"keeps timings usable when the heap API returns %s",
|
||||
async (value) => {
|
||||
const f = fixture();
|
||||
const result = await runReviewHarnessIdBenchmark(
|
||||
f.operations,
|
||||
{
|
||||
now: f.now,
|
||||
get memory() {
|
||||
if (value === "throws") throw new Error("Heap API unavailable");
|
||||
return { usedJSHeapSize: value as number };
|
||||
},
|
||||
},
|
||||
f.yieldControl
|
||||
);
|
||||
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.observations).toContain("JavaScript heap: unavailable on this device.");
|
||||
expect(result.observations.join("\n")).not.toMatch(/NaN|Infinity|private-/u);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DEFAULT_SETTINGS, deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { path2id_base } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { HashManager } from "@vrtmrz/livesync-commonlib/hashing";
|
||||
import type { IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
|
||||
|
||||
const FIXTURE_PASSPHRASE = "Self-hosted LiveSync ID benchmark passphrase";
|
||||
const FIXTURE_SOURCE = "Self-hosted LiveSync ID benchmark source";
|
||||
const FIXTURE_KEY = "ab".repeat(32);
|
||||
|
||||
export async function createIdBenchmarkOperations(): Promise<IdBenchmarkOperations> {
|
||||
const managers: HashManager[] = [];
|
||||
for (const independent of [false, true]) {
|
||||
const settings = Object.freeze({
|
||||
...DEFAULT_SETTINGS,
|
||||
encrypt: true,
|
||||
passphrase: FIXTURE_PASSPHRASE,
|
||||
hashAlg: "xxhash64" as const,
|
||||
idDerivationVersion: independent ? (1 as const) : (0 as const),
|
||||
idDerivationKey: independent ? FIXTURE_KEY : "",
|
||||
});
|
||||
// HashManager only reads currentSettings; this fixture has no storage or live service access.
|
||||
const settingService = { currentSettings: () => settings } as HashManager["options"]["settingService"];
|
||||
const manager = new HashManager({ settingService });
|
||||
if (!(await manager.initialise())) throw new Error("The benchmark hash manager could not initialise.");
|
||||
managers.push(manager);
|
||||
}
|
||||
return {
|
||||
deriveKey: () => deriveIdKey(FIXTURE_SOURCE),
|
||||
chunkId: (piece, independent) => managers[independent ? 1 : 0].computeHash(piece),
|
||||
// Fixture paths are already normalised; use the same ID calculation as PathService.
|
||||
documentId: (path, independent) =>
|
||||
path2id_base(path as FilePath, FIXTURE_PASSPHRASE, false, independent ? FIXTURE_KEY : undefined),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createIdBenchmarkOperations } from "./reviewHarnessIdBenchmarkRuntime";
|
||||
|
||||
describe("Review Harness benchmark implementation", () => {
|
||||
it("uses the packaged legacy and independent algorithms with isolated fixed settings", async () => {
|
||||
const originalDefaults = structuredClone(DEFAULT_SETTINGS);
|
||||
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network access is forbidden"));
|
||||
try {
|
||||
const operations = await createIdBenchmarkOperations();
|
||||
const chunk = "r".repeat(256);
|
||||
const legacy = await operations.chunkId(chunk, false);
|
||||
const independent = await operations.chunkId(chunk, true);
|
||||
|
||||
expect(legacy).toMatch(/^\+[0-9a-z]{1,13}$/u);
|
||||
expect(independent).toMatch(/^\+[0-9a-f]{64}$/u);
|
||||
expect(independent).toBe("+9223e53d99e80c29effee9e95e38ed168d13c14f717054f9e996a1cd0a597000");
|
||||
expect(await operations.chunkId(chunk, false)).toBe(legacy);
|
||||
expect(await operations.chunkId(chunk, true)).toBe(independent);
|
||||
expect(await operations.chunkId("s".repeat(256), true)).not.toBe(independent);
|
||||
|
||||
const legacyPath = await operations.documentId("benchmark/path-1.md", false);
|
||||
const independentPath = await operations.documentId("benchmark/path-1.md", true);
|
||||
expect(legacyPath).toMatch(/^f:[0-9a-f]{64}$/u);
|
||||
expect(independentPath).toMatch(/^f:[0-9a-f]{64}$/u);
|
||||
expect(legacyPath).not.toBe(independentPath);
|
||||
expect(await operations.documentId("benchmark/path-1.md", true)).toBe(independentPath);
|
||||
|
||||
const second = await createIdBenchmarkOperations();
|
||||
expect(await second.chunkId(chunk, true)).toBe(independent);
|
||||
expect(await operations.deriveKey()).toMatch(/^[0-9a-f]{64}$/u);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(DEFAULT_SETTINGS).toEqual(originalDefaults);
|
||||
} finally {
|
||||
fetch.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,17 @@ function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string
|
||||
}
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private requiresIdConfigurationReview(assessment: TweakAssessment): boolean {
|
||||
if (!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")) {
|
||||
return false;
|
||||
}
|
||||
Logger(
|
||||
"The document ID configurations differ. Import the correct Setup URI, or configure the matching ID key, before synchronising.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
|
||||
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
|
||||
const currentModified = current.tweakModified;
|
||||
@@ -196,6 +207,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
assessment = assessTweakCompatibility(this.settings, preferred)
|
||||
): Promise<[TweakValues | boolean, boolean]> {
|
||||
if (assessment.alignment === "matched") return [false, false];
|
||||
if (this.requiresIdConfigurationReview(assessment)) return [false, false];
|
||||
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
|
||||
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
|
||||
@@ -363,6 +375,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
const trialSignature = JSON.stringify(trialSetting);
|
||||
const currentSignature = resolutionSettingsSignature(this.settings);
|
||||
const assessment = assessTweakCompatibility(trialSetting, preferred);
|
||||
if (this.requiresIdConfigurationReview(assessment)) return { result: false, requireFetch: false };
|
||||
if (assessment.alignment === "matched") {
|
||||
this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
|
||||
return { result: false, requireFetch: false };
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type TweakValues,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { assessTweakCompatibility, configuredIdKey } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
import { setLang } from "@/common/translation";
|
||||
import {
|
||||
@@ -74,6 +74,68 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it.each([0, 1] as const)(
|
||||
"keeps ID configuration %s when automatically aligning Chunk settings",
|
||||
async (idDerivationVersion) => {
|
||||
const idDerivationKey = idDerivationVersion === 1 ? "ab".repeat(32) : "";
|
||||
const { module, core, askSelectStringDialogue } = createModule({
|
||||
encrypt: true,
|
||||
usePathObfuscation: false,
|
||||
idDerivationVersion,
|
||||
idDerivationKey,
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 1,
|
||||
});
|
||||
const preferred: TweakValues = {
|
||||
...extractObject(TweakValuesTemplate, core.settings),
|
||||
idDerivationVersion: idDerivationVersion === 1 ? 0 : 1,
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 2,
|
||||
};
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
|
||||
};
|
||||
core._services.setting.saveSettingData.mockImplementation(async () => {
|
||||
configuredIdKey(core.settings);
|
||||
});
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).resolves.toBe("CHECKAGAIN");
|
||||
|
||||
expect(core.settings).toMatchObject({ idDerivationVersion, idDerivationKey, hashAlg: "xxhash32" });
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["active", "trial"] as const)(
|
||||
"withholds ordinary tweak adoption for different document ID modes (%s)",
|
||||
async (route) => {
|
||||
const { module, core, askSelectStringDialogue } = createModule({
|
||||
encrypt: true,
|
||||
usePathObfuscation: true,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
});
|
||||
const preferred: TweakValues = {
|
||||
...extractObject(TweakValuesTemplate, core.settings),
|
||||
idDerivationVersion: 1,
|
||||
};
|
||||
|
||||
if (route === "active") {
|
||||
await expect(module._checkAndAskResolvingMismatchedTweaks(preferred)).resolves.toEqual([false, false]);
|
||||
} else {
|
||||
await expect(module._askUseRemoteConfiguration(core.settings, preferred)).resolves.toEqual({
|
||||
result: false,
|
||||
requireFetch: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(core.settings).toMatchObject({ idDerivationVersion: 0, idDerivationKey: "" });
|
||||
}
|
||||
);
|
||||
|
||||
it("compatibility: offers ordinary application for a missing legacy filename-case setting", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
collectingChunks,
|
||||
chunkFetchCounts,
|
||||
pluginScanningCount,
|
||||
hiddenFilesEventCount,
|
||||
hiddenFilesProcessingCount,
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
import { createMinimumVisibleActivityCount, createPaddedCounterLabel } from "./StatusBarDisplay.ts";
|
||||
import {
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
@@ -140,7 +144,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const labelStorageCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.storageApplyingCount, `💾`)
|
||||
);
|
||||
const labelChunkCount = registerDisplay(createPaddedCounterLabel(collectingChunks, `🧩`));
|
||||
const labelChunkCount = registerDisplay(createChunkFetchCounterLabel(chunkFetchCounts));
|
||||
const labelPluginScanCount = registerDisplay(createPaddedCounterLabel(pluginScanningCount, `🔌`));
|
||||
const labelConflictProcessCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.conflict.conflictProcessQueueCount, `🔩`)
|
||||
|
||||
@@ -140,6 +140,8 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
settingToApply.couchDB_USER = this.settings.couchDB_USER;
|
||||
settingToApply.couchDB_PASSWORD = this.settings.couchDB_PASSWORD;
|
||||
settingToApply.passphrase = this.settings.passphrase;
|
||||
settingToApply.idDerivationVersion = this.settings.idDerivationVersion;
|
||||
settingToApply.idDerivationKey = this.settings.idDerivationKey;
|
||||
}
|
||||
const oldSetting = this.generateSettingForMarkdown(
|
||||
this.settings,
|
||||
@@ -203,11 +205,13 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
const saveData = { ...(settings ? settings : this.settings) } as Partial<ObsidianLiveSyncSettings>;
|
||||
delete saveData.encryptedCouchDBConnection;
|
||||
delete saveData.encryptedPassphrase;
|
||||
delete saveData.encryptedIdDerivationKey;
|
||||
delete saveData.additionalSuffixOfDatabaseName;
|
||||
if (!saveData.writeCredentialsForSettingSync && !keepCredential) {
|
||||
delete saveData.couchDB_USER;
|
||||
delete saveData.couchDB_PASSWORD;
|
||||
delete saveData.passphrase;
|
||||
delete saveData.idDerivationKey;
|
||||
delete saveData.jwtKey;
|
||||
delete saveData.jwtKid;
|
||||
delete saveData.jwtSub;
|
||||
|
||||
@@ -569,6 +569,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
|
||||
// Internal Metadata encryption affects future Metadata writes and is not a rebuild requirement.
|
||||
isNeedRebuildLocal() {
|
||||
return this.isSomeDirty([
|
||||
"useIndexedDBAdapter",
|
||||
|
||||
@@ -47,6 +47,12 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
|
||||
}
|
||||
return workObj;
|
||||
}
|
||||
|
||||
function syncIdDerivationSettings(target: Partial<ObsidianLiveSyncSettings>, source: ObsidianLiveSyncSettings): void {
|
||||
target.idDerivationVersion = source.idDerivationVersion;
|
||||
target.idDerivationKey = source.idDerivationKey;
|
||||
}
|
||||
|
||||
function createRemoteConfigurationId(): string {
|
||||
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -116,7 +122,28 @@ export function paneRemoteConfig(
|
||||
.onClick(async () => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
|
||||
await setupManager.onlyE2EEConfiguration(UserMode.Update, originalSettings);
|
||||
const originalIdDerivationVersion = this.core.settings.idDerivationVersion;
|
||||
const originalIdDerivationKey = this.core.settings.idDerivationKey;
|
||||
const applied = await setupManager.onlyE2EEConfiguration(UserMode.Update, originalSettings);
|
||||
if (applied) {
|
||||
this.editingSettings.encryptInternalMetadata =
|
||||
this.core.settings.encryptInternalMetadata;
|
||||
if (this.initialSettings) {
|
||||
this.initialSettings.encryptInternalMetadata =
|
||||
this.core.settings.encryptInternalMetadata;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
if (
|
||||
this.core.settings.idDerivationVersion !== originalIdDerivationVersion ||
|
||||
this.core.settings.idDerivationKey !== originalIdDerivationKey
|
||||
) {
|
||||
syncIdDerivationSettings(this.editingSettings, this.core.settings);
|
||||
if (this.initialSettings) {
|
||||
syncIdDerivationSettings(this.initialSettings, this.core.settings);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
updateE2EESummary();
|
||||
})
|
||||
.setButtonText("Configure")
|
||||
@@ -155,9 +182,11 @@ export function paneRemoteConfig(
|
||||
const currentConfigs = cloneRemoteConfigurations(this.core.settings.remoteConfigurations);
|
||||
this.editingSettings.remoteConfigurations = currentConfigs;
|
||||
this.editingSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
|
||||
syncIdDerivationSettings(this.editingSettings, this.core.settings);
|
||||
if (this.initialSettings) {
|
||||
this.initialSettings.remoteConfigurations = cloneRemoteConfigurations(currentConfigs);
|
||||
this.initialSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
|
||||
syncIdDerivationSettings(this.initialSettings, this.core.settings);
|
||||
}
|
||||
};
|
||||
const persistRemoteConfigurations = async (synchroniseActiveRemote: boolean = false) => {
|
||||
@@ -243,7 +272,10 @@ export function paneRemoteConfig(
|
||||
...DEFAULT_SETTINGS,
|
||||
encrypt: this.editingSettings.encrypt,
|
||||
usePathObfuscation: this.editingSettings.usePathObfuscation,
|
||||
encryptInternalMetadata: this.editingSettings.encryptInternalMetadata,
|
||||
passphrase: this.editingSettings.passphrase,
|
||||
idDerivationVersion: this.editingSettings.idDerivationVersion,
|
||||
idDerivationKey: this.editingSettings.idDerivationKey,
|
||||
configPassphraseStore: this.editingSettings.configPassphraseStore,
|
||||
});
|
||||
const addRemoteConfiguration = async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
buttonClasses: [] as string[],
|
||||
clickHandlers: [] as Array<() => Promise<void> | void>,
|
||||
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
|
||||
settingClasses: [] as string[],
|
||||
}));
|
||||
@@ -51,7 +52,8 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
setDestructive() {
|
||||
return this;
|
||||
},
|
||||
onClick() {
|
||||
onClick(callback: () => Promise<void> | void) {
|
||||
runtime.clickHandlers.push(callback);
|
||||
return this;
|
||||
},
|
||||
setButtonText() {
|
||||
@@ -97,6 +99,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/ConnectionString", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
|
||||
@@ -114,6 +117,7 @@ function createPanelElement(): HTMLElement {
|
||||
|
||||
afterEach(() => {
|
||||
runtime.buttonClasses.length = 0;
|
||||
runtime.clickHandlers.length = 0;
|
||||
runtime.panels.length = 0;
|
||||
runtime.settingClasses.length = 0;
|
||||
vi.clearAllMocks();
|
||||
@@ -148,4 +152,93 @@ describe("paneRemoteConfig", () => {
|
||||
|
||||
expect(runtime.panels[0].destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies an internal Metadata preference change without scheduling setup initialisation", async () => {
|
||||
const originalSettings = {
|
||||
encrypt: true,
|
||||
passphrase: "passphrase",
|
||||
E2EEAlgorithm: "v2",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: false,
|
||||
remoteConfigurations: {},
|
||||
};
|
||||
const setupManager = {
|
||||
onlyE2EEConfiguration: vi.fn(async () => {
|
||||
host.core.settings.encryptInternalMetadata = true;
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
const host = {
|
||||
editingSettings: { ...originalSettings },
|
||||
initialSettings: { ...originalSettings },
|
||||
core: {
|
||||
settings: { ...originalSettings },
|
||||
getModule: vi.fn(() => setupManager),
|
||||
},
|
||||
lifetimeComponent: { register: vi.fn() },
|
||||
requestUpdate: vi.fn(),
|
||||
};
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "E2EE Configuration") {
|
||||
callback(createPanelElement());
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
|
||||
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
|
||||
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
|
||||
expect(host.initialSettings.encryptInternalMetadata).toBe(true);
|
||||
expect(host.requestUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("copies applied ID derivation settings into both dialogue buffers", async () => {
|
||||
const nextIdKey = "ab".repeat(32);
|
||||
const originalSettings = {
|
||||
encrypt: true,
|
||||
passphrase: "passphrase",
|
||||
E2EEAlgorithm: "v2",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: false,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
remoteConfigurations: {},
|
||||
};
|
||||
const setupManager = {
|
||||
onlyE2EEConfiguration: vi.fn(() => {
|
||||
host.core.settings.idDerivationVersion = 1;
|
||||
host.core.settings.idDerivationKey = nextIdKey;
|
||||
return Promise.resolve(false);
|
||||
}),
|
||||
};
|
||||
const host = {
|
||||
editingSettings: { ...originalSettings },
|
||||
initialSettings: { ...originalSettings },
|
||||
core: {
|
||||
settings: { ...originalSettings },
|
||||
getModule: vi.fn(() => setupManager),
|
||||
},
|
||||
lifetimeComponent: { register: vi.fn() },
|
||||
requestUpdate: vi.fn(),
|
||||
};
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "E2EE Configuration") {
|
||||
callback(createPanelElement());
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
|
||||
expect(host.editingSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
|
||||
expect(host.initialSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.initialSettings.idDerivationKey).toBe(nextIdKey);
|
||||
expect(host.requestUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ export function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdva
|
||||
export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncSettings>, showAdvanced = false) {
|
||||
const outputSummary: Record<string, string> = {};
|
||||
for (const key of Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[]) {
|
||||
if (key === "idDerivationKey" || key === "encryptedIdDerivationKey") continue;
|
||||
const config = getConfig(key as AllSettingItemKey);
|
||||
if (!config) continue;
|
||||
if (config.isAdvanced && !showAdvanced) continue;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
LOG_LEVEL_NOTICE,
|
||||
@@ -36,6 +35,7 @@ import type {
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData,
|
||||
SetupRemoteP2PInitialData,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
@@ -58,6 +58,20 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
|
||||
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
|
||||
return {
|
||||
...settings,
|
||||
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
|
||||
? settings.idDerivationVersion
|
||||
: 0,
|
||||
idDerivationKey: Object.prototype.hasOwnProperty.call(settings, "idDerivationKey")
|
||||
? settings.idDerivationKey
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User modes for onboarding and setup
|
||||
*/
|
||||
@@ -219,7 +233,7 @@ export class SetupManager extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
||||
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,14 +342,44 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
currentSetting
|
||||
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
const onlyInternalMetadataPreferenceChanged =
|
||||
currentSetting.encryptInternalMetadata !== e2eeConf.encryptInternalMetadata &&
|
||||
currentSetting.encrypt === e2eeConf.encrypt &&
|
||||
currentSetting.passphrase === e2eeConf.passphrase &&
|
||||
currentSetting.E2EEAlgorithm === e2eeConf.E2EEAlgorithm &&
|
||||
currentSetting.usePathObfuscation === e2eeConf.usePathObfuscation &&
|
||||
currentSetting.idDerivationVersion === e2eeConf.idDerivationVersion &&
|
||||
currentSetting.idDerivationKey === e2eeConf.idDerivationKey;
|
||||
if (userMode === UserMode.Update && onlyInternalMetadataPreferenceChanged) {
|
||||
if (e2eeConf.encryptInternalMetadata && currentSetting.remoteType === REMOTE_COUCHDB) {
|
||||
const proceed = "Enable without rebuilding — update every other device first";
|
||||
const choice = await this.core.confirm.askSelectStringDialogue(
|
||||
"A manual remote Rebuild is strongly recommended to protect existing file properties. " +
|
||||
"Before continuing without rebuilding, update every other synchronising device to a version " +
|
||||
"which supports this option, including devices currently running LiveSync. " +
|
||||
"Existing properties remain unchanged until they are rewritten or rebuilt.",
|
||||
[proceed, "Cancel"],
|
||||
{ title: "Encrypt internal file Properties", defaultAction: "Cancel" }
|
||||
);
|
||||
if (choice !== proceed) return false;
|
||||
}
|
||||
await this.services.setting.applyPartial(
|
||||
{ encryptInternalMetadata: e2eeConf.encryptInternalMetadata },
|
||||
true
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const newSetting = {
|
||||
...currentSetting,
|
||||
...e2eeConf,
|
||||
@@ -350,9 +394,12 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
originalSetting
|
||||
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
@@ -496,7 +543,13 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns Promise that resolves to true if settings applied successfully, false otherwise
|
||||
*/
|
||||
async decodeQR(qr: string) {
|
||||
const newSettings = decodeSettingsFromQRCodeData(qr);
|
||||
let newSettings: ObsidianLiveSyncSettings;
|
||||
try {
|
||||
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
|
||||
} catch {
|
||||
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSettings, UserMode.Unknown);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,58 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const savedKey = "12".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
const imported = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.idDerivationVersion;
|
||||
delete imported.idDerivationKey;
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.idDerivationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
});
|
||||
|
||||
it("does not inherit the missing half of a partially present Setup URI ID configuration", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const savedKey = "34".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
const imported = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.idDerivationKey;
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://partial-settings");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.idDerivationVersion).toBe(1);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
});
|
||||
|
||||
it("compatibility: normalises imported flat remote settings from QR data before applying", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
|
||||
@@ -208,6 +260,79 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("compatibility: applies legacy defaults when QR data omits ID derivation fields", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const savedKey = "56".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
const imported = { ...createLegacyRemoteSetting(), isConfigured: true } as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.idDerivationVersion;
|
||||
delete imported.idDerivationKey;
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(imported as ObsidianLiveSyncSettings);
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.decodeQR("qr-data");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.idDerivationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
});
|
||||
|
||||
it("rejects invalid QR settings before applying them", async () => {
|
||||
const { manager, setting } = createSetupManager();
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockImplementationOnce(() => {
|
||||
throw new Error("Invalid ID derivation key");
|
||||
});
|
||||
const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings");
|
||||
|
||||
await expect(manager.decodeQR("invalid-qr")).resolves.toBe(false);
|
||||
expect(applyExternalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the normal Fetch choice when ID derivation changes with the Metadata preference", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
const currentSettings: ObsidianLiveSyncSettings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
encrypt: true,
|
||||
passphrase: "e2ee-passphrase",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: false,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
};
|
||||
const nextIdKey = "78".repeat(32);
|
||||
setting.settings = currentSettings;
|
||||
const applyPartial = vi.spyOn(setting, "applyPartial");
|
||||
core.confirm = {
|
||||
askSelectStringDialogue: vi.fn(() =>
|
||||
Promise.resolve("Enable without rebuilding — update every other device first")
|
||||
),
|
||||
};
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
...currentSettings,
|
||||
encryptInternalMetadata: true,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: nextIdKey,
|
||||
})
|
||||
.mockResolvedValueOnce("existing-user")
|
||||
.mockResolvedValueOnce("apply");
|
||||
|
||||
await manager.onlyE2EEConfiguration(UserMode.Update, currentSettings);
|
||||
|
||||
expect(applyPartial).not.toHaveBeenCalled();
|
||||
expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(setting.currentSettings().idDerivationVersion).toBe(1);
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(nextIdKey);
|
||||
});
|
||||
|
||||
it("reserves Rebuild before saving a new-user configuration", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
setting.settings = { ...setting.currentSettings(), isConfigured: false };
|
||||
@@ -659,3 +784,23 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().P2P_ActiveRemoteConfigurationId).toBe("existing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal Metadata configuration", () => {
|
||||
it.each([true, false])(
|
||||
"applies the preference only after accepting the no-Rebuild warning (%s)",
|
||||
async (accept) => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
const current = { ...setting.settings, encryptInternalMetadata: false, remoteType: REMOTE_COUCHDB };
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValue({ ...current, encryptInternalMetadata: true });
|
||||
const ask = vi.fn(async (_message: string, choices: string[]) => (accept ? choices[0] : "Cancel"));
|
||||
core.confirm = { askSelectStringDialogue: ask };
|
||||
const apply = vi.spyOn(setting, "applyPartial").mockResolvedValue(undefined);
|
||||
await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(accept);
|
||||
expect(ask.mock.calls[0][1][0]).toContain("update every other device first");
|
||||
expect(ask.mock.calls[0][0]).toContain("currently running LiveSync");
|
||||
expect(apply).toHaveBeenCalledTimes(accept ? 1 : 0);
|
||||
expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,38 +13,144 @@
|
||||
E2EEAlgorithms,
|
||||
type EncryptionSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
deriveIdKey,
|
||||
deriveOrImportIdKey,
|
||||
formatIdRecoveryCode,
|
||||
ID_DERIVATION_VERSION,
|
||||
ID_RECOVERY_CODE_PREFIX,
|
||||
} from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { copyTo, pickEncryptionSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteE2EEResultType } from "./setupDialogTypes";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteE2EEInitialData,
|
||||
type SetupRemoteE2EEResultType,
|
||||
} from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, SetupRemoteE2EEInitialData>;
|
||||
type IdConfigurationChoice = "keep" | "random" | "custom";
|
||||
type IdCustomChoice = "passphrase" | "source" | "recovery";
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let default_encryption: EncryptionSettings = {
|
||||
encrypt: true,
|
||||
passphrase: "",
|
||||
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
|
||||
usePathObfuscation: true,
|
||||
} as EncryptionSettings;
|
||||
encryptInternalMetadata: true,
|
||||
idDerivationVersion: 0,
|
||||
idDerivationKey: "",
|
||||
};
|
||||
|
||||
let encryptionSettings = $state<EncryptionSettings>({ ...default_encryption });
|
||||
let newVault = $state(false);
|
||||
let idConfigurationChoice = $state<IdConfigurationChoice>("keep");
|
||||
let idCustomChoice = $state<IdCustomChoice>("source");
|
||||
let idDerivationSource = $state("");
|
||||
let idDerivationError = $state("");
|
||||
let recoveryCodeVisible = $state(false);
|
||||
let recoveryCodeCopied = $state(false);
|
||||
|
||||
const idDerivationConfigured = $derived(
|
||||
encryptionSettings.idDerivationVersion === ID_DERIVATION_VERSION &&
|
||||
typeof encryptionSettings.idDerivationKey === "string" &&
|
||||
encryptionSettings.idDerivationKey.length > 0
|
||||
);
|
||||
const recoveryCode = $derived.by(() =>
|
||||
idDerivationConfigured ? formatIdRecoveryCode(encryptionSettings.idDerivationKey) : ""
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, encryptionSettings);
|
||||
copyTo(initialData.settings, encryptionSettings);
|
||||
newVault = initialData.newVault;
|
||||
}
|
||||
}
|
||||
idConfigurationChoice = !idDerivationConfigured && newVault ? "random" : "keep";
|
||||
});
|
||||
let e2eeValid = $derived.by(() => {
|
||||
if (!encryptionSettings.encrypt) return true;
|
||||
return encryptionSettings.passphrase.trim().length >= 1;
|
||||
});
|
||||
let canEncryptInternalMetadata = $derived(
|
||||
encryptionSettings.encrypt &&
|
||||
encryptionSettings.E2EEAlgorithm === E2EEAlgorithms.V2 &&
|
||||
encryptionSettings.usePathObfuscation
|
||||
);
|
||||
|
||||
function commit() {
|
||||
setResult(pickEncryptionSettings(encryptionSettings));
|
||||
function resetIdDerivationSource() {
|
||||
idDerivationSource = "";
|
||||
idDerivationError = "";
|
||||
}
|
||||
|
||||
function toggleEncryption(enabled: boolean) {
|
||||
encryptionSettings.encrypt = enabled;
|
||||
if (!enabled) resetIdDerivationSource();
|
||||
}
|
||||
|
||||
function selectIdConfiguration() {
|
||||
recoveryCodeVisible = false;
|
||||
recoveryCodeCopied = false;
|
||||
resetIdDerivationSource();
|
||||
}
|
||||
|
||||
function selectIdCustomSource() {
|
||||
resetIdDerivationSource();
|
||||
}
|
||||
|
||||
async function copyRecoveryCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(recoveryCode);
|
||||
recoveryCodeCopied = true;
|
||||
} catch {
|
||||
idDerivationError = translateMessage("The recovery code could not be copied. Select and copy the visible code instead.");
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
idDerivationError = "";
|
||||
const result = pickEncryptionSettings(encryptionSettings);
|
||||
|
||||
if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") {
|
||||
let source = idDerivationSource;
|
||||
if (idConfigurationChoice === "random") {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
source = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
} else if (idCustomChoice === "passphrase") {
|
||||
source = encryptionSettings.passphrase;
|
||||
}
|
||||
if (source.length === 0) {
|
||||
if (!idDerivationConfigured) {
|
||||
idDerivationError = translateMessage("An ID source is required to enable this option.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
result.idDerivationKey =
|
||||
idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"
|
||||
? await importOrDeriveEnteredIdKey(source, idCustomChoice)
|
||||
: await deriveIdKey(source);
|
||||
result.idDerivationVersion = ID_DERIVATION_VERSION;
|
||||
} catch {
|
||||
idDerivationError = translateMessage("The ID source or recovery code is invalid. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idDerivationSource = "";
|
||||
setResult(result);
|
||||
}
|
||||
|
||||
async function importOrDeriveEnteredIdKey(source: string, choice: IdCustomChoice): Promise<string> {
|
||||
if (choice === "recovery" && !source.trim().startsWith(ID_RECOVERY_CODE_PREFIX)) {
|
||||
throw new Error("An ID recovery code is required.");
|
||||
}
|
||||
return await deriveOrImportIdKey(source);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,7 +158,11 @@
|
||||
<DialogHeader title={translateMessage("End-to-End Encryption")} />
|
||||
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
|
||||
<InputRow label={translateMessage("End-to-End Encryption")}>
|
||||
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={encryptionSettings.encrypt}
|
||||
onchange={(event) => toggleEncryption(event.currentTarget.checked)}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote title={translateMessage("Strongly Recommended")}>
|
||||
{translateMessage(
|
||||
@@ -87,6 +197,182 @@
|
||||
</InfoNote>
|
||||
{/if}
|
||||
|
||||
<fieldset class="sls-id-choices" disabled={!encryptionSettings.encrypt}>
|
||||
<legend>{translateMessage("ID generation")}</legend>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="keep"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<div class="sls-id-choice-text">
|
||||
<span>{translateMessage("Keep current configuration")}</span>
|
||||
<small class="sls-current-id-configuration">
|
||||
{#if idDerivationConfigured}
|
||||
{translateMessage(
|
||||
encryptionSettings.encrypt
|
||||
? "Current configuration: a saved ID key is used."
|
||||
: "Current configuration: the saved ID key is retained while E2EE is off."
|
||||
)}
|
||||
{:else}
|
||||
{translateMessage(
|
||||
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase."
|
||||
)}
|
||||
{/if}
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="random"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<span>{translateMessage("Generate a random ID key")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-derivation-choice"
|
||||
value="custom"
|
||||
bind:group={idConfigurationChoice}
|
||||
onchange={selectIdConfiguration}
|
||||
/>
|
||||
<span>{translateMessage("Set an ID key")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{#if encryptionSettings.encrypt && idConfigurationChoice === "keep" && !idDerivationConfigured}
|
||||
<InfoNote warning>
|
||||
{translateMessage("Changing the E2EE passphrase changes IDs generated by the legacy configuration.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") || idDerivationConfigured}
|
||||
{#if encryptionSettings.encrypt}
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idDerivationConfigured}
|
||||
<InfoNote title={translateMessage("Configured")}>
|
||||
{translateMessage("The saved ID key is configured. Its source cannot be shown again.")}
|
||||
</InfoNote>
|
||||
<button type="button" onclick={() => (recoveryCodeVisible = !recoveryCodeVisible)}>
|
||||
{translateMessage(recoveryCodeVisible ? "Hide current recovery code" : "Show current recovery code")}
|
||||
</button>
|
||||
{#if recoveryCodeVisible}
|
||||
<InputRow label={translateMessage("Current ID recovery code")}>
|
||||
<input type="text" readonly value={recoveryCode} aria-label={translateMessage("Current ID recovery code")} />
|
||||
<button type="button" onclick={copyRecoveryCode}>{translateMessage("Copy recovery code")}</button>
|
||||
</InputRow>
|
||||
{#if recoveryCodeCopied}
|
||||
<InfoNote>{translateMessage("Recovery code copied.")}</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if encryptionSettings.encrypt}
|
||||
{#if idConfigurationChoice === "custom"}
|
||||
<fieldset class="sls-id-choices sls-id-custom-choices">
|
||||
<legend>{translateMessage("How to set the ID key")}</legend>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="passphrase"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Derive from current E2EE passphrase")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="source"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Enter an ID source")}</span>
|
||||
</label>
|
||||
<label class="sls-id-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="id-custom-choice"
|
||||
value="recovery"
|
||||
bind:group={idCustomChoice}
|
||||
onchange={selectIdCustomSource}
|
||||
/>
|
||||
<span>{translateMessage("Import an ID recovery code")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{#if idCustomChoice === "source" || idCustomChoice === "recovery"}
|
||||
<InputRow
|
||||
label={translateMessage(idCustomChoice === "source" ? "ID source" : "ID recovery code")}
|
||||
>
|
||||
<Password
|
||||
name="id-derivation-source"
|
||||
placeholder={translateMessage(
|
||||
idCustomChoice === "source" ? "Enter an ID source" : "Enter an ID recovery code"
|
||||
)}
|
||||
bind:value={idDerivationSource}
|
||||
/>
|
||||
</InputRow>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if idDerivationConfigured && idConfigurationChoice !== "keep"}
|
||||
<InfoNote>
|
||||
{translateMessage("The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.")}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idConfigurationChoice === "custom" && idCustomChoice === "source"}
|
||||
<InfoNote>
|
||||
{translateMessage("Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "custom" && idCustomChoice === "recovery"}
|
||||
<InfoNote>
|
||||
{translateMessage("Paste a tagged recovery code from an existing device to restore the same ID key.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "random"}
|
||||
<InfoNote warning>
|
||||
{translateMessage("For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.")}
|
||||
</InfoNote>
|
||||
{:else if idConfigurationChoice === "custom" && idCustomChoice === "passphrase"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
{#if idDerivationConfigured && idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"}
|
||||
<InfoNote>{translateMessage("Leave this input empty to keep the saved ID key.")}</InfoNote>
|
||||
{/if}
|
||||
{/if}
|
||||
<InfoNote error visible={idDerivationError !== ""}>{idDerivationError}</InfoNote>
|
||||
{/if}
|
||||
|
||||
<InputRow label="Encrypt internal file Properties">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={encryptionSettings.encryptInternalMetadata}
|
||||
disabled={!canEncryptInternalMetadata}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
This option encrypts file properties used by Hidden File Sync and Customisation Sync.
|
||||
<br />
|
||||
It applies only to CouchDB and requires End-to-End Encryption, the V2 algorithm, and Property Encryption
|
||||
(Obfuscate Properties). The remote type is selected later in this setup wizard.
|
||||
<br />
|
||||
It protects properties written after the option is enabled; existing properties are not rewritten. A manual remote
|
||||
Rebuild is strongly recommended to protect existing properties. Update every other synchronising device to a compatible
|
||||
version before enabling this option, including devices currently running LiveSync.
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<InputRow label={translateMessage("Encryption Algorithm")}>
|
||||
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
|
||||
@@ -138,4 +424,41 @@
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
.sls-id-choices {
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.sls-id-choices legend {
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
.sls-id-choices:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sls-id-custom-choices {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
.sls-id-choice {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.sls-id-choice input[type="radio"] {
|
||||
flex: none;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.sls-id-choice-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sls-current-id-configuration {
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-ui-smaller);
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -110,6 +110,10 @@ export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | t
|
||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
|
||||
export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettings;
|
||||
export type SetupRemoteE2EEInitialData = {
|
||||
settings: EncryptionSettings;
|
||||
newVault: boolean;
|
||||
};
|
||||
|
||||
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
|
||||
|
||||
@@ -131,3 +131,50 @@ export function createPaddedCounterLabel(
|
||||
source.offChanged(update);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the disjoint initial and retry chunk-fetch counts with the same
|
||||
* padding and inactive linger behaviour as the other status counters.
|
||||
*/
|
||||
export function createChunkFetchCounterLabel(
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
||||
): DisposableReactiveValue<string> {
|
||||
const initialCount = reactiveSource(0);
|
||||
const retryingCount = reactiveSource(0);
|
||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
||||
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
|
||||
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
|
||||
let updatingCounts = false;
|
||||
let disposed = false;
|
||||
|
||||
const updateLabel = () => {
|
||||
if (updatingCounts || disposed) return;
|
||||
formatted.value = `${initialLabel.value}${retryingLabel.value}`;
|
||||
};
|
||||
initialLabel.onChanged(updateLabel);
|
||||
retryingLabel.onChanged(updateLabel);
|
||||
|
||||
const updateCounts = () => {
|
||||
if (disposed) return;
|
||||
updatingCounts = true;
|
||||
try {
|
||||
initialCount.value = source.value.initial;
|
||||
retryingCount.value = source.value.retrying;
|
||||
} finally {
|
||||
updatingCounts = false;
|
||||
updateLabel();
|
||||
}
|
||||
};
|
||||
source.onChanged(updateCounts);
|
||||
updateCounts();
|
||||
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
source.offChanged(updateCounts);
|
||||
initialLabel.offChanged(updateLabel);
|
||||
retryingLabel.offChanged(updateLabel);
|
||||
initialLabel.dispose();
|
||||
retryingLabel.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
@@ -137,3 +138,41 @@ describe("createPaddedCounterLabel", () => {
|
||||
expect(display.value).toBe(" 📄\u20070");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChunkFetchCounterLabel", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps initial and retry counts separate across an unchanged-total handoff", () => {
|
||||
const counts = reactiveSource({ initial: 2, retrying: 5 });
|
||||
const display = createChunkFetchCounterLabel(counts);
|
||||
const withoutPadding = () => display.value.replace(/\u2007/g, "");
|
||||
const transitionSnapshots: string[] = [];
|
||||
const observeTransitions = () => transitionSnapshots.push(withoutPadding());
|
||||
|
||||
expect(withoutPadding()).toBe(" 🛄2 🔁5");
|
||||
|
||||
display.onChanged(observeTransitions);
|
||||
counts.value = { initial: 0, retrying: 7 };
|
||||
expect(withoutPadding()).toBe(" 🛄0 🔁7");
|
||||
expect(transitionSnapshots).toEqual([" 🛄0 🔁7"]);
|
||||
display.offChanged(observeTransitions);
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
|
||||
expect(withoutPadding()).toBe(" 🛄0 🔁7");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(withoutPadding()).toBe(" 🔁7");
|
||||
|
||||
counts.value = { initial: 0, retrying: 0 };
|
||||
expect(withoutPadding()).toBe(" 🔁0");
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
counts.value = { initial: 1, retrying: 0 };
|
||||
|
||||
expect(withoutPadding()).toBe(" 🔁0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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