mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 00:08:23 +00:00
Default new CLI and Obsidian environments to NFC path identity
This commit is contained in:
1 parent
ac502b0261
commit
39cc31c2e8
26 files changed
+1466
-61
No files matched your search
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
return {
|
||||
|
||||
@@ -7,10 +7,14 @@ describe("createDefaultCliSettings", () => {
|
||||
const settings = createDefaultCliSettings();
|
||||
const recommended = createNewVaultSettings();
|
||||
|
||||
expect(settings.pathNormalizationVersion).toBe(1);
|
||||
expect(settings).toEqual({
|
||||
...recommended,
|
||||
pathNormalizationVersion: 1,
|
||||
useIndexedDBAdapter: false,
|
||||
isConfigured: false,
|
||||
});
|
||||
expect(recommended.pathNormalizationVersion).toBe(0);
|
||||
expect(settings.remoteConfigurations).not.toBe(recommended.remoteConfigurations);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import * as chokidar from "chokidar";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
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 {
|
||||
AutoAccepting,
|
||||
@@ -333,6 +334,45 @@ describe("CLI database preparation", () => {
|
||||
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) => {
|
||||
const storedPaths: string[] = [];
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
|
||||
@@ -30,6 +30,8 @@ import type { ReplicationSchedulingControl } from "@/serviceFeatures/replication
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { resolveInitialFetchPathIdentity } from "./initialFetchPathIdentity";
|
||||
import { createDefaultCliSettings } from "./cliSettingsDefaults";
|
||||
import {
|
||||
applyStoredSetting,
|
||||
@@ -601,6 +603,22 @@ export async function main(
|
||||
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
|
||||
const shutdown = async (signal: string) => {
|
||||
writeStdoutLine(standardIo);
|
||||
@@ -802,6 +820,7 @@ export async function main(
|
||||
: (JSON.parse(originalSettingsText) as ObsidianLiveSyncSettings);
|
||||
durable.additionalSuffixOfDatabaseName =
|
||||
core.services.setting.currentSettings().additionalSuffixOfDatabaseName;
|
||||
if (initialFetchPathIdentity !== undefined) durable.pathNormalizationVersion = initialFetchPathIdentity;
|
||||
settingsTextToCommit = JSON.stringify(durable, null, 2);
|
||||
}
|
||||
settingsTextToCommit ??= originalSettingsText;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
||||
objectStorage: [] as Array<{
|
||||
host: unknown;
|
||||
tryConnectRemote: ReturnType<typeof vi.fn>;
|
||||
inspectRemotePathIdentity: ReturnType<typeof vi.fn>;
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
@@ -64,6 +65,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
||||
LiveSyncJournalReplicator: class {
|
||||
host: unknown;
|
||||
tryConnectRemote = vi.fn();
|
||||
inspectRemotePathIdentity = vi.fn();
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
@@ -135,6 +137,45 @@ describe("replicator probe factories", () => {
|
||||
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 () => {
|
||||
const source = createSettings();
|
||||
const snapshot = { ...source };
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} 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 { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
|
||||
@@ -51,6 +52,20 @@ function createCouchDBConnectionProbe(
|
||||
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),
|
||||
dispose,
|
||||
};
|
||||
@@ -70,6 +85,7 @@ function createObjectStorageConnectionProbe(
|
||||
return { ok: false, reason: error };
|
||||
}
|
||||
},
|
||||
inspectPathIdentity: () => replicator.inspectRemotePathIdentity(snapshot),
|
||||
getStatus: () => replicator.getRemoteStatus(snapshot),
|
||||
dispose,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
} 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 { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
type SetupInitialisationMode,
|
||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.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";
|
||||
|
||||
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.
|
||||
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
|
||||
return {
|
||||
...settings,
|
||||
pathNormalizationVersion: Object.prototype.hasOwnProperty.call(settings, "pathNormalizationVersion")
|
||||
? settings.pathNormalizationVersion
|
||||
: 0,
|
||||
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
|
||||
? settings.idDerivationVersion
|
||||
: 0,
|
||||
@@ -233,7 +238,7 @@ export class SetupManager extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
newSetting.remoteType = REMOTE_COUCHDB;
|
||||
}
|
||||
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
|
||||
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
@@ -302,10 +308,37 @@ export class SetupManager extends AbstractModule {
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_MINIO;
|
||||
}
|
||||
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
|
||||
upsertRemoteConfigurationInPlace(newSetting, "s3", { 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
|
||||
* @param userMode
|
||||
@@ -345,10 +378,7 @@ export class SetupManager extends AbstractModule {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
>(SetupRemoteE2EE, { settings: currentSetting, newVault: userMode === UserMode.NewUser });
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
@@ -397,10 +427,7 @@ export class SetupManager extends AbstractModule {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
>(SetupRemoteE2EE, { settings: originalSetting, newVault: userMode === UserMode.NewUser });
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
@@ -545,7 +572,7 @@ export class SetupManager extends AbstractModule {
|
||||
async decodeQR(qr: string) {
|
||||
let newSettings: ObsidianLiveSyncSettings;
|
||||
try {
|
||||
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
|
||||
newSettings = normaliseImportedIdentitySettings(decodeSettingsFromQRCodeData(qr));
|
||||
} catch {
|
||||
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
|
||||
@@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createNewVaultSettings } from "@/common/newVaultSettings";
|
||||
import type {
|
||||
P2PConnectionProbeAdmission,
|
||||
P2PConnectionProbeAdmissionResult,
|
||||
@@ -98,6 +99,12 @@ function createSetupManager() {
|
||||
dialogManager,
|
||||
},
|
||||
setting,
|
||||
replicator: {
|
||||
createRemoteResource: vi.fn(async () => ({
|
||||
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
})),
|
||||
},
|
||||
} as any;
|
||||
const core: any = {
|
||||
_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 () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
@@ -192,12 +292,13 @@ describe("SetupManager", () => {
|
||||
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 savedKey = "12".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
pathNormalizationVersion: 1,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
@@ -205,6 +306,7 @@ describe("SetupManager", () => {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.pathNormalizationVersion;
|
||||
delete imported.idDerivationVersion;
|
||||
delete imported.idDerivationKey;
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
@@ -213,6 +315,7 @@ describe("SetupManager", () => {
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.pathNormalizationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
@@ -489,44 +592,47 @@ describe("SetupManager", () => {
|
||||
it.each([
|
||||
{ importedSize: 0, expectedSize: 0 },
|
||||
{ importedSize: undefined, expectedSize: 60 },
|
||||
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
couch: {
|
||||
id: "couch",
|
||||
name: "Office CouchDB",
|
||||
uri: "sls+https://alice:secret@couch.example/?db=notes",
|
||||
isEncrypted: false,
|
||||
])(
|
||||
"preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize",
|
||||
async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
couch: {
|
||||
id: "couch",
|
||||
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®ion=auto",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
archive: {
|
||||
id: "archive",
|
||||
name: "Archive bucket",
|
||||
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive®ion=auto",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
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");
|
||||
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();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
});
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
|
||||
Reference in new issue
Block a user