Refine P2P and manual setup workflows

This commit is contained in:
vorotamoroz
2026-07-23 15:23:47 +00:00
parent cd35858e01
commit 1df034f12a
60 changed files with 1884 additions and 770 deletions
@@ -29,6 +29,10 @@ import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svel
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";
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
@@ -216,7 +220,13 @@ export function paneRemoteConfig(
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
}
const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings);
const couchConf = await dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData
>(SetupRemoteCouchDB, {
settings: baseSettings,
mode: "settings",
});
if (couchConf === "cancelled" || typeof couchConf !== "object") {
return false;
}
+11 -3
View File
@@ -1,6 +1,5 @@
import {
type BucketSyncSetting,
type CouchDBConnection,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
@@ -34,6 +33,7 @@ import type {
ScanQRCodeResultType,
SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemoteResultType,
@@ -171,8 +171,16 @@ export class SetupManager extends AbstractModule {
): Promise<boolean> {
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
CouchDBConnection
>(SetupRemoteCouchDB, currentSetting);
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);
@@ -348,6 +348,39 @@ describe("SetupManager", () => {
expect(activeProfile?.uri).toContain("sls+https://alice:secret@couch.example");
});
it.each([
[UserMode.NewUser, "create-or-connect"],
[UserMode.ExistingUser, "connect-existing"],
[UserMode.Update, "settings"],
] as const)(
"passes the %s CouchDB database policy to the manual setup dialogue",
async (userMode, expectedMode) => {
const { manager, setting, dialogManager } = createSetupManager();
const couchConf = {
couchDB_URI: "https://couch.example",
couchDB_USER: "alice",
couchDB_PASSWORD: "secret",
couchDB_DBNAME: "notes",
couchDB_CustomHeaders: "",
useJWT: false,
jwtAlgorithm: "",
jwtKey: "",
jwtKid: "",
jwtSub: "",
jwtExpDuration: 5,
useRequestAPI: false,
};
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(couchConf).mockResolvedValueOnce("cancelled");
await manager.onCouchDBManualSetup(userMode, setting.currentSettings());
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(1, expect.anything(), {
settings: setting.currentSettings(),
mode: expectedMode,
});
}
);
it("adds and activates a manually configured Object Storage profile without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
@@ -1,6 +1,7 @@
<script lang="ts">
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
@@ -8,6 +9,7 @@
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
type Props = {
setResult: (result: IntroResultType) => void;
@@ -30,6 +32,11 @@
<DialogHeader title="Welcome to Self-hosted LiveSync" />
<Guidance>We will now guide you through a few questions to simplify the synchronisation setup.</Guidance>
<InfoNote>
{translateMessage(
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI."
)}
</InfoNote>
<Instruction>
<Question>First, please select the option that best describes your current situation.</Question>
<Options>
@@ -5,32 +5,66 @@
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: OutroExistingUserResultType) => void;
getInitialData?: () => { isP2P?: boolean } | undefined;
};
const { setResult }: Props = $props();
const { setResult, getInitialData }: Props = $props();
const isP2P = $derived(getInitialData?.()?.isP2P === true);
</script>
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the latest synchronisation data will be downloaded from the server to this device.</strong
>
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
files in this vault, conflicts may occur with the server data.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{#if isP2P}
<DialogHeader title={translateMessage("Setup Complete: Preparing to Fetch from Another Device")} />
<Guidance>
<p>
{translateMessage(
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device."
)}
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
{translateMessage(
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data."
)}
</p>
</Guidance>
<Instruction>
<Question>
{translateMessage("Restart this device, then choose the source device when P2P Rebuild opens.")}
</Question>
</Instruction>
<UserDecisions>
<Decision
title={translateMessage("Restart and Select Source Device")}
important={true}
commit={() => setResult(TYPE_APPLY)}
/>
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{:else}
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the latest synchronisation data will be downloaded from the server to this device.</strong
>
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
files in this vault, conflicts may occur with the server data.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>
{/if}
@@ -7,10 +7,14 @@
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
import { $msg as translateMessage } from "@/common/translation";
import { getDialogContext } from "@/modules/services/LiveSyncUI/svelteDialog";
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
type Props = {
trialRemoteSetting: ObsidianLiveSyncSettings;
};
const { trialRemoteSetting }: Props = $props();
const context = getDialogContext();
let detectedIssues = $state<ConfigCheckResult[]>([]);
async function testAndFixSettings() {
detectedIssues = [];
@@ -33,14 +37,23 @@
}
let processing = $state(false);
async function fixIssue(issue: ResultError<unknown>) {
const confirmation = getCouchDBServerFixConfirmation(issue.settingKey, issue.expectedValue);
const confirmed = await context.services.confirm.askYesNoDialog(confirmation.message, {
title: confirmation.title,
defaultOption: "No",
});
if (confirmed !== "yes") {
return;
}
try {
processing = true;
await issue.fix();
} catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-fix");
} finally {
await testAndFixSettings();
processing = false;
}
await testAndFixSettings();
processing = false;
}
const errorIssueCount = $derived.by(() => {
return detectedIssues.filter((issue) => isErrorResult(issue)).length;
@@ -64,7 +77,7 @@
</div>
{/snippet}
<UserDecisions>
<Decision title="Detect and Fix CouchDB Issues" important={true} commit={testAndFixSettings} />
<Decision title={translateMessage("Check server requirements")} important={true} commit={testAndFixSettings} />
</UserDecisions>
<div class="check-results">
<details open={!isAllSuccess}>
@@ -7,6 +7,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_USE_SETUP_URI,
TYPE_CONFIGURE_MANUALLY,
@@ -49,7 +50,10 @@
>
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong>
instead of a CouchDB/S3 server — P2P requires no server setup at all.
instead of a CouchDB/S3 server.
{translateMessage(
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery."
)}
</Option>
</Options>
</Instruction>
@@ -6,6 +6,7 @@
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
import { $msg as translateMessage } from "@/common/translation";
import {
TYPE_COUCHDB,
TYPE_BUCKET,
@@ -47,9 +48,9 @@
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
</Option>
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
This feature enables direct synchronisation between devices. No server is required, but both devices must be
online at the same time for synchronisation to occur, and some features may be limited. Internet connection
is only required to signalling (detecting peers) and not for data transfer.
{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>
</Instruction>
@@ -21,18 +21,27 @@
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { copyTo, pickCouchDBSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
import { TYPE_CANCELLED, type SetupRemoteCouchDBResultType } from "./setupDialogTypes";
import {
TYPE_CANCELLED,
type CouchDBSetupMode,
type SetupRemoteCouchDBInitialData,
type SetupRemoteCouchDBResultType,
} from "./setupDialogTypes";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
import { $msg as translateMessage } from "@/common/translation";
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, CouchDBConnection>;
let setupMode = $state<CouchDBSetupMode>("settings");
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, SetupRemoteCouchDBInitialData>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, syncSetting);
setupMode = initialData.mode;
copyTo(initialData.settings, syncSetting);
}
}
});
@@ -69,11 +78,15 @@
return "Failed to create replicator instance.";
}
try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
if (result) {
const result = await probeCouchDBConnection(
replicator,
trialRemoteSetting,
setupMode === "create-or-connect"
);
if (result.ok) {
return "";
} else {
return "Failed to connect to the server. Please check your settings.";
return `Failed to connect to the server: ${result.reason}`;
}
} catch (e) {
return `Failed to connect to the server: ${e}`;
@@ -122,7 +135,7 @@
});
const canProceed = $derived.by(() => {
return (
syncSetting.couchDB_URI.trim().length > 0 &&
isValidCouchDBServerURL(syncSetting.couchDB_URI.trim()) &&
syncSetting.couchDB_USER.trim().length > 0 &&
syncSetting.couchDB_PASSWORD.trim().length > 0 &&
syncSetting.couchDB_DBNAME.trim().length > 0 &&
@@ -132,6 +145,18 @@
const testSettings = $derived.by(() => {
return generateSetting();
});
const isURLInvalid = $derived.by(
() => syncSetting.couchDB_URI.trim() !== "" && !isValidCouchDBServerURL(syncSetting.couchDB_URI.trim())
);
const primaryActionTitle = $derived.by(() => {
if (setupMode === "create-or-connect") {
return translateMessage("Create or connect to database and continue");
}
if (setupMode === "connect-existing") {
return translateMessage("Connect to existing database and continue");
}
return translateMessage("Test connection and save");
});
</script>
<DialogHeader title="CouchDB Configuration" />
@@ -150,6 +175,7 @@
/>
</InputRow>
<InfoNote warning visible={isURIInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
<InfoNote warning visible={isURLInvalid}>{translateMessage("Enter a complete HTTP or HTTPS URL.")}</InfoNote>
<InputRow label="Username">
<input
type="text"
@@ -180,13 +206,11 @@
autocapitalize="off"
spellcheck="false"
required
pattern="^[a-z][a-z0-9_$()+/-]*$"
bind:value={syncSetting.couchDB_DBNAME}
/>
</InputRow>
<InfoNote>
You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an
underscore (_).
{translateMessage("CouchDB validates the database name when you connect. The name must not be empty.")}
</InfoNote>
<InputRow label="Use Internal API">
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
@@ -270,6 +294,11 @@
</InfoNote>
</ExtraItems>
<InfoNote warning>
{translateMessage(
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required."
)}
</InfoNote>
<PanelCouchDBCheck trialRemoteSetting={testSettings}></PanelCouchDBCheck>
<hr />
@@ -281,8 +310,19 @@
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" commit={() => commit()} />
<Decision title={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
)}
</InfoNote>
<Decision
title={translateMessage("Save without connecting")}
disabled={!canProceed}
commit={() => commit()}
/>
{/if}
<Decision title="Cancel" commit={() => cancel()} />
</UserDecisions>
{/if}
@@ -33,6 +33,8 @@
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
import { $msg as translateMessage } from "@/common/translation";
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
@@ -119,29 +121,15 @@
};
const replicator = new TrysteroReplicator(env);
try {
await replicator.setOnSetup();
await replicator.allowReconnection();
await replicator.open();
for (let i = 0; i < 10; i++) {
// await delay(1000);
await new Promise((resolve) => window.setTimeout(resolve, 1000));
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
if (replicator.knownAdvertisements.length > 0) {
break;
}
}
// context.holdingSettings = trialRemoteSetting;
if (replicator.knownAdvertisements.length === 0) {
return "Your settings seem correct, but no other peers were found.";
const result = await probeP2PSetupConnection(replicator);
if (!result.ok) {
return `Failed to connect to the signalling relay: ${result.reason}`;
}
return "";
} catch (e) {
return `Failed to connect to other peers: ${e}`;
} finally {
try {
replicator.close();
dummyPouch.destroy();
await replicator.close();
await dummyPouch.destroy();
} catch (e) {
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup");
}
@@ -195,18 +183,31 @@
<InputRow label="Enabled">
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
</InputRow>
<InputRow label="Relay URL">
<InputRow label={translateMessage("Signalling relay URLs")}>
<input
type="text"
name="p2p-relay-url"
placeholder="Enter the Relay URL)"
placeholder="wss://relay.example.com"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_relays}
/>
<button class="button" onclick={() => setDefaultRelay()}>Use vrtmrz's relay</button>
<button class="button" onclick={() => setDefaultRelay()}>
{translateMessage("Use the project's public signalling relay")}
</button>
</InputRow>
<InfoNote>
{translateMessage("Peer discovery uses Nostr-compatible signalling relays.")}
{translateMessage(
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay."
)}
<a
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md"
target="_blank"
rel="noopener noreferrer">{translateMessage("Learn more about P2P connections")}</a
>.
</InfoNote>
<InputRow label="Group ID">
<input
type="text"
@@ -245,12 +246,13 @@
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
launches.
</InfoNote>
<InputRow label="Auto Broadcast Changes">
<InputRow label={translateMessage("Announce changes automatically after connecting")}>
<input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
</InputRow>
<InfoNote>
If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without
requiring manual intervention. This requests peers to fetch this device's changes.
{translateMessage(
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection."
)}
</InfoNote>
<ExtraItems title="Advanced Settings">
<InfoNote>
@@ -258,10 +260,14 @@
connections. In most cases, you can leave these fields blank.
</InfoNote>
<InfoNote warning>
Using public TURN servers may have privacy implications, as your data will be relayed through third-party
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN
server provider before using their services. Also your `network administrator` too. You should consider setting
up your own TURN server for your FQDN, if possible.
{translateMessage(
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust."
)}
<a
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md#signalling-relay-and-turn-server"
target="_blank"
rel="noopener noreferrer">{translateMessage("Learn more about signalling and TURN")}</a
>.
</InfoNote>
<InputRow label="TURN Server URLs (comma-separated)">
<textarea
@@ -0,0 +1,63 @@
import type {
ObsidianLiveSyncSettings,
RemoteDBSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: string };
type CouchDBConnectionResult =
| string
| {
db: unknown;
info: unknown;
};
export interface CouchDBConnectionProbe {
isMobile(): boolean;
connectRemoteCouchDBWithSetting(
settings: RemoteDBSettings,
isMobile: boolean,
performSetup: boolean,
skipInfo: boolean
): CouchDBConnectionResult | Promise<CouchDBConnectionResult>;
}
export function isCouchDBConnectionProbe(value: unknown): value is CouchDBConnectionProbe {
return (
typeof value === "object" &&
value !== null &&
"isMobile" in value &&
typeof value.isMobile === "function" &&
"connectRemoteCouchDBWithSetting" in value &&
typeof value.connectRemoteCouchDBWithSetting === "function"
);
}
export async function probeCouchDBConnection(
replicator: unknown,
settings: ObsidianLiveSyncSettings,
createIfMissing: boolean
): Promise<CouchDBConnectionProbeResult> {
if (!isCouchDBConnectionProbe(replicator)) {
return { ok: false, reason: "The CouchDB connection probe is unavailable." };
}
const result = await replicator.connectRemoteCouchDBWithSetting(
settings,
replicator.isMobile(),
createIfMissing,
false
);
if (typeof result === "string") {
return { ok: false, reason: result };
}
return { ok: true };
}
export function isValidCouchDBServerURL(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
} catch {
return false;
}
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
const settings = {
couchDB_URI: "https://couch.example",
couchDB_DBNAME: "notes",
} as ObsidianLiveSyncSettings;
describe("CouchDB setup connection policy", () => {
it.each([
[false, "connect to an existing database"],
[true, "create or connect to a database"],
] as const)(
"%s can %s without changing the Commonlib connection contract",
async (createIfMissing, _description) => {
const connectRemoteCouchDBWithSetting = vi.fn(async () => ({
db: {},
info: { db_name: "notes" },
}));
const replicator = {
isMobile: vi.fn(() => false),
connectRemoteCouchDBWithSetting,
tryConnectRemote: vi.fn(),
};
await expect(probeCouchDBConnection(replicator, settings, createIfMissing)).resolves.toEqual({ ok: true });
expect(connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(settings, false, createIfMissing, false);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
}
);
it("returns the connection error without saving or creating through another path", async () => {
const replicator = {
isMobile: vi.fn(() => true),
connectRemoteCouchDBWithSetting: vi.fn(() => "database does not exist"),
};
await expect(probeCouchDBConnection(replicator, settings, false)).resolves.toEqual({
ok: false,
reason: "database does not exist",
});
});
it.each([
["https://couch.example", true],
["http://127.0.0.1:5984", true],
["ftp://couch.example", false],
["couch.example", false],
["https://", false],
])("validates the saved server URL %s", (value, expected) => {
expect(isValidCouchDBServerURL(value)).toBe(expected);
});
});
@@ -0,0 +1,11 @@
import { $msg } from "@/common/translation";
export function getCouchDBServerFixConfirmation(settingKey: string, expectedValue: string) {
return {
title: $msg("Change CouchDB server setting"),
message: $msg("Change CouchDB server setting '${SETTING}' to '${VALUE}'?", {
SETTING: settingKey,
VALUE: expectedValue,
}),
};
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
describe("CouchDB server requirement fixes", () => {
it("identifies the exact server setting and value before a fix is applied", () => {
expect(getCouchDBServerFixConfirmation("chttpd/require_valid_user", "true")).toEqual({
title: "Change CouchDB server setting",
message: "Change CouchDB server setting 'chttpd/require_valid_user' to 'true'?",
});
});
});
@@ -0,0 +1,23 @@
export type P2PSetupConnectionProbeResult = { ok: true } | { ok: false; reason: string };
export interface P2PSetupConnectionProbe {
setOnSetup(): void | Promise<void>;
allowReconnection(): void | Promise<void>;
open(): Promise<void>;
}
export async function probeP2PSetupConnection(
replicator: P2PSetupConnectionProbe
): Promise<P2PSetupConnectionProbeResult> {
try {
await replicator.setOnSetup();
await replicator.allowReconnection();
await replicator.open();
return { ok: true };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
describe("P2P setup connection probe", () => {
it("accepts an empty room after the signalling connection opens", async () => {
const replicator = {
knownAdvertisements: [],
setOnSetup: vi.fn(),
allowReconnection: vi.fn(),
open: vi.fn(async () => undefined),
};
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({ ok: true });
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
expect(replicator.allowReconnection).toHaveBeenCalledOnce();
expect(replicator.open).toHaveBeenCalledOnce();
});
it("reports a signalling connection failure", async () => {
const replicator = {
knownAdvertisements: [],
setOnSetup: vi.fn(),
allowReconnection: vi.fn(),
open: vi.fn(async () => {
throw new Error("relay unavailable");
}),
};
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({
ok: false,
reason: "relay unavailable",
});
});
});
@@ -102,6 +102,11 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
export type SetupRemoteCouchDBInitialData = {
settings: CouchDBConnection;
mode: CouchDBSetupMode;
};
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
@@ -12,7 +12,15 @@ import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
export type ResultMessage = { message: string; classes: string[] };
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
export type ResultOk<T> = { message: string; result: "ok"; value?: T };
export type ResultError<T> = { message: string; result: "error"; value: T; fixMessage: string; fix(): Promise<void> };
export type ResultError<T> = {
message: string;
result: "error";
value: T;
fixMessage: string;
settingKey: string;
expectedValue: string;
fix(): Promise<void>;
};
export type ConfigCheckResult<T = unknown, U = unknown> =
| ResultOk<T>
| ResultError<U>
@@ -79,8 +87,15 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
const addSuccess = <T>(msg: string, value?: T) => {
result.push({ message: msg, result: "ok", value });
};
const _addError = <T>(message: string, fixMessage: string, fix: () => Promise<void>, value?: T) => {
result.push({ message, result: "error", fixMessage, fix, value });
const _addError = <T>(
message: string,
fixMessage: string,
settingKey: string,
expectedValue: string,
fix: () => Promise<void>,
value?: T
) => {
result.push({ message, result: "error", fixMessage, settingKey, expectedValue, fix, value });
};
const addErrorMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, result: "error", classes });
@@ -90,6 +105,8 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
_addError(
message,
fixMessage,
key,
expected,
async () => {
await updateRemoteSetting(editingSettings, key, expected);
},