mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-29 23:07:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff8103e40 | ||
|
|
aeb97c2b13 | ||
|
|
44c52aa2dd | ||
|
|
d74e37559c | ||
|
|
5c7af20ad2 | ||
|
|
0f2efbd670 | ||
|
|
a9283be9d2 | ||
|
|
4d6a794f9d | ||
|
|
c4419a837c |
@@ -44,6 +44,7 @@
|
|||||||
"test:contract:context:obsidian": "npm run build && npm run test:e2e:obsidian:smoke",
|
"test:contract:context:obsidian": "npm run build && npm run test:e2e:obsidian:smoke",
|
||||||
"test:e2e:cli": "npm run test:e2e:ci --workspace self-hosted-livesync-cli",
|
"test:e2e:cli": "npm run test:e2e:ci --workspace self-hosted-livesync-cli",
|
||||||
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
|
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
|
||||||
|
"test:e2e:cli:adaptive-s3": "npm run test:e2e:adaptive-s3 --workspace self-hosted-livesync-cli",
|
||||||
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
|
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
|
||||||
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
|
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
|
||||||
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
|
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/co
|
|||||||
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||||
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
||||||
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||||
|
import { useJournalSyncFeature } from "@vrtmrz/livesync-commonlib/journal-sync";
|
||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
import { AbstractModule } from "./modules/AbstractModule";
|
import { AbstractModule } from "./modules/AbstractModule";
|
||||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
||||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
|
|
||||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||||
@@ -139,7 +139,6 @@ export class LiveSyncBaseCore<
|
|||||||
public registerModules(extraModules: AbstractModule[] = []) {
|
public registerModules(extraModules: AbstractModule[] = []) {
|
||||||
this._registerModule(new ModuleLiveSyncMain(this));
|
this._registerModule(new ModuleLiveSyncMain(this));
|
||||||
this._registerModule(new ModuleConflictChecker(this));
|
this._registerModule(new ModuleConflictChecker(this));
|
||||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
|
||||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||||
this._registerModule(new ModuleReplicator(this));
|
this._registerModule(new ModuleReplicator(this));
|
||||||
this._registerModule(new ModuleConflictResolver(this));
|
this._registerModule(new ModuleConflictResolver(this));
|
||||||
@@ -279,6 +278,8 @@ export class LiveSyncBaseCore<
|
|||||||
usePrepareDatabaseForUse(this);
|
usePrepareDatabaseForUse(this);
|
||||||
// Migration to multiple remote configurations
|
// Migration to multiple remote configurations
|
||||||
useRemoteConfigurationMigration(this);
|
useRemoteConfigurationMigration(this);
|
||||||
|
// Journal providers are selected through the composed remote-provider registry.
|
||||||
|
useJournalSyncFeature(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
|
|||||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||||
|
import type { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||||
|
import { journalProtocolConfigurationForSettings } from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||||
|
|
||||||
function redactConnectionString(uri: string): string {
|
function redactConnectionString(uri: string): string {
|
||||||
@@ -60,7 +62,19 @@ async function verifyRemoteState(
|
|||||||
}
|
}
|
||||||
milestone = await dbRet.db.get(MILESTONE_DOCID);
|
milestone = await dbRet.db.get(MILESTONE_DOCID);
|
||||||
} else if (settings.remoteType === REMOTE_MINIO) {
|
} else if (settings.remoteType === REMOTE_MINIO) {
|
||||||
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
|
const journalReplicator = replicator as LiveSyncJournalReplicator;
|
||||||
|
if (journalProtocolConfigurationForSettings(settings).journalFormat === "adaptive-v1") {
|
||||||
|
try {
|
||||||
|
await journalReplicator.client.ensureCheckpointCachesAreFresh();
|
||||||
|
standardIo.writeStderr("[Verification] Adaptive Journal repository is available.\n");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
standardIo.writeStderr(`[Verification] Failed to verify Adaptive Journal repository: ${message}\n`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
milestone = await (journalReplicator.client as JournalSyncCore).downloadJson("_00000000-milestone.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (milestone) {
|
if (milestone) {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
|||||||
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import {
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
REMOTE_COUCHDB,
|
||||||
|
REMOTE_MINIO,
|
||||||
|
REMOTE_P2P,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { runCommand } from "./runCommand";
|
import { runCommand } from "./runCommand";
|
||||||
import type { CLIOptions } from "./types";
|
import type { CLIOptions } from "./types";
|
||||||
@@ -717,6 +722,34 @@ describe("runCommand abnormal cases", () => {
|
|||||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("verifies an Adaptive Journal repository without reading the legacy milestone", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
const settings = core.services.setting.currentSettings();
|
||||||
|
settings.remoteType = REMOTE_MINIO;
|
||||||
|
settings.journalFormat = "adaptive-v1";
|
||||||
|
settings.packReadPolicy = "whole-pack";
|
||||||
|
|
||||||
|
const ensureCheckpointCachesAreFresh = vi.fn(async () => {});
|
||||||
|
core.services.replicator.getActiveReplicator.mockReturnValue({
|
||||||
|
nodeid: "test-node-id",
|
||||||
|
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||||
|
client: {
|
||||||
|
ensureCheckpointCachesAreFresh,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||||
|
...context,
|
||||||
|
core,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(ensureCheckpointCachesAreFresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||||
|
"[Verification] Adaptive Journal repository is available.\n"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
|
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
|
||||||
const core = createCoreMock();
|
const core = createCoreMock();
|
||||||
const settings = core.services.setting.currentSettings();
|
const settings = core.services.setting.currentSettings();
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
"pretest:e2e:ci": "npm run build",
|
"pretest:e2e:ci": "npm run build",
|
||||||
"test:e2e:ci": "deno task --cwd testdeno test:ci",
|
"test:e2e:ci": "deno task --cwd testdeno test:ci",
|
||||||
"test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose",
|
"test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose",
|
||||||
|
"pretest:e2e:adaptive-s3": "npm run build",
|
||||||
|
"test:e2e:adaptive-s3": "deno task --cwd testdeno test:adaptive-journal-s3",
|
||||||
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
||||||
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
|
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
|
||||||
"pretest:e2e:all": "npm run build",
|
"pretest:e2e:all": "npm run build",
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ type SerializableContainer =
|
|||||||
| {
|
| {
|
||||||
[NODE_KV_TYPED_KEY]: "ArrayBuffer";
|
[NODE_KV_TYPED_KEY]: "ArrayBuffer";
|
||||||
[NODE_KV_VALUES_KEY]: number[];
|
[NODE_KV_VALUES_KEY]: number[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
[NODE_KV_TYPED_KEY]: "BigInt";
|
||||||
|
[NODE_KV_VALUES_KEY]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -32,6 +36,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeForNodeKV(value: unknown): unknown {
|
function serializeForNodeKV(value: unknown): unknown {
|
||||||
|
if (typeof value === "bigint") {
|
||||||
|
return {
|
||||||
|
[NODE_KV_TYPED_KEY]: "BigInt",
|
||||||
|
[NODE_KV_VALUES_KEY]: value.toString(10),
|
||||||
|
} satisfies SerializableContainer;
|
||||||
|
}
|
||||||
if (value instanceof Set) {
|
if (value instanceof Set) {
|
||||||
return {
|
return {
|
||||||
[NODE_KV_TYPED_KEY]: "Set",
|
[NODE_KV_TYPED_KEY]: "Set",
|
||||||
@@ -78,6 +88,9 @@ function deserializeFromNodeKV(value: unknown): unknown {
|
|||||||
if (taggedType === "ArrayBuffer" && Array.isArray(taggedValues)) {
|
if (taggedType === "ArrayBuffer" && Array.isArray(taggedValues)) {
|
||||||
return Uint8Array.from(taggedValues).buffer;
|
return Uint8Array.from(taggedValues).buffer;
|
||||||
}
|
}
|
||||||
|
if (taggedType === "BigInt" && typeof taggedValues === "string" && /^-?(?:0|[1-9]\d*)$/u.test(taggedValues)) {
|
||||||
|
return BigInt(taggedValues);
|
||||||
|
}
|
||||||
|
|
||||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deserializeFromNodeKV(v)]));
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deserializeFromNodeKV(v)]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||||
|
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
||||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||||
|
|
||||||
|
function createInitialisableDependencies(): {
|
||||||
|
dependencies: NodeKeyValueDBDependencies;
|
||||||
|
initialise: () => Promise<boolean>;
|
||||||
|
} {
|
||||||
|
let initialise: (() => Promise<boolean>) | undefined;
|
||||||
|
const dependencies = {
|
||||||
|
appLifecycle: {
|
||||||
|
onSettingLoaded: {
|
||||||
|
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||||
|
initialise = handler;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
databaseEvents: {
|
||||||
|
onResetDatabase: { addHandler: vi.fn() },
|
||||||
|
onDatabaseInitialisation: { addHandler: vi.fn() },
|
||||||
|
onUnloadDatabase: { addHandler: vi.fn() },
|
||||||
|
onCloseDatabase: { addHandler: vi.fn() },
|
||||||
|
},
|
||||||
|
vault: {},
|
||||||
|
} as unknown as NodeKeyValueDBDependencies;
|
||||||
|
return {
|
||||||
|
dependencies,
|
||||||
|
initialise: async () => {
|
||||||
|
if (!initialise) throw new Error("Initialisation handler was not registered");
|
||||||
|
return await initialise();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("NodeKeyValueDBService.openSimpleStore", () => {
|
describe("NodeKeyValueDBService.openSimpleStore", () => {
|
||||||
it("creates a namespaced store handle before the backing database is initialised", () => {
|
it("creates a namespaced store handle before the backing database is initialised", () => {
|
||||||
const dependencies = {
|
const dependencies = {
|
||||||
@@ -44,4 +75,29 @@ describe("NodeKeyValueDBService.openSimpleStore", () => {
|
|||||||
|
|
||||||
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves bigint values used by Adaptive Journal state", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-node-kv-bigint-"));
|
||||||
|
const filePath = path.join(tempDir, "keyvalue-db.json");
|
||||||
|
const writerState = {
|
||||||
|
lastCommittedSequence: 9007199254740993n,
|
||||||
|
pendingCommit: { sequence: 18446744073709551615n },
|
||||||
|
writerEpoch: "test-writer-epoch",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstLifecycle = createInitialisableDependencies();
|
||||||
|
const first = new NodeKeyValueDBService(createServiceContext(), firstLifecycle.dependencies, filePath);
|
||||||
|
await expect(firstLifecycle.initialise()).resolves.toBe(true);
|
||||||
|
await first.openSimpleStore("adaptive").set("writer-state", writerState);
|
||||||
|
|
||||||
|
const secondLifecycle = createInitialisableDependencies();
|
||||||
|
const second = new NodeKeyValueDBService(createServiceContext(), secondLifecycle.dependencies, filePath);
|
||||||
|
await expect(secondLifecycle.initialise()).resolves.toBe(true);
|
||||||
|
|
||||||
|
await expect(second.openSimpleStore("adaptive").get("writer-state")).resolves.toEqual(writerState);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts",
|
"test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts",
|
||||||
"test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts",
|
"test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts",
|
||||||
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
|
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
|
||||||
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts"
|
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts",
|
||||||
|
"test:adaptive-journal-s3": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-s3.ts"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||||
|
|||||||
@@ -327,8 +327,8 @@ const COUCHDB_CONTAINER = "couchdb-test";
|
|||||||
const COUCHDB_IMAGE = "couchdb:3.5.0";
|
const COUCHDB_IMAGE = "couchdb:3.5.0";
|
||||||
|
|
||||||
const MINIO_CONTAINER = "minio-test";
|
const MINIO_CONTAINER = "minio-test";
|
||||||
const MINIO_IMAGE = "minio/minio";
|
const MINIO_IMAGE = "minio/minio:RELEASE.2025-04-22T22-12-26Z";
|
||||||
const MINIO_MC_IMAGE = "minio/mc";
|
const MINIO_MC_IMAGE = "minio/mc:RELEASE.2025-04-16T18-13-26Z";
|
||||||
|
|
||||||
export async function stopCouchdb(): Promise<void> {
|
export async function stopCouchdb(): Promise<void> {
|
||||||
await stopAndRemoveContainer(COUCHDB_CONTAINER);
|
await stopAndRemoveContainer(COUCHDB_CONTAINER);
|
||||||
@@ -466,6 +466,70 @@ export async function stopMinio(): Promise<void> {
|
|||||||
untrackContainer(MINIO_CONTAINER);
|
untrackContainer(MINIO_CONTAINER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMinioObjectKeys(
|
||||||
|
minioEndpoint: string,
|
||||||
|
accessKey: string,
|
||||||
|
secretKey: string,
|
||||||
|
bucket: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const cmd =
|
||||||
|
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||||
|
`mc ls --recursive --json myminio/${shQuote(bucket)}`;
|
||||||
|
const result = await docker(
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--network",
|
||||||
|
"host",
|
||||||
|
"--entrypoint",
|
||||||
|
"/bin/sh",
|
||||||
|
MINIO_MC_IMAGE,
|
||||||
|
"-c",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`Could not list MinIO objects: ${result.stderr.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.filter((line) => line.trim().length > 0)
|
||||||
|
.map((line) => JSON.parse(line) as { key?: unknown })
|
||||||
|
.map(({ key }) => {
|
||||||
|
if (typeof key !== "string") {
|
||||||
|
throw new Error("MinIO returned an object without a string key");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMinioObjectText(
|
||||||
|
minioEndpoint: string,
|
||||||
|
accessKey: string,
|
||||||
|
secretKey: string,
|
||||||
|
bucket: string,
|
||||||
|
key: string
|
||||||
|
): Promise<string> {
|
||||||
|
const cmd =
|
||||||
|
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||||
|
`mc cat myminio/${shQuote(bucket)}/${shQuote(key)}`;
|
||||||
|
const result = await docker(
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--network",
|
||||||
|
"host",
|
||||||
|
"--entrypoint",
|
||||||
|
"/bin/sh",
|
||||||
|
MINIO_MC_IMAGE,
|
||||||
|
"-c",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`Could not read MinIO object ${key}: ${result.stderr.trim()}`);
|
||||||
|
}
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
|
||||||
async function initMinioBucket(
|
async function initMinioBucket(
|
||||||
minioEndpoint: string,
|
minioEndpoint: string,
|
||||||
accessKey: string,
|
accessKey: string,
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ export async function applyRemoteSyncSettings(
|
|||||||
passphrase?: string;
|
passphrase?: string;
|
||||||
enableCompression?: boolean;
|
enableCompression?: boolean;
|
||||||
usePathObfuscation?: boolean;
|
usePathObfuscation?: boolean;
|
||||||
|
journalFormat?: "adaptive-v1" | "opaque-v1";
|
||||||
|
expectedRepositoryId?: string;
|
||||||
|
packReadPolicy?: "range" | "whole-pack";
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||||
@@ -143,6 +146,15 @@ export async function applyRemoteSyncSettings(
|
|||||||
data.secretKey = options.minioSecretKey;
|
data.secretKey = options.minioSecretKey;
|
||||||
data.region = "auto";
|
data.region = "auto";
|
||||||
data.forcePathStyle = true;
|
data.forcePathStyle = true;
|
||||||
|
if (options.journalFormat !== undefined) {
|
||||||
|
data.journalFormat = options.journalFormat;
|
||||||
|
}
|
||||||
|
if (options.expectedRepositoryId !== undefined) {
|
||||||
|
data.expectedRepositoryId = options.expectedRepositoryId;
|
||||||
|
}
|
||||||
|
if (options.packReadPolicy !== undefined) {
|
||||||
|
data.packReadPolicy = options.packReadPolicy;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.liveSync = true;
|
data.liveSync = true;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const TASKS = [
|
|||||||
"test:e2e-matrix:couchdb-enc1",
|
"test:e2e-matrix:couchdb-enc1",
|
||||||
"test:e2e-matrix:minio-enc0",
|
"test:e2e-matrix:minio-enc0",
|
||||||
"test:e2e-matrix:minio-enc1",
|
"test:e2e-matrix:minio-enc1",
|
||||||
|
"test:adaptive-journal-s3",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const [index, task] of TASKS.entries()) {
|
for (const [index, task] of TASKS.entries()) {
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
|
import { assertFilesEqual, runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||||
|
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
|
import { startMinio, stopMinio } from "./helpers/docker.ts";
|
||||||
|
|
||||||
|
const EXTERNAL_PACK_TEST_BYTES = 9 * 1024 * 1024;
|
||||||
|
|
||||||
|
function deterministicBytes(length: number, seed: number): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
let state = seed;
|
||||||
|
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||||
|
state ^= state << 13;
|
||||||
|
state ^= state >>> 17;
|
||||||
|
state ^= state << 5;
|
||||||
|
bytes[index] = state & 0xff;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnv(...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = Deno.env.get(key)?.trim();
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
throw new Error(`Required environment variable is missing: ${keys.join(" or ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal S3", async () => {
|
||||||
|
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
const endpoint = requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/u, "");
|
||||||
|
const accessKey = requireEnv("MINIO_ACCESS_KEY", "accessKey");
|
||||||
|
const secretKey = requireEnv("MINIO_SECRET_KEY", "secretKey");
|
||||||
|
const bucket = `${requireEnv("MINIO_BUCKET_NAME", "bucketName")}-${suffix}`;
|
||||||
|
const passphrase = "adaptive-journal-cli-e2e-passphrase";
|
||||||
|
|
||||||
|
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-s3");
|
||||||
|
const vaultA = workDir.join("vault-a");
|
||||||
|
const vaultB = workDir.join("vault-b");
|
||||||
|
const settingsA = workDir.join("settings-a.json");
|
||||||
|
const settingsB = workDir.join("settings-b.json");
|
||||||
|
const binarySourceA = workDir.join("source-a.bin");
|
||||||
|
const binarySourceB = workDir.join("source-b.bin");
|
||||||
|
const binaryDestinationA = workDir.join("destination-a.bin");
|
||||||
|
const binaryDestinationB = workDir.join("destination-b.bin");
|
||||||
|
await Deno.mkdir(vaultA, { recursive: true });
|
||||||
|
await Deno.mkdir(vaultB, { recursive: true });
|
||||||
|
|
||||||
|
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
||||||
|
await startMinio(endpoint, accessKey, secretKey, bucket);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initSettingsFile(settingsA);
|
||||||
|
await initSettingsFile(settingsB);
|
||||||
|
await applyRemoteSyncSettings(settingsA, {
|
||||||
|
remoteType: "MINIO",
|
||||||
|
minioBucket: bucket,
|
||||||
|
minioEndpoint: endpoint,
|
||||||
|
minioAccessKey: accessKey,
|
||||||
|
minioSecretKey: secretKey,
|
||||||
|
encrypt: true,
|
||||||
|
passphrase,
|
||||||
|
enableCompression: false,
|
||||||
|
journalFormat: "adaptive-v1",
|
||||||
|
packReadPolicy: "whole-pack",
|
||||||
|
});
|
||||||
|
await applyRemoteSyncSettings(settingsB, {
|
||||||
|
remoteType: "MINIO",
|
||||||
|
minioBucket: bucket,
|
||||||
|
minioEndpoint: endpoint,
|
||||||
|
minioAccessKey: accessKey,
|
||||||
|
minioSecretKey: secretKey,
|
||||||
|
encrypt: true,
|
||||||
|
passphrase,
|
||||||
|
enableCompression: false,
|
||||||
|
journalFormat: "adaptive-v1",
|
||||||
|
packReadPolicy: "range",
|
||||||
|
});
|
||||||
|
|
||||||
|
const textPath = "adaptive/text.md";
|
||||||
|
const binaryPath = "adaptive/data.bin";
|
||||||
|
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
|
||||||
|
await Deno.writeFile(binarySourceA, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x1a2b3c4d));
|
||||||
|
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
|
||||||
|
|
||||||
|
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||||
|
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||||
|
assertEquals(
|
||||||
|
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
|
||||||
|
`created-by-a-${suffix}`
|
||||||
|
);
|
||||||
|
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
|
||||||
|
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal Range transfer differs");
|
||||||
|
|
||||||
|
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
|
||||||
|
await Deno.writeFile(binarySourceB, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x5e6f7788));
|
||||||
|
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
|
||||||
|
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||||
|
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||||
|
assertEquals(
|
||||||
|
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
|
||||||
|
`updated-by-b-${suffix}`
|
||||||
|
);
|
||||||
|
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
|
||||||
|
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal whole-Pack transfer differs");
|
||||||
|
|
||||||
|
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
|
||||||
|
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||||
|
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||||
|
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
|
||||||
|
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
|
||||||
|
} finally {
|
||||||
|
if (!keepDocker) {
|
||||||
|
await stopMinio().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fireAndForget } from "octagonal-wheels/promises";
|
import { fireAndForget } from "octagonal-wheels/promises";
|
||||||
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||||
import { AbstractModule } from "@/modules/AbstractModule";
|
import { AbstractModule } from "@/modules/AbstractModule";
|
||||||
@@ -8,8 +9,7 @@ import type { LiveSyncCore } from "@/main";
|
|||||||
export class ModuleReplicatorCouchDB extends AbstractModule {
|
export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||||
const settings = { ...this.settings, ...settingOverride };
|
const settings = { ...this.settings, ...settingOverride };
|
||||||
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
|
if (!defaultRemoteProviderRegistry.isRemoteTypeInFamily(settings.remoteType, "couchdb")) {
|
||||||
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
|
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
||||||
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
|
|||||||
_everyAfterResumeProcess(): Promise<boolean> {
|
_everyAfterResumeProcess(): Promise<boolean> {
|
||||||
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
|
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
|
||||||
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
|
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
|
||||||
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
|
if (defaultRemoteProviderRegistry.isRemoteTypeInFamily(this.settings.remoteType, "couchdb")) {
|
||||||
const LiveSyncEnabled = this.settings.liveSync;
|
const LiveSyncEnabled = this.settings.liveSync;
|
||||||
const continuous = LiveSyncEnabled;
|
const continuous = LiveSyncEnabled;
|
||||||
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
|
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
|
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
|
||||||
|
|
||||||
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
|
function createModule(
|
||||||
|
settings: { liveSync: boolean; syncOnStart: boolean; remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO },
|
||||||
|
isReplicationReady = true
|
||||||
|
) {
|
||||||
const openReplication = vi.fn(async () => true);
|
const openReplication = vi.fn(async () => true);
|
||||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||||
const services = {
|
const services = {
|
||||||
@@ -30,7 +34,7 @@ function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isR
|
|||||||
_services: services,
|
_services: services,
|
||||||
services,
|
services,
|
||||||
settings: {
|
settings: {
|
||||||
remoteType: "",
|
remoteType: REMOTE_COUCHDB,
|
||||||
...settings,
|
...settings,
|
||||||
},
|
},
|
||||||
replicator: { openReplication },
|
replicator: { openReplication },
|
||||||
@@ -86,4 +90,18 @@ describe("ModuleReplicatorCouchDB resume replication activity", () => {
|
|||||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||||
expect(openReplication).not.toHaveBeenCalled();
|
expect(openReplication).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not start CouchDB replication for a registered Journal provider", async () => {
|
||||||
|
const { module, openReplication } = createModule({
|
||||||
|
liveSync: true,
|
||||||
|
syncOnStart: true,
|
||||||
|
remoteType: REMOTE_MINIO,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(module._anyNewReplicator()).resolves.toBe(false);
|
||||||
|
await module._everyAfterResumeProcess();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(openReplication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
||||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
|
||||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
|
||||||
import type { LiveSyncCore } from "@/main";
|
|
||||||
import { AbstractModule } from "@/modules/AbstractModule";
|
|
||||||
|
|
||||||
export class ModuleReplicatorMinIO extends AbstractModule {
|
|
||||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
|
||||||
const settings = { ...this.settings, ...settingOverride };
|
|
||||||
if (settings.remoteType == REMOTE_MINIO) {
|
|
||||||
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
|
|
||||||
}
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
|
||||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
REMOTE_COUCHDB,
|
|
||||||
REMOTE_MINIO,
|
|
||||||
REMOTE_P2P,
|
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
LOG_LEVEL_NOTICE,
|
LOG_LEVEL_NOTICE,
|
||||||
type ObsidianLiveSyncSettings,
|
type ObsidianLiveSyncSettings,
|
||||||
@@ -21,18 +18,12 @@ import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
|
|||||||
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
|
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
|
||||||
import {
|
import {
|
||||||
activateRemoteConfiguration,
|
activateRemoteConfiguration,
|
||||||
|
createRemoteConfigurationId,
|
||||||
|
defaultRemoteProviderRegistry,
|
||||||
|
suggestRemoteConfigurationName,
|
||||||
|
type BuiltInRemoteConfiguration,
|
||||||
type RemoteConfiguration,
|
type RemoteConfiguration,
|
||||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
|
||||||
import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
|
||||||
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
|
|
||||||
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
|
||||||
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
|
||||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
|
||||||
import type {
|
|
||||||
SetupRemoteCouchDBInitialData,
|
|
||||||
SetupRemoteCouchDBResultType,
|
|
||||||
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
|
|
||||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
|
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
|
||||||
|
|
||||||
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
|
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
|
||||||
@@ -43,10 +34,6 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
|
|||||||
}
|
}
|
||||||
return workObj;
|
return workObj;
|
||||||
}
|
}
|
||||||
function createRemoteConfigurationId(): string {
|
|
||||||
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneRemoteConfigurations(
|
function cloneRemoteConfigurations(
|
||||||
configs: Record<string, RemoteConfiguration> | undefined
|
configs: Record<string, RemoteConfiguration> | undefined
|
||||||
): Record<string, RemoteConfiguration> {
|
): Record<string, RemoteConfiguration> {
|
||||||
@@ -54,13 +41,10 @@ function cloneRemoteConfigurations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
|
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
|
||||||
if (settings.remoteType === REMOTE_MINIO) {
|
const type = defaultRemoteProviderRegistry.typeForRemoteType(settings.remoteType);
|
||||||
return ConnectionStringParser.serialize({ type: "s3", settings });
|
if (!type) throw new Error(`Unsupported remote type: ${settings.remoteType}`);
|
||||||
}
|
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
|
||||||
if (settings.remoteType === REMOTE_P2P) {
|
return defaultRemoteProviderRegistry.serialise(configuration);
|
||||||
return ConnectionStringParser.serialize({ type: "p2p", settings });
|
|
||||||
}
|
|
||||||
return ConnectionStringParser.serialize({ type: "couchdb", settings });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
|
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
|
||||||
@@ -71,21 +55,6 @@ function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string)
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
|
|
||||||
if (parsed.type === "couchdb") {
|
|
||||||
try {
|
|
||||||
const url = new URL(parsed.settings.couchDB_URI);
|
|
||||||
return `CouchDB ${url.host}`;
|
|
||||||
} catch {
|
|
||||||
return "Imported CouchDB";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parsed.type === "s3") {
|
|
||||||
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
|
|
||||||
}
|
|
||||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paneRemoteConfig(
|
export function paneRemoteConfig(
|
||||||
this: ObsidianLiveSyncSettingTab,
|
this: ObsidianLiveSyncSettingTab,
|
||||||
paneEl: HTMLElement,
|
paneEl: HTMLElement,
|
||||||
@@ -189,48 +158,10 @@ export function paneRemoteConfig(
|
|||||||
};
|
};
|
||||||
const runRemoteSetup = async (
|
const runRemoteSetup = async (
|
||||||
baseSettings: ObsidianLiveSyncSettings,
|
baseSettings: ObsidianLiveSyncSettings,
|
||||||
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
|
type?: BuiltInRemoteConfiguration["type"]
|
||||||
): Promise<ObsidianLiveSyncSettings | false> => {
|
): Promise<ObsidianLiveSyncSettings | false> => {
|
||||||
const setupManager = this.core.getModule(SetupManager);
|
const setupManager = this.core.getModule(SetupManager);
|
||||||
const dialogManager = setupManager.dialogManager;
|
return await setupManager.configureRemoteForSettings(baseSettings, type);
|
||||||
let targetRemoteType = remoteType;
|
|
||||||
|
|
||||||
if (targetRemoteType === undefined) {
|
|
||||||
const method = await dialogManager.openWithExplicitCancel(SetupRemote);
|
|
||||||
if (method === "cancelled") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
targetRemoteType =
|
|
||||||
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetRemoteType === REMOTE_MINIO) {
|
|
||||||
const bucketConf = await dialogManager.openWithExplicitCancel(SetupRemoteBucket, baseSettings);
|
|
||||||
if (bucketConf === "cancelled" || typeof bucketConf !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return { ...baseSettings, ...bucketConf, remoteType: REMOTE_MINIO };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetRemoteType === REMOTE_P2P) {
|
|
||||||
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings);
|
|
||||||
if (p2pConf === "cancelled" || typeof p2pConf !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
|
|
||||||
}
|
|
||||||
|
|
||||||
const couchConf = await dialogManager.openWithExplicitCancel<
|
|
||||||
SetupRemoteCouchDBResultType,
|
|
||||||
SetupRemoteCouchDBInitialData
|
|
||||||
>(SetupRemoteCouchDB, {
|
|
||||||
settings: baseSettings,
|
|
||||||
mode: "settings",
|
|
||||||
});
|
|
||||||
if (couchConf === "cancelled" || typeof couchConf !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB };
|
|
||||||
};
|
};
|
||||||
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
|
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
@@ -282,9 +213,9 @@ export function paneRemoteConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: RemoteConfigurationResult;
|
let parsed: BuiltInRemoteConfiguration;
|
||||||
try {
|
try {
|
||||||
parsed = ConnectionStringParser.parse(trimmedURI);
|
parsed = defaultRemoteProviderRegistry.parse(trimmedURI);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
|
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
|
||||||
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
|
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
|
||||||
@@ -302,7 +233,7 @@ export function paneRemoteConfig(
|
|||||||
configs[id] = {
|
configs[id] = {
|
||||||
id,
|
id,
|
||||||
name: name.trim() || defaultName,
|
name: name.trim() || defaultName,
|
||||||
uri: ConnectionStringParser.serialize(parsed),
|
uri: defaultRemoteProviderRegistry.serialise(parsed),
|
||||||
isEncrypted: false,
|
isEncrypted: false,
|
||||||
};
|
};
|
||||||
this.editingSettings.remoteConfigurations = configs;
|
this.editingSettings.remoteConfigurations = configs;
|
||||||
@@ -337,9 +268,9 @@ export function paneRemoteConfig(
|
|||||||
|
|
||||||
row.addButton((btn) =>
|
row.addButton((btn) =>
|
||||||
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
|
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
|
||||||
let parsed: RemoteConfigurationResult;
|
let parsed: BuiltInRemoteConfiguration;
|
||||||
try {
|
try {
|
||||||
parsed = ConnectionStringParser.parse(config.uri);
|
parsed = defaultRemoteProviderRegistry.parse(config.uri);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this.services.API.addLog(
|
this.services.API.addLog(
|
||||||
`Failed to parse remote configuration '${config.id}' for editing!`,
|
`Failed to parse remote configuration '${config.id}' for editing!`,
|
||||||
@@ -349,16 +280,9 @@ export function paneRemoteConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workSettings = createBaseRemoteSettings();
|
const workSettings = createBaseRemoteSettings();
|
||||||
if (parsed.type === "couchdb") {
|
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
|
||||||
workSettings.remoteType = REMOTE_COUCHDB;
|
|
||||||
} else if (parsed.type === "s3") {
|
|
||||||
workSettings.remoteType = REMOTE_MINIO;
|
|
||||||
} else {
|
|
||||||
workSettings.remoteType = REMOTE_P2P;
|
|
||||||
}
|
|
||||||
Object.assign(workSettings, parsed.settings);
|
|
||||||
|
|
||||||
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
|
const nextSettings = await runRemoteSetup(workSettings, parsed.type);
|
||||||
if (!nextSettings) {
|
if (!nextSettings) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -447,9 +371,9 @@ export function paneRemoteConfig(
|
|||||||
.addSeparator()
|
.addSeparator()
|
||||||
.addItem((item) => {
|
.addItem((item) => {
|
||||||
item.setTitle("📡 Fetch remote settings").onClick(async () => {
|
item.setTitle("📡 Fetch remote settings").onClick(async () => {
|
||||||
let parsed: RemoteConfigurationResult;
|
let parsed: BuiltInRemoteConfiguration;
|
||||||
try {
|
try {
|
||||||
parsed = ConnectionStringParser.parse(config.uri);
|
parsed = defaultRemoteProviderRegistry.parse(config.uri);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this.services.API.addLog(
|
this.services.API.addLog(
|
||||||
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
|
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
|
||||||
@@ -459,14 +383,7 @@ export function paneRemoteConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workSettings = createBaseRemoteSettings();
|
const workSettings = createBaseRemoteSettings();
|
||||||
if (parsed.type === "couchdb") {
|
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
|
||||||
workSettings.remoteType = REMOTE_COUCHDB;
|
|
||||||
} else if (parsed.type === "s3") {
|
|
||||||
workSettings.remoteType = REMOTE_MINIO;
|
|
||||||
} else {
|
|
||||||
workSettings.remoteType = REMOTE_P2P;
|
|
||||||
}
|
|
||||||
Object.assign(workSettings, parsed.settings);
|
|
||||||
const newTweaks =
|
const newTweaks =
|
||||||
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
|
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
|
||||||
workSettings
|
workSettings
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
type BucketSyncSetting,
|
|
||||||
type EncryptionSettings,
|
type EncryptionSettings,
|
||||||
type ObsidianLiveSyncSettings,
|
type ObsidianLiveSyncSettings,
|
||||||
type P2PSyncSetting,
|
|
||||||
LOG_LEVEL_NOTICE,
|
LOG_LEVEL_NOTICE,
|
||||||
LOG_LEVEL_VERBOSE,
|
LOG_LEVEL_VERBOSE,
|
||||||
REMOTE_COUCHDB,
|
|
||||||
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 "@vrtmrz/livesync-commonlib/settings";
|
||||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
import {
|
||||||
|
defaultRemoteProviderRegistry,
|
||||||
|
upsertRemoteConfigurationInPlace,
|
||||||
|
type BuiltInRemoteConfiguration,
|
||||||
|
} 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";
|
||||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||||
@@ -20,9 +20,6 @@ import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
|
|||||||
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
|
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
|
||||||
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
|
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
|
||||||
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
|
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
|
||||||
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
|
||||||
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
|
||||||
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
|
||||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||||
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||||
@@ -31,11 +28,8 @@ import type {
|
|||||||
OutroExistingUserResultType,
|
OutroExistingUserResultType,
|
||||||
OutroNewUserResultType,
|
OutroNewUserResultType,
|
||||||
ScanQRCodeResultType,
|
ScanQRCodeResultType,
|
||||||
SetupRemoteBucketResultType,
|
|
||||||
SetupRemoteCouchDBResultType,
|
|
||||||
SetupRemoteCouchDBInitialData,
|
|
||||||
SetupRemoteE2EEResultType,
|
SetupRemoteE2EEResultType,
|
||||||
SetupRemoteP2PResultType,
|
SetupRemoteInitialData,
|
||||||
SetupRemoteResultType,
|
SetupRemoteResultType,
|
||||||
UseSetupURIResultType,
|
UseSetupURIResultType,
|
||||||
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
||||||
@@ -44,6 +38,8 @@ import {
|
|||||||
applySettingsWithScheduledInitialisation,
|
applySettingsWithScheduledInitialisation,
|
||||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
||||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||||
|
import { builtInRemoteSetupRegistry } from "./SetupWizard/builtInRemoteSetupProviders.ts";
|
||||||
|
import type { RemoteSetupIntent } from "./SetupWizard/RemoteSetupRegistry.ts";
|
||||||
|
|
||||||
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||||
return {
|
return {
|
||||||
@@ -79,6 +75,8 @@ export const enum UserMode {
|
|||||||
* Setup Manager to handle onboarding and configuration setup
|
* Setup Manager to handle onboarding and configuration setup
|
||||||
*/
|
*/
|
||||||
export class SetupManager extends AbstractModule {
|
export class SetupManager extends AbstractModule {
|
||||||
|
readonly remoteSetupRegistry = builtInRemoteSetupRegistry;
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
// * Dialog manager for handling Svelte dialogs
|
// * Dialog manager for handling Svelte dialogs
|
||||||
// */
|
// */
|
||||||
@@ -157,6 +155,76 @@ export class SetupManager extends AbstractModule {
|
|||||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setupIntent(userMode: UserMode): RemoteSetupIntent {
|
||||||
|
if (userMode === UserMode.NewUser) return "create-or-connect";
|
||||||
|
if (userMode === UserMode.ExistingUser) return "connect-existing";
|
||||||
|
return "settings";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectRemoteSetupProvider(): Promise<BuiltInRemoteConfiguration["type"] | false> {
|
||||||
|
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType, SetupRemoteInitialData>(
|
||||||
|
SetupRemote,
|
||||||
|
this.remoteSetupRegistry.choices()
|
||||||
|
);
|
||||||
|
return method === "cancelled" ? false : method;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a provider-specific dialogue without applying or storing the resulting settings.
|
||||||
|
*
|
||||||
|
* The settings pane uses this boundary to prepare a remote profile in its own edit buffer.
|
||||||
|
*/
|
||||||
|
async configureRemoteForSettings(
|
||||||
|
currentSetting: ObsidianLiveSyncSettings,
|
||||||
|
type?: BuiltInRemoteConfiguration["type"]
|
||||||
|
): Promise<ObsidianLiveSyncSettings | false> {
|
||||||
|
const selectedType = type ?? (await this.selectRemoteSetupProvider());
|
||||||
|
if (selectedType === false) return false;
|
||||||
|
|
||||||
|
const configuration = await this.remoteSetupRegistry.open(selectedType, {
|
||||||
|
dialogManager: this.dialogManager,
|
||||||
|
intent: "settings",
|
||||||
|
settings: currentSetting,
|
||||||
|
});
|
||||||
|
if (configuration === "cancelled") return false;
|
||||||
|
|
||||||
|
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
|
||||||
|
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
|
||||||
|
return newSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onRemoteManualSetup(
|
||||||
|
type: BuiltInRemoteConfiguration["type"],
|
||||||
|
userMode: UserMode,
|
||||||
|
currentSetting: ObsidianLiveSyncSettings,
|
||||||
|
activate: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
const configuration = await this.remoteSetupRegistry.open(type, {
|
||||||
|
dialogManager: this.dialogManager,
|
||||||
|
intent: this.setupIntent(userMode),
|
||||||
|
settings: currentSetting,
|
||||||
|
});
|
||||||
|
if (configuration === "cancelled") {
|
||||||
|
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||||
|
return await this.onOnboard(userMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
|
||||||
|
if (activate) {
|
||||||
|
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
|
||||||
|
} else {
|
||||||
|
Object.assign(newSetting, configuration.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activateForP2P = defaultRemoteProviderRegistry.supportsActivationRole(type, "p2p");
|
||||||
|
upsertRemoteConfigurationInPlace(newSetting, type, {
|
||||||
|
id: activateForP2P ? newSetting.P2P_ActiveRemoteConfigurationId || undefined : undefined,
|
||||||
|
activate,
|
||||||
|
activateForP2P,
|
||||||
|
});
|
||||||
|
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles manual setup for CouchDB
|
* Handles manual setup for CouchDB
|
||||||
* @param userMode
|
* @param userMode
|
||||||
@@ -169,31 +237,7 @@ export class SetupManager extends AbstractModule {
|
|||||||
currentSetting: ObsidianLiveSyncSettings,
|
currentSetting: ObsidianLiveSyncSettings,
|
||||||
activate = true
|
activate = true
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const couchConf = await this.dialogManager.openWithExplicitCancel<
|
return await this.onRemoteManualSetup("couchdb", userMode, currentSetting, activate);
|
||||||
SetupRemoteCouchDBResultType,
|
|
||||||
SetupRemoteCouchDBInitialData
|
|
||||||
>(SetupRemoteCouchDB, {
|
|
||||||
settings: currentSetting,
|
|
||||||
mode:
|
|
||||||
userMode === UserMode.NewUser
|
|
||||||
? "create-or-connect"
|
|
||||||
: userMode === UserMode.ExistingUser
|
|
||||||
? "connect-existing"
|
|
||||||
: "settings",
|
|
||||||
});
|
|
||||||
if (couchConf === "cancelled") {
|
|
||||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
|
||||||
return await this.onOnboard(userMode);
|
|
||||||
}
|
|
||||||
const newSetting = {
|
|
||||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
|
||||||
...couchConf,
|
|
||||||
} as ObsidianLiveSyncSettings;
|
|
||||||
if (activate) {
|
|
||||||
newSetting.remoteType = REMOTE_COUCHDB;
|
|
||||||
}
|
|
||||||
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
|
|
||||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,23 +252,7 @@ export class SetupManager extends AbstractModule {
|
|||||||
currentSetting: ObsidianLiveSyncSettings,
|
currentSetting: ObsidianLiveSyncSettings,
|
||||||
activate = true
|
activate = true
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const bucketConf = await this.dialogManager.openWithExplicitCancel<
|
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
|
||||||
SetupRemoteBucketResultType,
|
|
||||||
BucketSyncSetting
|
|
||||||
>(SetupRemoteBucket, currentSetting);
|
|
||||||
if (bucketConf === "cancelled") {
|
|
||||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
|
||||||
return await this.onOnboard(userMode);
|
|
||||||
}
|
|
||||||
const newSetting = {
|
|
||||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
|
||||||
...bucketConf,
|
|
||||||
} as ObsidianLiveSyncSettings;
|
|
||||||
if (activate) {
|
|
||||||
newSetting.remoteType = REMOTE_MINIO;
|
|
||||||
}
|
|
||||||
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
|
|
||||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,24 +267,7 @@ export class SetupManager extends AbstractModule {
|
|||||||
currentSetting: ObsidianLiveSyncSettings,
|
currentSetting: ObsidianLiveSyncSettings,
|
||||||
activate = true
|
activate = true
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
return await this.onRemoteManualSetup("p2p", userMode, currentSetting, activate);
|
||||||
SetupRemoteP2P,
|
|
||||||
currentSetting
|
|
||||||
);
|
|
||||||
if (p2pConf === "cancelled") {
|
|
||||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
|
||||||
return await this.onOnboard(userMode);
|
|
||||||
}
|
|
||||||
const newSetting = {
|
|
||||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
|
||||||
...p2pConf,
|
|
||||||
} as ObsidianLiveSyncSettings;
|
|
||||||
upsertRemoteConfigurationInPlace(newSetting, "p2p", {
|
|
||||||
id: newSetting.P2P_ActiveRemoteConfigurationId || undefined,
|
|
||||||
activate,
|
|
||||||
activateForP2P: true,
|
|
||||||
});
|
|
||||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -310,21 +321,15 @@ export class SetupManager extends AbstractModule {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||||
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
|
const method = await this.selectRemoteSetupProvider();
|
||||||
if (method === "couchdb") {
|
if (method === false) {
|
||||||
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
|
|
||||||
} else if (method === "bucket") {
|
|
||||||
return await this.onBucketManualSetup(userMode, currentSetting, true);
|
|
||||||
} else if (method === "p2p") {
|
|
||||||
return await this.onP2PManualSetup(userMode, currentSetting, true);
|
|
||||||
} else if (method === "cancelled") {
|
|
||||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||||
if (userMode !== UserMode.Unknown) {
|
if (userMode !== UserMode.Unknown) {
|
||||||
return await this.onOnboard(userMode);
|
return await this.onOnboard(userMode);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
// Should not reach here.
|
return await this.onRemoteManualSetup(method, userMode, currentSetting, true);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Confirms and applies settings obtained from the wizard
|
* Confirms and applies settings obtained from the wizard
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -421,6 +422,40 @@ describe("SetupManager", () => {
|
|||||||
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
|
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the registered provider choices when configuring a Settings edit buffer", async () => {
|
||||||
|
const { manager, setting, dialogManager } = createSetupManager();
|
||||||
|
const bucketSettings = {
|
||||||
|
endpoint: "https://storage.example",
|
||||||
|
accessKey: "key",
|
||||||
|
secretKey: "secret",
|
||||||
|
bucket: "notes",
|
||||||
|
region: "auto",
|
||||||
|
bucketPrefix: "",
|
||||||
|
useCustomRequestHandler: false,
|
||||||
|
bucketCustomHeaders: "",
|
||||||
|
forcePathStyle: true,
|
||||||
|
};
|
||||||
|
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("s3").mockResolvedValueOnce(bucketSettings);
|
||||||
|
|
||||||
|
const nextSettings = await manager.configureRemoteForSettings(setting.currentSettings());
|
||||||
|
|
||||||
|
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.anything(),
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: "couchdb" }),
|
||||||
|
expect.objectContaining({ type: "s3" }),
|
||||||
|
expect.objectContaining({ type: "p2p" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.anything(),
|
||||||
|
setting.currentSettings()
|
||||||
|
);
|
||||||
|
expect(nextSettings).toEqual(expect.objectContaining({ ...bucketSettings, remoteType: REMOTE_MINIO }));
|
||||||
|
});
|
||||||
|
|
||||||
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
||||||
const { manager, setting, dialogManager } = createSetupManager();
|
const { manager, setting, dialogManager } = createSetupManager();
|
||||||
setting.settings = {
|
setting.settings = {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import type {
|
||||||
|
BuiltInRemoteConfiguration,
|
||||||
|
RemoteProviderConfiguration,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||||
|
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||||
|
|
||||||
|
export type RemoteSetupIntent = "create-or-connect" | "connect-existing" | "settings";
|
||||||
|
|
||||||
|
export interface RemoteSetupDialogManager {
|
||||||
|
openWithExplicitCancel<TResult, TInitial = TResult>(
|
||||||
|
component: ComponentHasResult<TResult, TInitial>,
|
||||||
|
initialData?: TInitial
|
||||||
|
): Promise<TResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSetupContext {
|
||||||
|
readonly dialogManager: RemoteSetupDialogManager;
|
||||||
|
readonly intent: RemoteSetupIntent;
|
||||||
|
readonly settings: ObsidianLiveSyncSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSetupChoice<TType extends string = string> {
|
||||||
|
readonly description: string;
|
||||||
|
readonly proceedTitle: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly type: TType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteSetupProviderDescriptor<
|
||||||
|
TConfiguration extends RemoteProviderConfiguration = RemoteProviderConfiguration,
|
||||||
|
> {
|
||||||
|
readonly type: TConfiguration["type"];
|
||||||
|
choice(): Omit<RemoteSetupChoice<TConfiguration["type"]>, "type">;
|
||||||
|
open(context: RemoteSetupContext): Promise<TConfiguration | "cancelled">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-side presentation for the remote providers compiled into this plug-in.
|
||||||
|
*
|
||||||
|
* Connection semantics remain in Commonlib's remote provider registry. This registry only owns
|
||||||
|
* the Obsidian-specific choice text and setup dialogue for each provider.
|
||||||
|
*/
|
||||||
|
export class RemoteSetupRegistry<TConfiguration extends RemoteProviderConfiguration = BuiltInRemoteConfiguration> {
|
||||||
|
private readonly providersByType = new Map<string, RemoteSetupProviderDescriptor<TConfiguration>>();
|
||||||
|
private frozen = false;
|
||||||
|
|
||||||
|
register<TProviderConfiguration extends TConfiguration>(
|
||||||
|
descriptor: RemoteSetupProviderDescriptor<TProviderConfiguration>
|
||||||
|
): this {
|
||||||
|
if (this.frozen) throw new Error("The remote setup registry is frozen");
|
||||||
|
if (this.providersByType.has(descriptor.type)) {
|
||||||
|
throw new Error(`Remote setup provider '${descriptor.type}' is already registered`);
|
||||||
|
}
|
||||||
|
this.providersByType.set(descriptor.type, descriptor);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
freeze(): this {
|
||||||
|
this.frozen = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFrozen(): boolean {
|
||||||
|
return this.frozen;
|
||||||
|
}
|
||||||
|
|
||||||
|
choices(): RemoteSetupChoice<TConfiguration["type"]>[] {
|
||||||
|
return [...this.providersByType.values()].map((provider) => ({
|
||||||
|
...provider.choice(),
|
||||||
|
type: provider.type,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
has(type: string): type is TConfiguration["type"] {
|
||||||
|
return this.providersByType.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(type: TConfiguration["type"], context: RemoteSetupContext): Promise<TConfiguration | "cancelled"> {
|
||||||
|
const provider = this.providersByType.get(type);
|
||||||
|
if (!provider) throw new Error(`Unsupported remote setup provider: ${type}`);
|
||||||
|
return provider.open(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
|
||||||
|
|
||||||
|
type TestConfiguration =
|
||||||
|
| { type: "alpha"; settings: { value: string } }
|
||||||
|
| { type: "beta"; settings: { enabled: boolean } };
|
||||||
|
|
||||||
|
function alphaProvider(
|
||||||
|
open: RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>>["open"] = async () => ({
|
||||||
|
type: "alpha",
|
||||||
|
settings: { value: "configured" },
|
||||||
|
})
|
||||||
|
): RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>> {
|
||||||
|
return {
|
||||||
|
type: "alpha",
|
||||||
|
choice: () => ({
|
||||||
|
title: "Alpha",
|
||||||
|
description: "Alpha description",
|
||||||
|
proceedTitle: "Configure Alpha",
|
||||||
|
}),
|
||||||
|
open,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RemoteSetupRegistry", () => {
|
||||||
|
it("preserves registration order and dispatches setup through the selected provider", async () => {
|
||||||
|
const openAlpha = vi.fn(alphaProvider().open);
|
||||||
|
const registry = new RemoteSetupRegistry<TestConfiguration>()
|
||||||
|
.register(alphaProvider(openAlpha))
|
||||||
|
.register({
|
||||||
|
type: "beta",
|
||||||
|
choice: () => ({
|
||||||
|
title: "Beta",
|
||||||
|
description: "Beta description",
|
||||||
|
proceedTitle: "Configure Beta",
|
||||||
|
}),
|
||||||
|
open: async () => ({ type: "beta", settings: { enabled: true } }),
|
||||||
|
})
|
||||||
|
.freeze();
|
||||||
|
const context = {
|
||||||
|
dialogManager: { openWithExplicitCancel: vi.fn() },
|
||||||
|
intent: "settings" as const,
|
||||||
|
settings: {} as never,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(registry.choices().map((choice) => choice.type)).toEqual(["alpha", "beta"]);
|
||||||
|
await expect(registry.open("alpha", context)).resolves.toEqual({
|
||||||
|
type: "alpha",
|
||||||
|
settings: { value: "configured" },
|
||||||
|
});
|
||||||
|
expect(openAlpha).toHaveBeenCalledWith(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate and late registrations", () => {
|
||||||
|
const registry = new RemoteSetupRegistry<TestConfiguration>().register(alphaProvider());
|
||||||
|
|
||||||
|
expect(() => registry.register(alphaProvider())).toThrow("already registered");
|
||||||
|
|
||||||
|
registry.freeze();
|
||||||
|
expect(registry.isFrozen()).toBe(true);
|
||||||
|
expect(() =>
|
||||||
|
registry.register({
|
||||||
|
type: "beta",
|
||||||
|
choice: () => ({ title: "Beta", description: "", proceedTitle: "" }),
|
||||||
|
open: async () => "cancelled",
|
||||||
|
})
|
||||||
|
).toThrow("frozen");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { BucketSyncSetting, P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||||
|
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||||
|
import { $msg as translateMessage } from "@/common/translation";
|
||||||
|
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
|
||||||
|
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
|
||||||
|
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
|
||||||
|
import type {
|
||||||
|
SetupRemoteBucketResultType,
|
||||||
|
SetupRemoteCouchDBInitialData,
|
||||||
|
SetupRemoteCouchDBResultType,
|
||||||
|
SetupRemoteP2PResultType,
|
||||||
|
} from "./dialogs/setupDialogTypes";
|
||||||
|
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
|
||||||
|
|
||||||
|
type ConfigurationOf<TType extends BuiltInRemoteConfiguration["type"]> = Extract<
|
||||||
|
BuiltInRemoteConfiguration,
|
||||||
|
{ type: TType }
|
||||||
|
>;
|
||||||
|
|
||||||
|
function assertSemanticProvider(type: BuiltInRemoteConfiguration["type"]): void {
|
||||||
|
const isRegistered = defaultRemoteProviderRegistry.providerSummaries().some((provider) => provider.type === type);
|
||||||
|
if (!isRegistered) throw new Error(`Remote setup provider '${type}' has no Commonlib provider`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCouchDBRemoteSetup(
|
||||||
|
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||||
|
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||||
|
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"couchdb">> = {
|
||||||
|
type: "couchdb",
|
||||||
|
choice: () => ({
|
||||||
|
title: "CouchDB",
|
||||||
|
description: translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc"),
|
||||||
|
proceedTitle: translateMessage("Continue to CouchDB setup"),
|
||||||
|
}),
|
||||||
|
open: async ({ dialogManager, intent, settings }) => {
|
||||||
|
const result = await dialogManager.openWithExplicitCancel<
|
||||||
|
SetupRemoteCouchDBResultType,
|
||||||
|
SetupRemoteCouchDBInitialData
|
||||||
|
>(SetupRemoteCouchDB, { settings, mode: intent });
|
||||||
|
return result === "cancelled" ? result : { type: "couchdb", settings: result };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertSemanticProvider(descriptor.type);
|
||||||
|
return registry.register(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useS3RemoteSetup(
|
||||||
|
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||||
|
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||||
|
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"s3">> = {
|
||||||
|
type: "s3",
|
||||||
|
choice: () => ({
|
||||||
|
title: translateMessage("Ui.SetupWizard.SetupRemote.BucketOption"),
|
||||||
|
description: translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc"),
|
||||||
|
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket"),
|
||||||
|
}),
|
||||||
|
open: async ({ dialogManager, settings }) => {
|
||||||
|
const result = await dialogManager.openWithExplicitCancel<SetupRemoteBucketResultType, BucketSyncSetting>(
|
||||||
|
SetupRemoteBucket,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
return result === "cancelled" ? result : { type: "s3", settings: result };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertSemanticProvider(descriptor.type);
|
||||||
|
return registry.register(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useP2PRemoteSetup(
|
||||||
|
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||||
|
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||||
|
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"p2p">> = {
|
||||||
|
type: "p2p",
|
||||||
|
choice: () => ({
|
||||||
|
title: translateMessage("Ui.SetupWizard.SetupRemote.P2POption"),
|
||||||
|
description: translateMessage(
|
||||||
|
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
|
||||||
|
),
|
||||||
|
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P"),
|
||||||
|
}),
|
||||||
|
open: async ({ dialogManager, settings }) => {
|
||||||
|
const result = await dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
||||||
|
SetupRemoteP2P,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
return result === "cancelled" ? result : { type: "p2p", settings: result };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertSemanticProvider(descriptor.type);
|
||||||
|
return registry.register(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||||
|
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
|
||||||
|
useCouchDBRemoteSetup(registry);
|
||||||
|
useS3RemoteSetup(registry);
|
||||||
|
useP2PRemoteSetup(registry);
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const builtInRemoteSetupRegistry = createBuiltInRemoteSetupRegistry().freeze();
|
||||||
@@ -7,58 +7,33 @@
|
|||||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||||
import { $msg as translateMessage } from "@/common/translation";
|
import { $msg as translateMessage } from "@/common/translation";
|
||||||
import {
|
import { onMount } from "svelte";
|
||||||
TYPE_COUCHDB,
|
import { type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||||
TYPE_BUCKET,
|
import { TYPE_CANCELLED, type SetupRemoteInitialData, type SetupRemoteResultType } from "./setupDialogTypes";
|
||||||
TYPE_P2P,
|
|
||||||
TYPE_CANCELLED,
|
|
||||||
type SetupRemoteResultType,
|
|
||||||
} from "./setupDialogTypes";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = GuestDialogProps<SetupRemoteResultType, SetupRemoteInitialData>;
|
||||||
setResult: (result: SetupRemoteResultType) => void;
|
const { setResult, getInitialData }: Props = $props();
|
||||||
};
|
let choices = $state<SetupRemoteInitialData>([]);
|
||||||
const { setResult }: Props = $props();
|
|
||||||
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
|
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
|
||||||
let proceedTitle = $derived.by(() => {
|
onMount(() => {
|
||||||
if (userType === TYPE_COUCHDB) {
|
choices = getInitialData?.() ?? [];
|
||||||
return translateMessage("Continue to CouchDB setup");
|
|
||||||
} else if (userType === TYPE_BUCKET) {
|
|
||||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
|
|
||||||
} else if (userType === TYPE_P2P) {
|
|
||||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
|
|
||||||
} else {
|
|
||||||
return translateMessage("Please select an option to proceed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const canProceed = $derived.by(() => {
|
|
||||||
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
|
|
||||||
});
|
});
|
||||||
|
const selectedChoice = $derived(choices.find((choice) => choice.type === userType));
|
||||||
|
const proceedTitle = $derived(
|
||||||
|
selectedChoice?.proceedTitle ?? translateMessage("Please select an option to proceed")
|
||||||
|
);
|
||||||
|
const canProceed = $derived(selectedChoice !== undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
|
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
|
||||||
<Instruction>
|
<Instruction>
|
||||||
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
|
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
|
||||||
<Options>
|
<Options>
|
||||||
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
|
{#each choices as choice (choice.type)}
|
||||||
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
|
<Option selectedValue={choice.type} title={choice.title} bind:value={userType}>
|
||||||
</Option>
|
{choice.description}
|
||||||
<Option
|
</Option>
|
||||||
selectedValue={TYPE_BUCKET}
|
{/each}
|
||||||
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
|
|
||||||
bind:value={userType}
|
|
||||||
>
|
|
||||||
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
|
|
||||||
</Option>
|
|
||||||
<Option
|
|
||||||
selectedValue={TYPE_P2P}
|
|
||||||
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
|
|
||||||
bind:value={userType}
|
|
||||||
>
|
|
||||||
{translateMessage(
|
|
||||||
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
|
|
||||||
)}
|
|
||||||
</Option>
|
|
||||||
</Options>
|
</Options>
|
||||||
</Instruction>
|
</Instruction>
|
||||||
<UserDecisions>
|
<UserDecisions>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
ObsidianLiveSyncSettings,
|
ObsidianLiveSyncSettings,
|
||||||
P2PConnectionInfo,
|
P2PConnectionInfo,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||||
|
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||||
|
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
|
||||||
|
|
||||||
export const TYPE_IDENTICAL = "identical";
|
export const TYPE_IDENTICAL = "identical";
|
||||||
export const TYPE_INDEPENDENT = "independent";
|
export const TYPE_INDEPENDENT = "independent";
|
||||||
@@ -38,7 +40,7 @@ export const TYPE_CLOSE = "close";
|
|||||||
|
|
||||||
// SetupRemote
|
// SetupRemote
|
||||||
export const TYPE_COUCHDB = "couchdb";
|
export const TYPE_COUCHDB = "couchdb";
|
||||||
export const TYPE_BUCKET = "bucket";
|
export const TYPE_BUCKET = "s3";
|
||||||
export const TYPE_P2P = "p2p";
|
export const TYPE_P2P = "p2p";
|
||||||
|
|
||||||
export type ResultTypeVault =
|
export type ResultTypeVault =
|
||||||
@@ -93,7 +95,8 @@ export type SelectMethodExistingResultType =
|
|||||||
| typeof TYPE_CONFIGURE_MANUALLY
|
| typeof TYPE_CONFIGURE_MANUALLY
|
||||||
| typeof TYPE_CANCELLED;
|
| typeof TYPE_CANCELLED;
|
||||||
|
|
||||||
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
|
export type SetupRemoteResultType = BuiltInRemoteConfiguration["type"] | typeof TYPE_CANCELLED;
|
||||||
|
export type SetupRemoteInitialData = RemoteSetupChoice<BuiltInRemoteConfiguration["type"]>[];
|
||||||
|
|
||||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||||
|
|
||||||
|
|||||||
@@ -147,14 +147,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
|||||||
: req instanceof Request && typeof req.method === "string"
|
: req instanceof Request && typeof req.method === "string"
|
||||||
? req.method
|
? req.method
|
||||||
: "GET";
|
: "GET";
|
||||||
if (typeof req !== "string") {
|
const suppliedBody = opts?.body ?? (req instanceof Request ? req.body : undefined);
|
||||||
if (opts?.body) {
|
if (suppliedBody !== undefined && suppliedBody !== null) {
|
||||||
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
|
body = typeof suppliedBody === "string" ? suppliedBody : await new Response(suppliedBody).arrayBuffer();
|
||||||
} else if (req.body) {
|
|
||||||
body = await new Response(req.body).arrayBuffer();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body = opts?.body as string;
|
|
||||||
}
|
}
|
||||||
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
|
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
|
||||||
|
|
||||||
@@ -192,7 +187,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
|||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
};
|
};
|
||||||
const r = await requestUrl({ ...requestParam, throw: false });
|
const r = await requestUrl({ ...requestParam, throw: false });
|
||||||
return new Response(r.arrayBuffer, {
|
const responseHasNoBody =
|
||||||
|
method.toUpperCase() === "HEAD" || r.status === 204 || r.status === 205 || r.status === 304;
|
||||||
|
return new Response(responseHasNoBody ? null : r.arrayBuffer, {
|
||||||
headers: r.headers,
|
headers: r.headers,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
statusText: `${r.status}`,
|
statusText: `${r.status}`,
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ const mocks = vi.hoisted(() => ({
|
|||||||
platform: {
|
platform: {
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
},
|
},
|
||||||
|
requestUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/deps.ts", () => ({
|
vi.mock("@/deps.ts", () => ({
|
||||||
Platform: mocks.platform,
|
Platform: mocks.platform,
|
||||||
requestUrl: vi.fn(),
|
requestUrl: mocks.requestUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/deps", () => ({
|
vi.mock("@/deps", () => ({
|
||||||
Platform: mocks.platform,
|
Platform: mocks.platform,
|
||||||
requestUrl: vi.fn(),
|
requestUrl: mocks.requestUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||||
@@ -65,3 +66,41 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
|
|||||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ObsidianAPIService.nativeFetch", () => {
|
||||||
|
it("normalises a typed-array body when the request URL is a string", async () => {
|
||||||
|
mocks.requestUrl.mockResolvedValue({
|
||||||
|
arrayBuffer: new Uint8Array([9, 8, 7]).buffer,
|
||||||
|
headers: { etag: '"created"' },
|
||||||
|
status: 201,
|
||||||
|
});
|
||||||
|
const source = new Uint8Array([0, 1, 2, 3, 4]);
|
||||||
|
const body = source.subarray(1, 4);
|
||||||
|
|
||||||
|
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
|
||||||
|
body: body as unknown as BodyInit,
|
||||||
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
const request = mocks.requestUrl.mock.calls[0][0] as { body?: unknown };
|
||||||
|
expect(request.body).toBeInstanceOf(ArrayBuffer);
|
||||||
|
expect([...new Uint8Array(request.body as ArrayBuffer)]).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs a bodyless response for a successful DELETE", async () => {
|
||||||
|
mocks.requestUrl.mockResolvedValue({
|
||||||
|
arrayBuffer: new ArrayBuffer(0),
|
||||||
|
headers: {},
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user