Default new CLI and Obsidian environments to NFC path identity

This commit is contained in:
vorotamoroz committed 2026-11-02 04:40:44 +00:00
1 parent ac502b0261
commit 39cc31c2e8
26 files changed
+1466 -61

No files matched your search

+11 -1
View File
@@ -106,9 +106,19 @@ preflight uses the supported host listing and local Metadata. It refuses
ambiguous file or parent-directory candidates before database reconstruction; ambiguous file or parent-directory candidates before database reconstruction;
it does not claim to detect physical aliases which a host does not expose. it does not claim to detect physical aliases which a host does not expose.
## Unreleased extension: defaults for new environments
The later Journal and P2P extension uses NFC for newly generated CLI settings
and Obsidian manual onboarding. It retains legacy defaults for stored settings
and old Setup URIs, and reads an existing central remote's declaration before
first participation. This refines the original default decision without
implicitly changing existing document IDs. Shared Commonlib defaults and other
hosts remain unchanged. See the maintained [design](../design_docs/unicode_path_identity.md#initial-settings-and-existing-environments)
for the initial-setup and failure rules.
## Alternatives considered ## Alternatives considered
- **Use NFC by default:** rejected because existing clients and databases must - **Use NFC by default for all settings:** rejected because existing clients and databases must
retain their current IDs until their owners deliberately migrate them. retain their current IDs until their owners deliberately migrate them.
- **Normalise only lookups:** rejected because new Metadata writes and deletes - **Normalise only lookups:** rejected because new Metadata writes and deletes
could continue using legacy IDs and create duplicate documents. could continue using legacy IDs and create duplicate documents.
+28 -2
View File
@@ -23,7 +23,8 @@ and is included in current Commonlib `0.2.2`, used by Self-hosted LiveSync
release. release.
An unreleased extension adds Journal and P2P for the same maintained CLI and An unreleased extension adds Journal and P2P for the same maintained CLI and
Obsidian hosts, and moves the setting to General Settings. The original dates Obsidian hosts, moves the setting to General Settings, and defaults new
synchronisation environments to NFC. The original dates
and reviewed versions above remain the historical baseline. and reviewed versions above remain the historical baseline.
## Scope ## Scope
@@ -67,6 +68,31 @@ under legacy identity cannot be resumed under version `1`, or vice versa. This
reuses the existing checkpoint identity and does not add a separate recovery reuses the existing checkpoint identity and does not add a separate recovery
store. store.
## Initial settings and existing environments
The unreleased extension uses version `1` for newly generated CLI settings and
Obsidian manual onboarding. Stored settings and old Setup URIs with no path
version retain `0`. Commonlib's shared settings defaults remain `0`, so this
host policy does not extend to WebApp or WebPeer. An absent settings file alone
does not establish that an existing local database is new.
Before saving initial manual CouchDB or Object Storage setup, the host reads
the remote declaration through its owned connection resource. A configured
remote supplies `0` or `1`; only a confirmed empty remote retains the new
settings' selected version. Read failures and unsupported or contradictory
declarations stop setup without saving a guessed value. The CLI makes the same
read before opening the local database for an unconfigured client's first
explicit Fetch. Ordinary configured startup and source Rebuild do not adopt a
remote's mode automatically.
CouchDB inspection reads the version document without preparing encryption or
creating a database. Without a version document, a non-empty database remains
legacy. Journal inspection reads its existing milestone ranges and preferred
Tweaks without registering a node, preparing a Security Seed, or resetting a
checkpoint. P2P Setup URIs carry the selected version. Manual P2P configuration
requires the owner to match the source; the signalling probe does not establish
the remote database's identity, and replication still rejects a mismatch.
## Admission and database compatibility ## Admission and database compatibility
Clients validate local and remote database identity before accepting ordinary Clients validate local and remote database identity before accepting ordinary
@@ -337,7 +363,7 @@ This design does not:
- migrate remote document IDs or revision histories in place; - migrate remote document IDs or revision histories in place;
- choose among ambiguous NFC-equivalent files or parents, or merge their - choose among ambiguous NFC-equivalent files or parents, or merge their
content; content;
- change the default away from legacy path identity; - automatically change existing settings or database identity;
- enable version `1` for Direct file access, WebApp, or an - enable version `1` for Direct file access, WebApp, or an
unverified host adapter; or unverified host adapter; or
- claim native iOS or macOS filesystem compatibility. - claim native iOS or macOS filesystem compatibility.
+1 -1
View File
@@ -51,7 +51,7 @@ Compare the recovered files with the preserved originals, including the affected
## Migrate to Unicode NFC path identity ## Migrate to Unicode NFC path identity
Use this coordinated migration when CLI and Obsidian clients should identify composed and decomposed Unicode spellings as the same path. Version `0` remains the default and preserves legacy identity. CouchDB support is released; Object Storage and P2P support belong to the unreleased extension described in the [Unicode path identity design](design_docs/unicode_path_identity.md). Use this coordinated migration when CLI and Obsidian clients should identify composed and decomposed Unicode spellings as the same path. Existing settings and old Setup URIs retain version `0`, which preserves legacy identity. The unreleased extension defaults new CLI settings and Obsidian manual onboarding to version `1`; this does not migrate an existing database. CouchDB support is released; Object Storage and P2P support belong to the unreleased extension described in the [Unicode path identity design](design_docs/unicode_path_identity.md).
This migration reconstructs the databases from one authoritative Vault. It does not rewrite document IDs in place or rename files. This migration reconstructs the databases from one authoritative Vault. It does not rewrite document IDs in place or rename files.
+1 -1
View File
@@ -242,7 +242,7 @@ Show verbose log. Please enable when you report the logs
Setting key: pathNormalizationVersion Setting key: pathNormalizationVersion
Version `0` preserves legacy file identity and is the default for existing and new settings. Version `1` uses Unicode NFC when deriving document IDs, so equivalent composed and decomposed paths identify the same file across the CLI and Obsidian. Existing physical file names are retained. CouchDB support is released. The unreleased extension also supports Object Storage and P2P for the maintained CLI and Obsidian hosts. This is a common local database setting, independently of the active transport. Version `0` preserves legacy file identity. Existing settings and Setup URIs which omit this field retain version `0`. New CLI settings and Obsidian manual onboarding use version `1` in the unreleased extension. On first manual setup or an unconfigured CLI's initial Fetch, an existing CouchDB or Object Storage declaration takes precedence; an unavailable or unsupported declaration stops setup. P2P Setup URIs carry the selected version; manual P2P setup requires matching settings on every peer. Version `1` uses Unicode NFC when deriving document IDs, so equivalent composed and decomposed paths identify the same file across the CLI and Obsidian. Existing physical file names are retained. CouchDB support is released. The unreleased extension also supports Object Storage and P2P for the maintained CLI and Obsidian hosts. This is a common local database setting, independently of the active transport.
Version `1` requires a coordinated migration: set it on the source client before Rebuild, then set it on each remaining client before Fetch. Changing the setting alone does not migrate existing Metadata. Follow [Migrate to Unicode NFC path identity](recovery.md#migrate-to-unicode-nfc-path-identity) for the complete procedure. Do not resume a version `0` client against the migrated remote. Version `1` requires a coordinated migration: set it on the source client before Rebuild, then set it on each remaining client before Fetch. Changing the setting alone does not migrate existing Metadata. Follow [Migrate to Unicode NFC path identity](recovery.md#migrate-to-unicode-nfc-path-identity) for the complete procedure. Do not resume a version `0` client against the migrated remote.
+3 -3
View File
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-41c8317ec737.tgz", "@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-dd26db91a1f8.tgz",
"@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/obsidian-plugin-kit": "0.1.7",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0", "@vrtmrz/value-store": "0.1.0",
@@ -4066,8 +4066,8 @@
}, },
"node_modules/@vrtmrz/livesync-commonlib": { "node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.2.3", "version": "0.2.3",
"resolved": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-41c8317ec737.tgz", "resolved": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-dd26db91a1f8.tgz",
"integrity": "sha512-EAcvb599dluvaToLUE5lh474TFBux0qZduAVf7FuuiHw3eSme+ZoE64mu7di85fYyI+dvQJN3231ZbOTBTXdYA==", "integrity": "sha512-AZCxGj8RULPLe7wAJCYtfzYb6brKw69qhDhZPVUIldnkbEZlR2kzlSHHjrw1JnXRAONU3ax2r9yYsFGNz3drEg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
+3 -2
View File
@@ -135,7 +135,8 @@
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts", "test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts", "test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts",
"pretest:browser-ui-lifecycle": "svelte-check --tsconfig test/browser-apps/ui-lifecycle/tsconfig.json --fail-on-warnings && vite build --config test/browser-apps/ui-lifecycle/vite.config.ts", "pretest:browser-ui-lifecycle": "svelte-check --tsconfig test/browser-apps/ui-lifecycle/tsconfig.json --fail-on-warnings && vite build --config test/browser-apps/ui-lifecycle/vite.config.ts",
"test:browser-ui-lifecycle": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/ui-lifecycle/browser-ui-lifecycle.test.ts" "test:browser-ui-lifecycle": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/ui-lifecycle/browser-ui-lifecycle.test.ts",
"test:e2e:obsidian:p2p-nfc": "tsx test/e2e-obsidian/scripts/run-compose-p2p-nfc.ts"
}, },
"keywords": [], "keywords": [],
"author": "vorotamoroz", "author": "vorotamoroz",
@@ -209,7 +210,7 @@
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0", "@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-41c8317ec737.tgz", "@vrtmrz/livesync-commonlib": "file:_testdata/nfc/vrtmrz-livesync-commonlib-0.2.3-nfc-dd26db91a1f8.tgz",
"@vrtmrz/obsidian-plugin-kit": "0.1.7", "@vrtmrz/obsidian-plugin-kit": "0.1.7",
"@vrtmrz/ui-interactions": "0.1.2", "@vrtmrz/ui-interactions": "0.1.2",
"@vrtmrz/value-store": "0.1.0", "@vrtmrz/value-store": "0.1.0",
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; import { createNewVaultSettings } from "@/common/newVaultSettings.ts";
export function createDefaultCliSettings(): ObsidianLiveSyncSettings { export function createDefaultCliSettings(): ObsidianLiveSyncSettings {
return { return {
@@ -7,10 +7,14 @@ describe("createDefaultCliSettings", () => {
const settings = createDefaultCliSettings(); const settings = createDefaultCliSettings();
const recommended = createNewVaultSettings(); const recommended = createNewVaultSettings();
expect(settings.pathNormalizationVersion).toBe(1);
expect(settings).toEqual({ expect(settings).toEqual({
...recommended, ...recommended,
pathNormalizationVersion: 1,
useIndexedDBAdapter: false, useIndexedDBAdapter: false,
isConfigured: false, isConfigured: false,
}); });
expect(recommended.pathNormalizationVersion).toBe(0);
expect(settings.remoteConfigurations).not.toBe(recommended.remoteConfigurations);
}); });
}); });
+20
View File
@@ -0,0 +1,20 @@
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "@/common/initialPathIdentity";
/** Adopt an existing central remote only for the first explicit Fetch. */
export async function resolveInitialFetchPathIdentity(
settings: ObsidianLiveSyncSettings,
createProbe: () => Promise<RemoteConnectionProbe | undefined>
): Promise<0 | 1 | undefined> {
if (settings.isConfigured) return undefined;
const hasRemote =
(settings.remoteType === REMOTE_COUCHDB && !!settings.couchDB_URI && !!settings.couchDB_DBNAME) ||
(settings.remoteType === REMOTE_MINIO && !!settings.endpoint && !!settings.bucket);
if (!hasRemote) return undefined;
return await resolveInitialPathIdentity(await createProbe(), settings.pathNormalizationVersion);
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createDefaultCliSettings } from "./cliSettingsDefaults";
import { resolveInitialFetchPathIdentity } from "./initialFetchPathIdentity";
function fixture() {
const settings = {
...createDefaultCliSettings(),
couchDB_URI: "https://db.test",
couchDB_DBNAME: "vault",
endpoint: "https://objects.test",
bucket: "vault",
};
const probe = {
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => ({
status: "configured" as const,
pathNormalizationVersion: 0 as const,
})),
dispose: vi.fn(async () => undefined),
};
return { settings, probe, createProbe: vi.fn(async () => probe) };
}
describe("initial CLI Fetch path identity", () => {
it.each([REMOTE_COUCHDB, REMOTE_MINIO])("adopts a legacy %s remote before the first Fetch", async (remoteType) => {
const { settings, probe, createProbe } = fixture();
settings.remoteType = remoteType;
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBe(0);
expect(settings.pathNormalizationVersion).toBe(1);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("leaves an already configured device unchanged", async () => {
const { settings, createProbe } = fixture();
settings.isConfigured = true;
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBeUndefined();
expect(createProbe).not.toHaveBeenCalled();
});
it.each(["unconfigured", "p2p"])("does not infer identity for %s settings", async (kind) => {
const { settings, createProbe } = fixture();
if (kind === "p2p") settings.remoteType = REMOTE_P2P;
else settings.couchDB_URI = "";
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBeUndefined();
expect(createProbe).not.toHaveBeenCalled();
});
});
+40
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import * as chokidar from "chokidar"; import * as chokidar from "chokidar";
import { path } from "@vrtmrz/livesync-commonlib/node"; import { path } from "@vrtmrz/livesync-commonlib/node";
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { import {
AutoAccepting, AutoAccepting,
@@ -333,6 +334,45 @@ describe("CLI database preparation", () => {
expect(watch).not.toHaveBeenCalled(); expect(watch).not.toHaveBeenCalled();
}); });
it("retains the adopted initial Fetch identity without --write-settings", async () => {
await writeFile(
settingsPath,
JSON.stringify({
...createDefaultCliSettings(),
isConfigured: false,
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
couchDB_USER: "user",
couchDB_PASSWORD: "pass",
})
);
const dispose = vi.fn(async () => undefined);
const inspect = vi.spyOn(ReplicatorService.prototype, "createRemoteResource").mockResolvedValue({
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
dispose,
});
await start(
"fetch",
async (_options, { core }) => {
expect(core.services.setting.currentSettings().pathNormalizationVersion).toBe(0);
return true;
},
0,
true
);
const persisted = JSON.parse(await readFile(settingsPath, "utf-8"));
expect(persisted.pathNormalizationVersion).toBe(0);
expect(dispose).toHaveBeenCalledOnce();
expect(inspect).toHaveBeenCalledOnce();
await start("ls", async (_options, { core }) => {
expect(core.services.setting.currentSettings().pathNormalizationVersion).toBe(0);
return false;
});
expect(inspect).toHaveBeenCalledOnce();
});
it.each(["rebuild", "fetch"] as const)("does not scan or mirror legacy Vault files before %s", async (command) => { it.each(["rebuild", "fetch"] as const)("does not scan or mirror legacy Vault files before %s", async (command) => {
const storedPaths: string[] = []; const storedPaths: string[] = [];
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => { const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
+19
View File
@@ -30,6 +30,8 @@ import type { ReplicationSchedulingControl } from "@/serviceFeatures/replication
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context"; import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
import { writeStderrLine, writeStdoutLine } from "./cliOutput"; import { writeStderrLine, writeStdoutLine } from "./cliOutput";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialFetchPathIdentity } from "./initialFetchPathIdentity";
import { createDefaultCliSettings } from "./cliSettingsDefaults"; import { createDefaultCliSettings } from "./cliSettingsDefaults";
import { import {
applyStoredSetting, applyStoredSetting,
@@ -601,6 +603,22 @@ export async function main(
throw new Error("Replication scheduling was not provided during core feature composition."); throw new Error("Replication scheduling was not provided during core feature composition.");
} }
let initialFetchPathIdentity: 0 | 1 | undefined;
if (options.command === "fetch") {
core.services.appLifecycle.onSettingLoaded.addHandler(async () => {
const settings = core.services.setting.currentSettings();
const version = await resolveInitialFetchPathIdentity(settings, () =>
core.services.replicator.createRemoteResource(REMOTE_RESOURCE_KINDS.CONNECTION, settings)
);
if (version !== undefined) {
initialFetchPathIdentity = version;
await core.services.setting.applyPartial({ pathNormalizationVersion: version });
await core.services.setting.saveSettingData();
}
return true;
});
}
// Setup signal handlers for graceful shutdown // Setup signal handlers for graceful shutdown
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
writeStdoutLine(standardIo); writeStdoutLine(standardIo);
@@ -802,6 +820,7 @@ export async function main(
: (JSON.parse(originalSettingsText) as ObsidianLiveSyncSettings); : (JSON.parse(originalSettingsText) as ObsidianLiveSyncSettings);
durable.additionalSuffixOfDatabaseName = durable.additionalSuffixOfDatabaseName =
core.services.setting.currentSettings().additionalSuffixOfDatabaseName; core.services.setting.currentSettings().additionalSuffixOfDatabaseName;
if (initialFetchPathIdentity !== undefined) durable.pathNormalizationVersion = initialFetchPathIdentity;
settingsTextToCommit = JSON.stringify(durable, null, 2); settingsTextToCommit = JSON.stringify(durable, null, 2);
} }
settingsTextToCommit ??= originalSettingsText; settingsTextToCommit ??= originalSettingsText;
+15
View File
@@ -0,0 +1,15 @@
import type { RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "./ownedRemoteResource";
/** Select initial settings from a read-only declaration before opening the local database. */
export async function resolveInitialPathIdentity(
probe: RemoteConnectionProbe | undefined,
newEnvironmentVersion: number
): Promise<0 | 1> {
if (!probe) throw new Error("The remote path identity cannot be inspected.");
return await withOwnedRemoteResource(probe, async (ownedProbe) => {
if (!ownedProbe.inspectPathIdentity) throw new Error("The remote path identity cannot be inspected.");
const result = await ownedProbe.inspectPathIdentity();
return result.status === "empty" ? (newEnvironmentVersion === 1 ? 1 : 0) : result.pathNormalizationVersion;
});
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { PathIdentityInspection, RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "./initialPathIdentity";
function makeProbe(result: PathIdentityInspection) {
return {
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => result),
dispose: vi.fn(async () => undefined),
} satisfies RemoteConnectionProbe;
}
describe("resolveInitialPathIdentity", () => {
it.each([0, 1] as const)("adopts existing version %s instead of the new-environment default", async (version) => {
const probe = makeProbe({ status: "configured", pathNormalizationVersion: version });
await expect(resolveInitialPathIdentity(probe, 1 - version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it.each([0, 1] as const)("retains version %s only for a confirmed empty remote", async (version) => {
const probe = makeProbe({ status: "empty" });
await expect(resolveInitialPathIdentity(probe, version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("preserves inspection failures and disposes the probe", async () => {
const probe = makeProbe({ status: "empty" });
const error = new Error("remote unavailable");
probe.inspectPathIdentity.mockRejectedValue(error);
await expect(resolveInitialPathIdentity(probe, 1)).rejects.toBe(error);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("refuses an unavailable inspection capability without guessing", async () => {
const probe = makeProbe({ status: "empty" });
const { inspectPathIdentity: _inspect, ...unsupported } = probe;
await expect(resolveInitialPathIdentity(unsupported, 1)).rejects.toThrow("cannot be inspected");
expect(probe.dispose).toHaveBeenCalledOnce();
await expect(resolveInitialPathIdentity(undefined, 1)).rejects.toThrow("cannot be inspected");
});
});
+10
View File
@@ -0,0 +1,10 @@
import { createNewVaultSettings as createCommonlibNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
/** Create settings for a new LiveSync environment. */
export function createNewVaultSettings(): ObsidianLiveSyncSettings {
return {
...createCommonlibNewVaultSettings(),
pathNormalizationVersion: 1,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
NEW_VAULT_SETTINGS,
SETTINGS_SCHEMA_DEFAULTS,
createNewVaultSettings as createCommonlibNewVaultSettings,
} from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "./newVaultSettings.ts";
describe("createNewVaultSettings", () => {
it("selects NFC path identity only for new host settings", () => {
expect(SETTINGS_SCHEMA_DEFAULTS.pathNormalizationVersion).toBe(0);
expect(NEW_VAULT_SETTINGS.pathNormalizationVersion).toBe(0);
expect(createCommonlibNewVaultSettings().pathNormalizationVersion).toBe(0);
expect(createNewVaultSettings().pathNormalizationVersion).toBe(1);
});
it("keeps remote configurations independent of shared and other new-Vault settings", () => {
const first = createNewVaultSettings();
const second = createNewVaultSettings();
const shared = createCommonlibNewVaultSettings();
expect(first.remoteConfigurations).not.toBe(second.remoteConfigurations);
expect(first.remoteConfigurations).not.toBe(shared.remoteConfigurations);
expect(first.pluginSyncExtendedSetting).not.toBe(second.pluginSyncExtendedSetting);
expect(first.pluginSyncExtendedSetting).not.toBe(shared.pluginSyncExtendedSetting);
first.remoteConfigurations.example = {
id: "example",
name: "Example",
uri: "sls+http://localhost/",
isEncrypted: false,
};
expect(second.remoteConfigurations).toEqual({});
expect(shared.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.pluginSyncExtendedSetting).toEqual({});
});
});
+42 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO, VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
objectStorage: [] as Array<{ objectStorage: [] as Array<{
host: unknown; host: unknown;
tryConnectRemote: ReturnType<typeof vi.fn>; tryConnectRemote: ReturnType<typeof vi.fn>;
inspectRemotePathIdentity: ReturnType<typeof vi.fn>;
getRemoteStatus: ReturnType<typeof vi.fn>; getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>; getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>; getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
@@ -64,6 +65,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
LiveSyncJournalReplicator: class { LiveSyncJournalReplicator: class {
host: unknown; host: unknown;
tryConnectRemote = vi.fn(); tryConnectRemote = vi.fn();
inspectRemotePathIdentity = vi.fn();
getRemoteStatus = vi.fn(); getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn(); getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn(); getReplicationPBKDF2Salt = vi.fn();
@@ -135,6 +137,45 @@ describe("replicator probe factories", () => {
expect(databaseClose).not.toHaveBeenCalled(); expect(databaseClose).not.toHaveBeenCalled();
}); });
it("inspects CouchDB identity without encryption setup or remote writes and closes on failure", async () => {
const source = { ...createSettings(), encrypt: true };
const probe = await createCouchDBConnectionProbeFactory({} as never)(source);
const replicator = mocks.couchDB[0];
const get = vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 13, used_features: ["nfc-path-identity-v1"] }));
const close = vi.fn(async () => undefined);
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: { get }, close });
await expect(probe.inspectPathIdentity!()).resolves.toEqual({
status: "configured",
pathNormalizationVersion: 1,
});
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(
{ ...source, encrypt: false },
false,
false,
false
);
expect(close).toHaveBeenCalledOnce();
get.mockRejectedValueOnce(new Error("read unavailable"));
await expect(probe.inspectPathIdentity!()).rejects.toThrow("read unavailable");
expect(close).toHaveBeenCalledTimes(2);
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
});
it("delegates Journal identity inspection to the owned settings snapshot", async () => {
const source = createSettings();
const snapshot = { ...source };
const probe = await createObjectStorageConnectionProbeFactory({} as never)(source);
const replicator = mocks.objectStorage[0];
replicator.inspectRemotePathIdentity.mockResolvedValue({ status: "empty" });
source.bucket = "changed";
await expect(probe.inspectPathIdentity!()).resolves.toEqual({ status: "empty" });
expect(replicator.inspectRemotePathIdentity).toHaveBeenCalledWith(snapshot);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
await probe.dispose();
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => { it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => {
const source = createSettings(); const source = createSettings();
const snapshot = { ...source }; const snapshot = { ...source };
@@ -8,6 +8,7 @@ import {
LiveSyncCouchDBReplicator, LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv, type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { inspectDatabasePathIdentity } from "@vrtmrz/livesync-commonlib/replication";
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared"; import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
@@ -51,6 +52,20 @@ function createCouchDBConnectionProbe(
await connection.close(); await connection.close();
} }
}, },
inspectPathIdentity: async () => {
const connection = await replicator.connectRemoteCouchDBWithSetting(
{ ...snapshot, encrypt: false },
replicator.isMobile(),
false,
false
);
if (typeof connection === "string") throw new Error(connection);
try {
return await inspectDatabasePathIdentity(connection.db);
} finally {
await connection.close();
}
},
getStatus: () => replicator.getRemoteStatus(snapshot), getStatus: () => replicator.getRemoteStatus(snapshot),
dispose, dispose,
}; };
@@ -70,6 +85,7 @@ function createObjectStorageConnectionProbe(
return { ok: false, reason: error }; return { ok: false, reason: error };
} }
}, },
inspectPathIdentity: () => replicator.inspectRemotePathIdentity(snapshot),
getStatus: () => replicator.getRemoteStatus(snapshot), getStatus: () => replicator.getRemoteStatus(snapshot),
dispose, dispose,
}; };
+39 -12
View File
@@ -7,7 +7,7 @@ import {
REMOTE_COUCHDB, REMOTE_COUCHDB,
REMOTE_MINIO, REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; import { createNewVaultSettings } from "@/common/newVaultSettings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import Intro from "./SetupWizard/dialogs/Intro.svelte"; import Intro from "./SetupWizard/dialogs/Intro.svelte";
@@ -49,6 +49,8 @@ import {
type SetupInitialisationMode, type SetupInitialisationMode,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts"; } from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts"; import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "@/common/initialPathIdentity";
import type { P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p"; import type { P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p";
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings { function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
@@ -58,11 +60,14 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
}; };
} }
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings { function normaliseImportedIdentitySettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
// Setup URIs are complete imports even when their encoder omitted default-valued fields. // 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. // Fill each missing half so a receiving device cannot supply the unrelated saved key.
return { return {
...settings, ...settings,
pathNormalizationVersion: Object.prototype.hasOwnProperty.call(settings, "pathNormalizationVersion")
? settings.pathNormalizationVersion
: 0,
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion") idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
? settings.idDerivationVersion ? settings.idDerivationVersion
: 0, : 0,
@@ -233,7 +238,7 @@ export class SetupManager extends AbstractModule {
return false; return false;
} }
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE); this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode); return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdentitySettings(newSetting), userMode);
} }
/** /**
@@ -271,6 +276,7 @@ export class SetupManager extends AbstractModule {
if (activate) { if (activate) {
newSetting.remoteType = REMOTE_COUCHDB; newSetting.remoteType = REMOTE_COUCHDB;
} }
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate }); upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate); return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
} }
@@ -302,10 +308,37 @@ export class SetupManager extends AbstractModule {
if (activate) { if (activate) {
newSetting.remoteType = REMOTE_MINIO; newSetting.remoteType = REMOTE_MINIO;
} }
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate }); upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate); return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
} }
private async selectInitialPathIdentity(
settings: ObsidianLiveSyncSettings,
userMode: UserMode,
activate: boolean
): Promise<boolean> {
if (!activate || (userMode !== UserMode.NewUser && userMode !== UserMode.ExistingUser)) return true;
try {
const probe = await this.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.CONNECTION,
settings
);
settings.pathNormalizationVersion = await resolveInitialPathIdentity(
probe,
settings.pathNormalizationVersion
);
return true;
} catch (error) {
this._log(
"The remote path identity could not be inspected. Settings have not been applied.",
LOG_LEVEL_NOTICE
);
this._log(error, LOG_LEVEL_VERBOSE);
return false;
}
}
/** /**
* Handles manual setup for P2P * Handles manual setup for P2P
* @param userMode * @param userMode
@@ -345,10 +378,7 @@ export class SetupManager extends AbstractModule {
const e2eeConf = await this.dialogManager.openWithExplicitCancel< const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType, SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData SetupRemoteE2EEInitialData
>( >(SetupRemoteE2EE, { settings: currentSetting, newVault: userMode === UserMode.NewUser });
SetupRemoteE2EE,
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
);
if (e2eeConf === "cancelled") { if (e2eeConf === "cancelled") {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE); this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
return false; return false;
@@ -397,10 +427,7 @@ export class SetupManager extends AbstractModule {
const e2eeConf = await this.dialogManager.openWithExplicitCancel< const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType, SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData SetupRemoteE2EEInitialData
>( >(SetupRemoteE2EE, { settings: originalSetting, newVault: userMode === UserMode.NewUser });
SetupRemoteE2EE,
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
);
if (e2eeConf === "cancelled") { if (e2eeConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE); this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode); return await this.onOnboard(userMode);
@@ -545,7 +572,7 @@ export class SetupManager extends AbstractModule {
async decodeQR(qr: string) { async decodeQR(qr: string) {
let newSettings: ObsidianLiveSyncSettings; let newSettings: ObsidianLiveSyncSettings;
try { try {
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr)); newSettings = normaliseImportedIdentitySettings(decodeSettingsFromQRCodeData(qr));
} catch { } catch {
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE); this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
return false; return false;
+143 -37
View File
@@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
REMOTE_COUCHDB, REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P, REMOTE_P2P,
type ObsidianLiveSyncSettings, type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService"; import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; import { createNewVaultSettings } from "@/common/newVaultSettings";
import type { import type {
P2PConnectionProbeAdmission, P2PConnectionProbeAdmission,
P2PConnectionProbeAdmissionResult, P2PConnectionProbeAdmissionResult,
@@ -98,6 +99,12 @@ function createSetupManager() {
dialogManager, dialogManager,
}, },
setting, setting,
replicator: {
createRemoteResource: vi.fn(async () => ({
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
dispose: vi.fn(async () => undefined),
})),
},
} as any; } as any;
const core: any = { const core: any = {
_services: services, _services: services,
@@ -165,6 +172,99 @@ describe("SetupManager", () => {
); );
}); });
it.each([REMOTE_COUCHDB, REMOTE_MINIO])(
"uses an existing remote declaration during first manual setup for %s",
async (remoteType) => {
const { manager, core, dialogManager } = createSetupManager();
const dispose = vi.fn(async () => undefined);
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
dispose,
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
couchDB_USER: "user",
couchDB_PASSWORD: "pass",
endpoint: "https://objects.test",
bucket: "vault",
accessKey: "key",
secretKey: "secret",
region: "auto",
bucketPrefix: "",
});
const initial = { ...createLegacyRemoteSetting(), pathNormalizationVersion: 1 } as ObsidianLiveSyncSettings;
if (remoteType === REMOTE_COUCHDB) await manager.onCouchDBManualSetup(UserMode.NewUser, initial);
else await manager.onBucketManualSetup(UserMode.NewUser, initial);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ pathNormalizationVersion: 0 }),
UserMode.NewUser,
true
);
expect(dispose).toHaveBeenCalledOnce();
expect(initial.pathNormalizationVersion).toBe(1);
}
);
it.each([
[{ status: "empty" }, 1],
[{ status: "configured", pathNormalizationVersion: 1 }, 1],
] as const)("uses inspected setup identity %s", async (inspection, expected) => {
const { manager, core, dialogManager } = createSetupManager();
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => inspection),
dispose: vi.fn(async () => undefined),
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings());
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ pathNormalizationVersion: expected }),
UserMode.NewUser,
true
);
});
it("does not apply setup settings when identity inspection fails", async () => {
const { manager, core, dialogManager, setting } = createSetupManager();
const failure = new Error("remote unavailable");
const dispose = vi.fn(async () => undefined);
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => {
throw failure;
}),
dispose,
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await expect(manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings())).resolves.toBe(false);
expect(confirm).not.toHaveBeenCalled();
expect(setting.saveSettingData).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledOnce();
});
it.each([
[UserMode.Update, true],
[UserMode.NewUser, false],
] as const)("keeps identity during profile edits in mode %s with activation %s", async (mode, activate) => {
const { manager, core, dialogManager } = createSetupManager();
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await manager.onCouchDBManualSetup(mode, createLegacyRemoteSetting(), activate);
expect(core.services.replicator.createRemoteResource).not.toHaveBeenCalled();
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ pathNormalizationVersion: 0 }), mode, activate);
});
it("starts manual new-user setup from the recommended new-Vault settings", async () => { it("starts manual new-user setup from the recommended new-Vault settings", async () => {
const { manager, dialogManager } = createSetupManager(); const { manager, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually"); dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
@@ -192,12 +292,13 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb"); expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
}); });
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => { it("compatibility: treats omitted identity fields in a Setup URI as legacy defaults", async () => {
const { manager, setting, dialogManager } = createSetupManager(); const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "12".repeat(32); const savedKey = "12".repeat(32);
setting.settings = { setting.settings = {
...createLegacyRemoteSetting(), ...createLegacyRemoteSetting(),
isConfigured: true, isConfigured: true,
pathNormalizationVersion: 1,
idDerivationVersion: 1, idDerivationVersion: 1,
idDerivationKey: savedKey, idDerivationKey: savedKey,
}; };
@@ -205,6 +306,7 @@ describe("SetupManager", () => {
...createLegacyRemoteSetting(), ...createLegacyRemoteSetting(),
isConfigured: true, isConfigured: true,
} as Partial<ObsidianLiveSyncSettings>; } as Partial<ObsidianLiveSyncSettings>;
delete imported.pathNormalizationVersion;
delete imported.idDerivationVersion; delete imported.idDerivationVersion;
delete imported.idDerivationKey; delete imported.idDerivationKey;
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings)); vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
@@ -213,6 +315,7 @@ describe("SetupManager", () => {
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings"); await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0]; const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.pathNormalizationVersion).toBe(0);
expect(mergedSettings.idDerivationVersion).toBe(0); expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe(""); expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey); expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
@@ -489,44 +592,47 @@ describe("SetupManager", () => {
it.each([ it.each([
{ importedSize: 0, expectedSize: 0 }, { importedSize: 0, expectedSize: 0 },
{ importedSize: undefined, expectedSize: 60 }, { importedSize: undefined, expectedSize: 60 },
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => { ])(
const { manager, setting, dialogManager } = createSetupManager(); "preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize",
setting.settings = { async ({ importedSize, expectedSize }) => {
...setting.currentSettings(), const { manager, setting, dialogManager } = createSetupManager();
customChunkSize: 60, setting.settings = {
}; ...setting.currentSettings(),
const imported = { customChunkSize: 60,
...DEFAULT_SETTINGS, };
remoteConfigurations: { const imported = {
couch: { ...DEFAULT_SETTINGS,
id: "couch", remoteConfigurations: {
name: "Office CouchDB", couch: {
uri: "sls+https://alice:secret@couch.example/?db=notes", id: "couch",
isEncrypted: false, name: "Office CouchDB",
uri: "sls+https://alice:secret@couch.example/?db=notes",
isEncrypted: false,
},
archive: {
id: "archive",
name: "Archive bucket",
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive&region=auto",
isEncrypted: false,
},
}, },
archive: { activeConfigurationId: "archive",
id: "archive", } as ObsidianLiveSyncSettings;
name: "Archive bucket", if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive&region=auto", else imported.customChunkSize = importedSize;
isEncrypted: false, dialogManager.openWithExplicitCancel
}, .mockResolvedValueOnce(imported)
}, .mockResolvedValueOnce("compatible-existing-user");
activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings;
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
else imported.customChunkSize = importedSize;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings"); await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
const current = setting.currentSettings(); const current = setting.currentSettings();
expect(current.customChunkSize).toBe(expectedSize); expect(current.customChunkSize).toBe(expectedSize);
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations); expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive"); expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false); expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
}); }
);
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => { it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager(); const { manager, setting, dialogManager } = createSetupManager();
+13
View File
@@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1
FROM node:26-slim AS node26
FROM mcr.microsoft.com/playwright:v1.62.0-noble
COPY --from=node26 /usr/local/bin/node /usr/local/bin/node
ENV NODE_OPTIONS=--max-old-space-size=3072
WORKDIR /workspace
ENTRYPOINT ["node", "--import", "tsx", "test/e2e-obsidian/scripts/p2p-nfc-sync.ts"]
+6
View File
@@ -244,6 +244,8 @@ The P2P Setup URI workflow also schedules another Fetch and injects an exception
`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay. `test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay.
`test:e2e:obsidian:p2p-nfc` runs the plain case and the E2EE case using Path Obfuscation and an independent ID key, serially, in an isolated Compose project with a local signalling relay and two real Obsidian processes at a time. Each case seeds an NFD-named source file, fetches it by its NFC spelling, and compares the real local Metadata ID, exact body, and referenced Chunks on both peers. It edits the note from the receiving peer, verifies that the Metadata ID remains stable and that the original revision body remains readable, then fully stops and relaunches the same Vault to check that the ID and updated body remain. A fresh path version 0 peer must show the visible P2P compatibility refusal; the test verifies that it receives no source Metadata and retains its local sentinel. Run `npm run test:e2e:obsidian:p2p-nfc -- --case plain` or `-- --case encrypted` to select one case for debugging. The host wrapper builds the plug-in, uses the existing extracted Obsidian directory and its `obsidian-cli`, mounts the repository read-only, and tears down its unique Compose project after the run. It does not download Obsidian or npm dependencies.
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan. `test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
`test:e2e:obsidian:partial-startup-file-failure` is a focused Linux release-acceptance scenario for an ordinary configured restart. It stores one valid database-only note and one database-only note whose path component is 258 UTF-8 bytes, then restarts the same isolated Vault and profile. On a Linux test Vault which enforces the conventional 255-byte component limit, the scenario requires the valid file to be reflected, the application to become ready, the partial-failure Notice to appear, and the failed path to remain readable and eligible for a later scan with its exact path in the verbose log. It remains outside `local-suite` because the failure fixture is deliberately platform-specific. `test:e2e:obsidian:partial-startup-file-failure` is a focused Linux release-acceptance scenario for an ordinary configured restart. It stores one valid database-only note and one database-only note whose path component is 258 UTF-8 bytes, then restarts the same isolated Vault and profile. On a Linux test Vault which enforces the conventional 255-byte component limit, the scenario requires the valid file to be reflected, the application to become ready, the partial-failure Notice to appear, and the failed path to remain readable and eligible for a later scan with its exact path in the verbose log. It remains outside `local-suite` because the failure fixture is deliberately platform-specific.
@@ -404,6 +406,10 @@ When these checks pass for the release artefacts, BRAT acceptance can be limited
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds. - `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds. - `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds. - `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
- `E2E_OBSIDIAN_P2P_NFC_TIMEOUT_MS`: timeout for P2P NFC transfers and the incompatible-peer refusal; default is 120 seconds.
- `E2E_OBSIDIAN_P2P_NFC_UI_TIMEOUT_MS`: timeout for P2P NFC status and setup controls; default is 60 seconds.
- `E2E_P2P_NFC_CASE`: case selected by the Compose wrapper (`all`, `plain`, or `encrypted`); default is `all`. The `--case` option overrides this value.
- `E2E_P2P_NFC_OBSIDIAN_DIR`: extracted Linux Obsidian directory mounted into the Compose runner. The wrapper normally infers it from the discovered `OBSIDIAN_BINARY` and requires the corresponding `obsidian-cli` in the same directory.
- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds. - `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds.
- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. - `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`.
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`. - `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
+36
View File
@@ -0,0 +1,36 @@
name: livesync-p2p-nfc
services:
p2p-relay:
image: ghcr.io/hoytech/strfry:latest
entrypoint: ["/app/strfry"]
command: ["--config", "/etc/strfry/strfry.conf", "relay"]
volumes:
- ../../test/fixtures/p2p-relay/strfry.conf:/etc/strfry/strfry.conf:ro
tmpfs:
- /app/strfry-db:rw,size=256m,mode=1777
p2p-nfc:
build:
context: ../..
dockerfile: test/e2e-obsidian/Dockerfile.p2p
depends_on:
p2p-relay:
condition: service_started
environment:
OBSIDIAN_BINARY: ${E2E_P2P_NFC_OBSIDIAN_BINARY:?Run this scenario through run-compose-p2p-nfc.ts}
OBSIDIAN_CLI: ${E2E_P2P_NFC_OBSIDIAN_CLI:?Run this scenario through run-compose-p2p-nfc.ts}
E2E_P2P_RELAY_URL: ws://p2p-relay:7777/
E2E_P2P_NFC_CASE: ${E2E_P2P_NFC_CASE:-all}
E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: "9222"
E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT: "9223"
E2E_OBSIDIAN_DIAGNOSTICS_DIR: /artifacts
volumes:
- ../..:/workspace:ro
- ${E2E_P2P_NFC_OBSIDIAN_DIR:?Run this scenario through run-compose-p2p-nfc.ts}:/opt/obsidian:ro
- ${E2E_P2P_NFC_ARTIFACT_DIR:?Run this scenario through run-compose-p2p-nfc.ts}:/artifacts
working_dir: /workspace
mem_limit: 6g
cpus: 2
init: true
ipc: host
+787
View File
@@ -0,0 +1,787 @@
import { randomBytes } from "node:crypto";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { connect } from "node:net";
import { dirname, join } from "node:path";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { createNewVaultSettings, P2P_DEFAULT_SETTINGS, PREFERRED_BASE } from "@vrtmrz/livesync-commonlib/settings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
modalByTitle,
resumeCompatibilityReviewIfShown,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
import {
assertEqual,
waitForLocalDatabaseEntry,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_NFC_UI_TIMEOUT_MS ?? 60000);
const syncTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_NFC_TIMEOUT_MS ?? 120000);
const pathNfd = "E2E/p2p-nfc/Cafe\u0301/re\u0301sume\u0301.md";
const pathNfc = pathNfd.normalize("NFC");
const sourceContent = "# NFC identity\n\nThis NFD-named source note keeps one Metadata identity across P2P.\n";
const editedContent = "# NFC identity\n\nThis update came from the NFC spelling on the receiving peer.\n";
const restartedContent = "# NFC identity\n\nThis edit was sent after the receiving Obsidian process restarted.\n";
const sentinelPath = "E2E/p2p-nfc/local-sentinel.md";
const sentinelContent = "# Local sentinel\n\nKeep this local file when a mode 0 peer is refused.\n";
type CaseDefinition = { name: "plain" | "encrypted"; encrypted: boolean };
type EntryState = LocalDatabaseEntry & { content: string; chunksPresent: boolean };
type VaultFileState = { found: boolean; path: string | null; content: string | null };
type RunnerContext = {
binary: string;
cliBinary: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
type SharedSettings = {
encrypted: boolean;
e2eePassphrase: string;
idKey: string;
p2pPassphrase: string;
relay: string;
roomId: string;
};
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error("Invalid secondary Obsidian remote debugging port: " + second);
}
return [first, second];
}
function selectedCases(arguments_: readonly string[]): CaseDefinition[] {
let requested = process.env.E2E_P2P_NFC_CASE?.trim() || "all";
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--help" || argument === "-h") {
console.log("Usage: p2p-nfc-sync.ts [--case all|plain|encrypted]");
process.exit(0);
}
if (argument === "--case") {
const value = arguments_[index + 1];
if (!value) throw new Error("--case requires all, plain, or encrypted.");
requested = value;
index += 1;
continue;
}
if (argument.startsWith("--case=")) {
requested = argument.slice("--case=".length);
continue;
}
throw new Error("Unknown P2P NFC scenario argument: " + argument);
}
const cases: CaseDefinition[] = [
{ name: "plain", encrypted: false },
{ name: "encrypted", encrypted: true },
];
if (requested === "all") return cases;
const selected = cases.filter((candidate) => candidate.name === requested);
if (selected.length === 0) throw new Error("Unsupported P2P NFC case: " + requested);
return selected;
}
function createSettings(shared: SharedSettings, pathNormalizationVersion: 0 | 1): ObsidianLiveSyncSettings {
const settings = createNewVaultSettings();
Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, {
isConfigured: true,
liveSync: false,
syncOnStart: false,
syncOnSave: false,
useEden: false,
customChunkSize: 60,
readChunksOnline: true,
E2EEAlgorithm: "v2",
encrypt: shared.encrypted,
passphrase: shared.encrypted ? shared.e2eePassphrase : "",
usePathObfuscation: shared.encrypted,
idDerivationVersion: shared.encrypted ? 1 : 0,
idDerivationKey: shared.encrypted ? shared.idKey : "",
pathNormalizationVersion,
P2P_Enabled: true,
P2P_AutoStart: false,
P2P_AutoBroadcast: false,
P2P_roomID: shared.roomId,
P2P_passphrase: shared.p2pPassphrase,
P2P_relays: shared.relay,
P2P_AppID: "self-hosted-livesync-real-obsidian-p2p-nfc-e2e",
});
upsertRemoteConfigurationInPlace(settings, "p2p", {
id: "e2e-p2p-nfc",
name: "P2P NFC E2E",
activate: true,
activateForP2P: true,
});
return settings;
}
async function createSetupArtifact(shared: SharedSettings, pathNormalizationVersion: 0 | 1): Promise<SetupArtifact> {
const setupPassphrase = randomBytes(24).toString("base64url");
const result = await encodeTimeBoundSetupURI(createSettings(shared, pathNormalizationVersion), setupPassphrase, {
mode: "persistent",
removeProperties: ["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks"],
skipDefaultValue: true,
});
const setupURI = result.uri.trim();
if (!setupURI.startsWith("obsidian://setuplivesync?settings=")) {
throw new Error("Commonlib did not create a valid P2P Setup URI.");
}
return { setupURI, setupPassphrase };
}
async function waitForRelay(relay: string): Promise<void> {
const endpoint = new URL(relay);
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
throw new Error("P2P relay must use ws: or wss:, received " + endpoint.protocol);
}
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
const host = endpoint.hostname;
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
let lastError: unknown;
while (Date.now() < deadline) {
try {
await new Promise<void>((resolve, reject) => {
const socket = connect({ host, port });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error("connection timed out"));
});
socket.once("error", reject);
});
await new Promise((resolve) => setTimeout(resolve, 1500));
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(
"P2P relay is not ready at " +
relay +
": " +
(lastError instanceof Error ? lastError.message : String(lastError))
);
}
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
return session;
}
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession | undefined): Promise<void> {
if (!session) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) await stopSession(context, session);
}
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
const target = join(vaultPath, path);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, content, "utf8");
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
async function assertSourceNameIsNfd(vaultPath: string, expectedContent: string): Promise<void> {
const nfdFile = join(vaultPath, pathNfd);
const nfcFile = join(vaultPath, pathNfc);
assertEqual(await readFile(nfdFile, "utf8"), expectedContent, "The source NFD file has unexpected content.");
if (pathNfd !== pathNfc && (await pathExists(nfcFile))) {
throw new Error("NFC reflection created a second physical source file beside the NFD file.");
}
}
async function readVaultFileState(vaultPath: string, requestedPath: string): Promise<VaultFileState> {
const candidates = [...new Set([requestedPath, requestedPath.normalize("NFD")])];
for (const candidate of candidates) {
try {
const content = await readFile(join(vaultPath, candidate), "utf8");
return { found: true, path: candidate, content };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
return { found: false, path: null, content: null };
}
async function waitForVaultContent(
vaultPath: string,
requestedPath: string,
expectedContent: string
): Promise<VaultFileState> {
const deadline = Date.now() + syncTimeoutMs;
let lastState: VaultFileState = { found: false, path: null, content: null };
while (Date.now() < deadline) {
lastState = await readVaultFileState(vaultPath, requestedPath);
if (lastState.found && lastState.content === expectedContent) return lastState;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
"Timed out waiting for NFC path content: " + JSON.stringify({ requestedPath, expectedContent, lastState })
);
}
async function modifyVaultNote(
cliBinary: string,
environment: NodeJS.ProcessEnv,
requestedPath: string,
content: string
): Promise<string> {
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
"const requestedPath=" + JSON.stringify(requestedPath) + ";",
"const content=" + JSON.stringify(content) + ";",
"const file=app.vault.getFiles().find(candidate=>candidate.path.normalize('NFC')===requestedPath);",
"if(!file) throw new Error('Could not find the NFC-equivalent file: '+requestedPath);",
"await app.vault.modify(file,content);",
"return JSON.stringify(file.path);",
"})()",
].join(""),
environment
);
}
async function readEntryState(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string,
revision?: string
): Promise<EntryState> {
return await evalObsidianJson<EntryState>(
cliBinary,
[
"(async()=>{",
"const path=" + JSON.stringify(path) + ";",
"const revision=" + JSON.stringify(revision ?? null) + ";",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const entry=await core.localDatabase.getDBEntry(path,revision?{rev:revision}:undefined,false,true,true);",
"if(!entry||!entry._id||!entry._rev) throw new Error('Metadata was not available at '+path);",
"const children=Array.isArray(entry.children)?entry.children:[];",
"const chunkRows=children.length?await core.localDatabase.allDocsRaw({keys:children,include_docs:true}):{rows:[]};",
"const chunksPresent=children.length>0&&chunkRows.rows.filter(row=>Boolean(row.doc)&&!Boolean(row.value?.deleted)&&!Boolean(row.doc?.deleted)&&!Boolean(row.doc?._deleted)).length===children.length;",
"const content=Array.isArray(entry.data)?entry.data.join(''):typeof entry.data==='string'?entry.data:'';",
"return JSON.stringify({id:entry._id,rev:entry._rev,path:entry.path,children,content,chunksPresent});",
"})()",
].join(""),
environment
);
}
async function writeEntryStateWhenReady(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string,
expectedContent: string
): Promise<EntryState> {
await waitForLocalDatabaseEntry(cliBinary, environment, path, { timeoutMs: syncTimeoutMs });
const deadline = Date.now() + syncTimeoutMs;
let state: EntryState;
do {
state = await readEntryState(cliBinary, environment, path);
if (state.content === expectedContent && state.chunksPresent) return state;
await new Promise((resolve) => setTimeout(resolve, 250));
} while (Date.now() < deadline);
assertEqual(state.content, expectedContent, "The local Metadata body differs from the expected note bytes.");
throw new Error("The Metadata entry is missing one or more referenced Chunk documents.");
}
async function readCurrentSettings(
cliBinary: string,
environment: NodeJS.ProcessEnv,
expectedIdKey: string
): Promise<{
pathNormalizationVersion: number;
encrypt: boolean;
usePathObfuscation: boolean;
idDerivationVersion: number;
idKeyMatches: boolean;
p2pEnabled: boolean;
}> {
return await evalObsidianJson<{
pathNormalizationVersion: number;
encrypt: boolean;
usePathObfuscation: boolean;
idDerivationVersion: number;
idKeyMatches: boolean;
p2pEnabled: boolean;
}>(
cliBinary,
[
"(async()=>{",
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
"const expectedIdKey=" + JSON.stringify(expectedIdKey) + ";",
"return JSON.stringify({pathNormalizationVersion:settings.pathNormalizationVersion??0,encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,idDerivationVersion:settings.idDerivationVersion??0,idKeyMatches:settings.idDerivationKey===expectedIdKey,p2pEnabled:settings.P2P_Enabled});",
"})()",
].join(""),
environment
);
}
async function assertCaseSettings(
cliBinary: string,
environment: NodeJS.ProcessEnv,
definition: CaseDefinition,
expectedIdKey: string,
pathNormalizationVersion: 0 | 1
): Promise<void> {
const settings = await readCurrentSettings(cliBinary, environment, expectedIdKey);
assertEqual(
settings.pathNormalizationVersion,
pathNormalizationVersion,
"The P2P Setup URI lost path identity mode."
);
assertEqual(settings.encrypt, definition.encrypted, "The P2P Setup URI changed the E2EE setting.");
assertEqual(settings.usePathObfuscation, definition.encrypted, "The P2P Setup URI changed Path Obfuscation.");
assertEqual(settings.idDerivationVersion, definition.encrypted ? 1 : 0, "The ID derivation version differs.");
assertEqual(settings.idKeyMatches, true, "The independent ID key did not reach this device.");
assertEqual(settings.p2pEnabled, true, "The P2P Setup URI did not enable P2P.");
}
async function executeCommand(port: number, commandId: string): Promise<void> {
const executed = await withObsidianPage(
port,
async (page) =>
await page.evaluate(
(id) =>
(
globalThis as typeof globalThis & {
app?: { commands?: { executeCommandById(commandId: string): boolean } };
}
).app?.commands?.executeCommandById(id) === true,
commandId
)
);
if (!executed) throw new Error("Obsidian command was not available: " + commandId);
}
async function openP2PStatus(port: number): Promise<void> {
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
const open = pane.getByRole("button", { name: "Open connection", exact: true });
if (await open.isVisible()) await open.click({ timeout: uiTimeoutMs });
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function reconnectP2PStatus(port: number): Promise<void> {
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
const disconnect = pane.getByRole("button", { name: "Disconnect", exact: true });
if (await disconnect.isVisible()) await disconnect.click({ timeout: uiTimeoutMs });
const open = pane.getByRole("button", { name: "Open connection", exact: true });
await open.waitFor({ state: "visible", timeout: uiTimeoutMs });
await open.click({ timeout: uiTimeoutMs });
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function waitForDetectedPeer(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
}
async function replicateFromStatusPane(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
await pane.getByRole("button", { name: "Replicate now" }).first().click({ timeout: uiTimeoutMs });
});
}
async function acceptConnectionRequests(ports: readonly number[], shouldStop: () => boolean): Promise<void> {
while (!shouldStop()) {
for (const port of ports) {
const visible = await withObsidianPage(
port,
async (page) => await modalByTitle(page, "P2P Connection Request").isVisible()
).catch(() => false);
if (!visible) continue;
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "P2P Connection Request")
.getByRole("button", { name: "Accept", exact: true })
.click({ timeout: uiTimeoutMs });
});
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
async function fetchFromFirstPeer(portA: number, portB: number): Promise<void> {
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs });
});
let finished = false;
const acceptor = acceptConnectionRequests([portA, portB], () => finished);
try {
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
await modal
.locator(".peer-item")
.first()
.getByRole("button", { name: "Sync", exact: true })
.click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
} finally {
finished = true;
await acceptor;
}
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
if (await modal.isVisible()) {
await modal.getByRole("button", { name: "Skip and close" }).click({ timeout: uiTimeoutMs });
}
});
}
async function replicateAndWait(
sourcePort: number,
receiverPort: number,
receiverVaultPath: string,
expectedPath: string,
expectedContent: string
): Promise<void> {
let finished = false;
const acceptor = acceptConnectionRequests([sourcePort, receiverPort], () => finished);
try {
await replicateFromStatusPane(sourcePort);
await waitForVaultContent(receiverVaultPath, expectedPath, expectedContent);
} finally {
finished = true;
await acceptor;
}
}
async function waitForTweakMismatchNotice(port: number, session: ObsidianLiveSyncSession): Promise<string> {
const deadline = Date.now() + syncTimeoutMs;
let lastNotices: string[] = [];
while (Date.now() < deadline) {
lastNotices = await withObsidianPage(
port,
async (page) => await page.locator(".notice").allTextContents()
).catch(() => lastNotices);
const mismatch = lastNotices.find((notice) =>
notice.includes("The path identity mode does not match this peer")
);
if (mismatch) return mismatch;
await new Promise((resolve) => setTimeout(resolve, 150));
}
const output = session.app.output();
throw new Error(
"The incompatible P2P attempt did not show its tweak refusal. Notices: " +
JSON.stringify(lastNotices) +
"\nObsidian stdout: " +
output.stdout.slice(-2000) +
"\nObsidian stderr: " +
output.stderr.slice(-2000)
);
}
async function readMetadataIds(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<{ ids: string[]; paths: string[] }> {
return await evalObsidianJson<{ ids: string[]; paths: string[] }>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entries=rows.map(row=>row.doc).filter(doc=>doc&&Array.isArray(doc.children)&&typeof doc._id==='string'&&!doc._deleted);",
"return JSON.stringify({ids:entries.map(entry=>entry._id),paths:entries.map(entry=>typeof entry.path==='string'?entry.path.normalize('NFC'):'')});",
"})()",
].join(""),
environment
);
}
async function runCase(definition: CaseDefinition): Promise<void> {
if (pathNfd === pathNfc) throw new Error("The test fixture path did not produce distinct NFC and NFD spellings.");
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error("Could not find obsidian-cli. Checked paths: " + cli.checked.join(", "));
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
const [portA, portB] = sessionPorts();
const relay = process.env.E2E_P2P_RELAY_URL ?? "ws://p2p-relay:7777/";
const shared: SharedSettings = {
encrypted: definition.encrypted,
e2eePassphrase: randomBytes(24).toString("base64url"),
idKey: definition.encrypted ? randomBytes(32).toString("hex") : "",
p2pPassphrase: randomBytes(24).toString("base64url"),
relay,
roomId: "real-obsidian-p2p-nfc-" + randomBytes(12).toString("hex"),
};
const artifact = await createSetupArtifact(shared, 1);
const incompatibleArtifact = await createSetupArtifact(shared, 0);
const captures: SetupCaptureNames = {
scenario: "p2p-nfc-" + definition.name,
guide: "p2p-nfc-" + definition.name,
};
const vaults: TemporaryVault[] = [];
let sessionA: ObsidianLiveSyncSession | undefined;
let sessionB: ObsidianLiveSyncSession | undefined;
let sessionMode0: ObsidianLiveSyncSession | undefined;
try {
const vaultA = await createTemporaryVault("obsidian-livesync-p2p-nfc-source-");
vaults.push(vaultA);
const vaultB = await createTemporaryVault("obsidian-livesync-p2p-nfc-receiver-");
vaults.push(vaultB);
await writeVaultFile(vaultA.path, pathNfd, sourceContent);
console.log("P2P NFC case: " + definition.name);
console.log("Temporary source and receiving Vaults: " + vaultA.name + ", " + vaultB.name);
await waitForRelay(relay);
sessionA = await startSession(context, vaultA, portA);
await enterSetupURI(portA, "new", artifact, captures);
await captureAndStartInitialisation(portA, "new", captures);
await confirmRebuild(portA, captures);
await acknowledgeDisabledOptionalFeatures(portA, captures);
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
assertEqual(firstState.p2pEnabled, true, "The first device did not enable P2P.");
await assertCaseSettings(context.cliBinary, sessionA.cliEnv, definition, shared.idKey, 1);
await waitForVaultContent(vaultA.path, pathNfc, sourceContent);
const sourceEntry = await writeEntryStateWhenReady(context.cliBinary, sessionA.cliEnv, pathNfc, sourceContent);
assertEqual(sourceEntry.path.normalize("NFC"), pathNfc, "The source Metadata path is not NFC-equivalent.");
await assertSourceNameIsNfd(vaultA.path, sourceContent);
await openP2PStatus(portA);
sessionB = await startSession(context, vaultB, portB);
await enterSetupURI(portB, "existing", artifact, captures);
await captureAndStartInitialisation(portB, "existing", captures);
await confirmFastFetch(portB, captures);
await fetchFromFirstPeer(portA, portB);
await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, pathNfc, { timeoutMs: syncTimeoutMs });
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
assertEqual(secondState.p2pEnabled, true, "The second device did not enable P2P.");
await assertCaseSettings(context.cliBinary, sessionB.cliEnv, definition, shared.idKey, 1);
await waitForVaultContent(vaultB.path, pathNfc, sourceContent);
const receiverEntry = await writeEntryStateWhenReady(
context.cliBinary,
sessionB.cliEnv,
pathNfc,
sourceContent
);
assertEqual(
receiverEntry.id,
sourceEntry.id,
"The NFD source and NFC peer paths produced different Metadata IDs."
);
assertEqual(receiverEntry.content, sourceContent, "The NFC peer did not receive the exact source body.");
await reconnectP2PStatus(portA);
await reconnectP2PStatus(portB);
await waitForDetectedPeer(portB);
await modifyVaultNote(context.cliBinary, sessionB.cliEnv, pathNfc, editedContent);
const locallyEditedEntry = await writeEntryStateWhenReady(
context.cliBinary,
sessionB.cliEnv,
pathNfc,
editedContent
);
assertEqual(locallyEditedEntry.id, sourceEntry.id, "The NFC peer edit changed the Metadata ID.");
await replicateAndWait(portB, portA, vaultA.path, pathNfc, editedContent);
const sourceAfterEdit = await writeEntryStateWhenReady(
context.cliBinary,
sessionA.cliEnv,
pathNfc,
editedContent
);
assertEqual(sourceAfterEdit.id, sourceEntry.id, "The reverse edit changed the source Metadata ID.");
assertEqual(sourceAfterEdit.content, editedContent, "The source did not receive the exact reverse edit.");
const originalRevision = await readEntryState(context.cliBinary, sessionA.cliEnv, pathNfc, sourceEntry.rev);
assertEqual(originalRevision.rev, sourceEntry.rev, "The original source revision was not retained.");
assertEqual(originalRevision.content, sourceContent, "P2P discarded the original revision body.");
await assertSourceNameIsNfd(vaultA.path, editedContent);
await stopSession(context, sessionB);
sessionB = await startSession(context, vaultB, portB);
await waitForLiveSyncCoreReady(context.cliBinary, sessionB.cliEnv);
await waitForVaultContent(vaultB.path, pathNfc, editedContent);
const afterRestart = await writeEntryStateWhenReady(context.cliBinary, sessionB.cliEnv, pathNfc, editedContent);
assertEqual(afterRestart.id, sourceEntry.id, "A full Obsidian restart changed the Metadata ID.");
assertEqual(afterRestart.content, editedContent, "A full Obsidian restart changed the local Metadata body.");
const originalAfterRestart = await readEntryState(context.cliBinary, sessionB.cliEnv, pathNfc, sourceEntry.rev);
assertEqual(
originalAfterRestart.content,
sourceContent,
"A full restart discarded the original revision body."
);
await reconnectP2PStatus(portA);
await openP2PStatus(portB);
await waitForDetectedPeer(portB);
await modifyVaultNote(context.cliBinary, sessionB.cliEnv, pathNfc, restartedContent);
await writeEntryStateWhenReady(context.cliBinary, sessionB.cliEnv, pathNfc, restartedContent);
await replicateAndWait(portB, portA, vaultA.path, pathNfc, restartedContent);
const afterRestartTransfer = await writeEntryStateWhenReady(
context.cliBinary,
sessionA.cliEnv,
pathNfc,
restartedContent
);
assertEqual(afterRestartTransfer.id, sourceEntry.id, "A post-restart transfer changed the Metadata ID.");
await assertSourceNameIsNfd(vaultA.path, restartedContent);
await stopSession(context, sessionB);
sessionB = undefined;
const vaultMode0 = await createTemporaryVault("obsidian-livesync-p2p-nfc-mode0-");
vaults.push(vaultMode0);
await writeVaultFile(vaultMode0.path, sentinelPath, sentinelContent);
sessionMode0 = await startSession(context, vaultMode0, portB);
await enterSetupURI(portB, "new", incompatibleArtifact, captures);
await captureAndStartInitialisation(portB, "new", captures);
await confirmRebuild(portB, captures);
await acknowledgeDisabledOptionalFeatures(portB, captures);
await finishInitialisation(portB, context.cliBinary, sessionMode0.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
await assertCaseSettings(context.cliBinary, sessionMode0.cliEnv, definition, shared.idKey, 0);
const sentinelBefore = await writeEntryStateWhenReady(
context.cliBinary,
sessionMode0.cliEnv,
sentinelPath,
sentinelContent
);
await reconnectP2PStatus(portA);
await openP2PStatus(portB);
await waitForDetectedPeer(portB);
const sourceMetadataBefore = await readMetadataIds(context.cliBinary, sessionA.cliEnv);
if (!sourceMetadataBefore.ids.includes(sourceEntry.id)) {
throw new Error("The source Metadata entry was absent before the incompatible peer attempted replication.");
}
let finished = false;
const acceptor = acceptConnectionRequests([portA, portB], () => finished);
try {
await replicateFromStatusPane(portB);
await waitForTweakMismatchNotice(portB, sessionMode0);
} finally {
finished = true;
await acceptor;
}
await new Promise((resolve) => setTimeout(resolve, 500));
const mode0MetadataAfter = await readMetadataIds(context.cliBinary, sessionMode0.cliEnv);
if (mode0MetadataAfter.ids.includes(sourceEntry.id)) {
throw new Error("The mode 0 peer imported source Metadata after the visible P2P tweak refusal.");
}
if (mode0MetadataAfter.paths.includes(pathNfc)) {
throw new Error("The mode 0 peer created source-path Metadata after the visible P2P tweak refusal.");
}
const absentSourceFile = await readVaultFileState(vaultMode0.path, pathNfc);
assertEqual(absentSourceFile.found, false, "The mode 0 peer reflected the rejected source file.");
const sentinelAfter = await writeEntryStateWhenReady(
context.cliBinary,
sessionMode0.cliEnv,
sentinelPath,
sentinelContent
);
assertEqual(sentinelAfter.id, sentinelBefore.id, "The failed P2P attempt changed the local sentinel ID.");
assertEqual(
sentinelAfter.rev,
sentinelBefore.rev,
"The failed P2P attempt changed the local sentinel revision."
);
assertEqual(sentinelAfter.content, sentinelContent, "The failed P2P attempt changed the local sentinel body.");
console.log(
"P2P NFC case passed: NFD source identity, NFC lookup, exact Chunk-backed transfer, reverse edit, revision retention, full restart, mode 0 refusal, and sentinel preservation."
);
} finally {
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
for (const vault of vaults) await vault.dispose();
}
}
async function main(): Promise<void> {
const cases = selectedCases(process.argv.slice(2));
for (const definition of cases) await runCase(definition);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,102 @@
import { spawn } from "node:child_process";
import { mkdtemp, realpath, rm, stat } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { randomBytes } from "node:crypto";
import { tmpdir } from "node:os";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
type CommandResult = { code: number; signal: NodeJS.Signals | null };
function run(command: string, args: readonly string[], env: NodeJS.ProcessEnv = process.env): Promise<CommandResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(command, [...args], { cwd: process.cwd(), env, stdio: "inherit" });
child.once("error", reject);
child.once("close", (code, signal) => resolveResult({ code: code ?? 1, signal }));
});
}
function pathInside(root: string, candidate: string): string {
const localPath = relative(root, candidate);
if (localPath === ".." || localPath.startsWith(".." + sep) || isAbsolute(localPath)) {
throw new Error(
"The discovered Obsidian executable paths must share one extracted application directory. " +
"Set E2E_P2P_NFC_OBSIDIAN_DIR to their common root."
);
}
return localPath.split(sep).join("/");
}
async function main(): Promise<void> {
const repoRoot = resolve(process.cwd());
const binary = await realpath(requireObsidianBinary());
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error("Could not find obsidian-cli. Checked paths: " + cli.checked.join(", "));
}
const cliBinary = await realpath(cli.binary);
const appDirectory = await realpath(resolve(process.env.E2E_P2P_NFC_OBSIDIAN_DIR?.trim() || dirname(binary)));
if (!(await stat(appDirectory)).isDirectory()) {
throw new Error("The selected Obsidian application root is not a directory: " + appDirectory);
}
const containerBinary = "/opt/obsidian/" + pathInside(appDirectory, binary);
const containerCli = "/opt/obsidian/" + pathInside(appDirectory, cliBinary);
const build = await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]);
if (build.code !== 0) {
throw new Error("The host plug-in build failed with exit code " + build.code + ".");
}
const artifactsDirectory = await mkdtemp(join(tmpdir(), "livesync-e2e-p2p-nfc-"));
const projectName = "livesync-p2p-nfc-" + process.pid + "-" + Date.now() + "-" + randomBytes(4).toString("hex");
const composeFile = join(repoRoot, "test/e2e-obsidian/compose.p2p.yml");
const composeArgs = ["compose", "--project-name", projectName, "--file", composeFile];
const composeEnvironment: NodeJS.ProcessEnv = {
...process.env,
E2E_P2P_NFC_OBSIDIAN_DIR: appDirectory,
E2E_P2P_NFC_OBSIDIAN_BINARY: containerBinary,
E2E_P2P_NFC_OBSIDIAN_CLI: containerCli,
E2E_P2P_NFC_ARTIFACT_DIR: artifactsDirectory,
};
let exitCode = 0;
let failure: unknown;
try {
const result = await run(
"docker",
[...composeArgs, "run", "--rm", "--build", "p2p-nfc", ...process.argv.slice(2)],
composeEnvironment
);
exitCode = result.code;
if (result.signal) exitCode = 1;
} catch (error) {
failure = error;
exitCode = 1;
} finally {
try {
const teardown = await run(
"docker",
[...composeArgs, "down", "--volumes", "--remove-orphans"],
composeEnvironment
);
if (teardown.code !== 0 && exitCode === 0) exitCode = teardown.code;
} catch (error) {
if (exitCode === 0) {
failure = error;
exitCode = 1;
}
}
if (exitCode === 0) {
await rm(artifactsDirectory, { recursive: true, force: true });
} else {
console.error("P2P NFC diagnostics were retained at " + artifactsDirectory);
}
}
if (failure) throw failure;
if (exitCode !== 0) process.exitCode = exitCode;
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+1
View File
@@ -13,6 +13,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased ## Unreleased
- Unicode NFC path identity can be used with Object Storage and P2P on CLI and Obsidian clients. The setting is available under General Settings → File identity. Existing databases require a coordinated Rebuild and Fetch; physical file names and Chunk IDs stay unchanged. All P2P participants must be updated and migrated. - Unicode NFC path identity can be used with Object Storage and P2P on CLI and Obsidian clients. The setting is available under General Settings → File identity. Existing databases require a coordinated Rebuild and Fetch; physical file names and Chunk IDs stay unchanged. All P2P participants must be updated and migrated.
- New CLI settings and Obsidian manual onboarding use NFC. Existing settings and old Setup URIs retain legacy identity; initial central-remote setup follows the remote declaration. Existing databases still require the migration above.
- Object Storage checks the selected identity before Fetch replaces the local database and before ordinary remote administration changes its milestone. Rebuild preserves the old identity until the remote journals have been deleted. - Object Storage checks the selected identity before Fetch replaces the local database and before ordinary remote administration changes its milestone. Rebuild preserves the old identity until the remote journals have been deleted.
- We can continue CouchDB synchronisation after rebuilding an encrypted remote when **Use Internal API** is enabled. (#1234) - We can continue CouchDB synchronisation after rebuilding an encrypted remote when **Use Internal API** is enabled. (#1234)