From 76318944a4dcf5fa6d46163c3e71cfa89104d928 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 30 Aug 2026 16:16:17 +0000 Subject: [PATCH] Route P2P Setup probes through the room owner --- src/common/messages/combinedMessages.prod.ts | 4 + src/common/messagesJson/en.json | 1 + src/common/messagesYAML/en.yaml | 1 + .../P2PReplicator/P2PServerStatusPane.svelte | 4 +- src/main.ts | 1 + .../SettingDialogue/PaneRemoteConfig.ts | 3 +- src/modules/features/SetupManager.ts | 29 +++- .../features/SetupManager.unit.spec.ts | 32 +++- .../SetupWizard/dialogs/SetupRemoteP2P.svelte | 142 ++++++++++-------- .../dialogs/p2pSetupConnectionProbe.ts | 34 ++++- .../p2pSetupConnectionProbe.unit.spec.ts | 64 +++++++- .../SetupWizard/dialogs/setupDialogTypes.ts | 6 + 12 files changed, 248 insertions(+), 73 deletions(-) diff --git a/src/common/messages/combinedMessages.prod.ts b/src/common/messages/combinedMessages.prod.ts index 968bb4c1..5e7208bb 100644 --- a/src/common/messages/combinedMessages.prod.ts +++ b/src/common/messages/combinedMessages.prod.ts @@ -9791,6 +9791,10 @@ export const allMessages: Readonly { + const connectionProbe = this.p2pSetupConnectionProbe; + if (!connectionProbe) { + throw new Error("The P2P Setup connection probe is not available."); + } + return this.dialogManager.openWithExplicitCancel( + SetupRemoteP2P, + { settings, connectionProbe } + ); + } + /** * Ask which existing data should be authoritative for pending setting changes, * then reserve the matching next-start operation before applying them. @@ -280,10 +304,7 @@ export class SetupManager extends AbstractModule { currentSetting: ObsidianLiveSyncSettings, activate = true ): Promise { - const p2pConf = await this.dialogManager.openWithExplicitCancel( - SetupRemoteP2P, - currentSetting - ); + const p2pConf = await this.openP2PSetup(currentSetting); if (p2pConf === "cancelled") { this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE); return await this.onOnboard(userMode); diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts index 7bf889c8..41d9ecaf 100644 --- a/src/modules/features/SetupManager.unit.spec.ts +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -8,6 +8,11 @@ import { import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; +import type { + P2PConnectionProbeAdmission, + P2PConnectionProbeAdmissionResult, + P2PConnectionProbeSettings, +} from "@vrtmrz/livesync-commonlib/p2p"; vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} })); vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} })); @@ -124,11 +129,23 @@ function createSetupManager() { }, }); + const p2pSetupConnectionProbe: P2PConnectionProbeAdmission = { + async run( + _settings: P2PConnectionProbeSettings, + runOwnedTrial: () => Promise + ): Promise> { + return { status: "trial", result: await runOwnedTrial() }; + }, + }; + const manager = new SetupManager(core); + manager.registerP2PSetupConnectionProbe(p2pSetupConnectionProbe); + return { - manager: new SetupManager(core), + manager, setting, dialogManager, core, + p2pSetupConnectionProbe, }; } @@ -138,6 +155,19 @@ describe("SetupManager", () => { vi.restoreAllMocks(); }); + it("opens P2P Setup with the registered owner admission", async () => { + const { manager, setting, dialogManager, p2pSetupConnectionProbe } = createSetupManager(); + const settings = setting.currentSettings(); + dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + + await expect(manager.openP2PSetup(settings)).resolves.toBe("cancelled"); + + expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ settings, connectionProbe: p2pSetupConnectionProbe }) + ); + }); + it("starts manual new-user setup from the recommended new-Vault settings", async () => { const { manager, dialogManager } = createSetupManager(); dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually"); diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte index 82324b14..ec6a55ab 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte @@ -36,10 +36,14 @@ import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog"; import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types"; import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte"; - import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes"; + import { + TYPE_CANCELLED, + type SetupRemoteP2PInitialData, + 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"; + import { coordinateP2PSetupConnectionProbe, probeP2PSetupConnection } from "./p2pSetupConnectionProbe"; const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS); let syncSetting = $state({ ...default_setting }); @@ -48,18 +52,18 @@ let error = $state(""); let connectionPathResetNotice = $state(false); const hasValidTurnServer = $derived(hasValidP2PTurnServerUrl(syncSetting.P2P_turnServers ?? "")); - type Props = GuestDialogProps; + type Props = GuestDialogProps; const { setResult, getInitialData }: Props = $props(); + let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined; onMount(() => { - let initialData: P2PSyncSetting | undefined = undefined; - if (getInitialData) { - initialData = getInitialData(); - if (initialData) { - copyTo(initialData, syncSetting); - } + const initialData = getInitialData?.(); + connectionProbe = initialData?.connectionProbe; + const initialSettings = initialData?.settings; + if (initialSettings) { + copyTo(initialSettings, syncSetting); } - const initialPeerName = (initialData?.P2P_DevicePeerName ?? "").trim(); + const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim(); if (initialPeerName !== "") { return; } @@ -97,58 +101,74 @@ try { processing = true; const trialRemoteSetting = generateSetting(); - const map = new Map(); - const store = { - get: (key: string) => { - return Promise.resolve(map.get(key) || null); - }, - set: (key: string, value: any) => { - map.set(key, value); - return Promise.resolve(); - }, - delete: (key: string) => { - map.delete(key); - return Promise.resolve(); - }, - keys: () => { - return Promise.resolve(Array.from(map.keys())); - }, - get db() { - return Promise.resolve(this); - }, - } as SimpleStore; - - const dummyPouch = new PouchDB("dummy"); - const env: ReplicatorHostEnv = { - events: context.context.events, - translate: context.context.translate, - settings: trialRemoteSetting, - processReplicatedDocs: async (_docs: any[]) => { - return; - }, - confirm: context.services.confirm, - db: dummyPouch, - simpleStore: store, - deviceName: syncSetting.P2P_DevicePeerName || "unnamed-device", - platform: "setup-wizard", - }; - const replicator = new TrysteroReplicator(env); - try { - const result = await probeP2PSetupConnection(replicator); - if (!result.ok) { - return translateMessage("Failed to connect to the signalling relay: ${reason}", { - reason: `${result.reason}`, - }); - } - return ""; - } finally { - try { - await replicator.dispose(); - await dummyPouch.destroy(); - } catch (e) { - Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup"); - } + const admission = connectionProbe; + if (!admission) { + throw new Error("The P2P Setup connection probe is not available."); } + const result = await coordinateP2PSetupConnectionProbe(admission, trialRemoteSetting, async () => { + const map = new Map(); + const store = { + get: (key: string) => { + return Promise.resolve(map.get(key) || null); + }, + set: (key: string, value: any) => { + map.set(key, value); + return Promise.resolve(); + }, + delete: (key: string) => { + map.delete(key); + return Promise.resolve(); + }, + keys: () => { + return Promise.resolve(Array.from(map.keys())); + }, + get db() { + return Promise.resolve(this); + }, + } as SimpleStore; + + const dummyPouch = new PouchDB("dummy"); + let replicator: TrysteroReplicator | undefined; + try { + const env: ReplicatorHostEnv = { + events: context.context.events, + translate: context.context.translate, + settings: trialRemoteSetting, + processReplicatedDocs: async (_docs: any[]) => { + return; + }, + confirm: context.services.confirm, + db: dummyPouch, + simpleStore: store, + deviceName: syncSetting.P2P_DevicePeerName || "unnamed-device", + platform: "setup-wizard", + }; + replicator = new TrysteroReplicator(env); + return await probeP2PSetupConnection(replicator); + } finally { + try { + await replicator?.dispose(); + } catch (e) { + Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-replicator-cleanup"); + } + try { + await dummyPouch.destroy(); + } catch (e) { + Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-database-cleanup"); + } + } + }); + if (!result.ok) { + if ("kind" in result && result.kind === "blocked") { + return translateMessage( + "The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing." + ); + } + return translateMessage("Failed to connect to the signalling relay: ${reason}", { + reason: `${result.reason}`, + }); + } + return ""; } finally { processing = false; } diff --git a/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.ts b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.ts index 44fd8b00..ec8fc9ea 100644 --- a/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.ts +++ b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.ts @@ -1,4 +1,17 @@ -export type P2PSetupConnectionProbeResult = { ok: true } | { ok: false; reason: string }; +import { + ACTIVE_P2P_RELAY_BINDING_CONFLICT, + type P2PConnectionProbeAdmission, + type P2PConnectionProbeSettings, +} from "@vrtmrz/livesync-commonlib/p2p"; + +export type P2PSetupConnectionProbeResult = + | { readonly ok: true } + | { readonly ok: false; readonly reason: string } + | { + readonly ok: false; + readonly kind: "blocked"; + readonly reason: typeof ACTIVE_P2P_RELAY_BINDING_CONFLICT; + }; export interface P2PSetupConnectionProbe { setOnSetup(): void | Promise; @@ -6,6 +19,25 @@ export interface P2PSetupConnectionProbe { open(): Promise; } +/** Interpret the stable P2P owner's admission without constructing transport eagerly. */ +export async function coordinateP2PSetupConnectionProbe( + admission: P2PConnectionProbeAdmission, + trialSettings: P2PConnectionProbeSettings, + runOwnedTrial: () => Promise +): Promise { + const settlement = await admission.run(trialSettings, runOwnedTrial); + if (settlement.status === "observed-active") return { ok: true }; + if (settlement.status === "blocked") { + return { + ok: false, + kind: "blocked", + reason: settlement.reason, + }; + } + return settlement.result; +} + +/** Open one separately owned signalling connection and report its outcome. */ export async function probeP2PSetupConnection( replicator: P2PSetupConnectionProbe ): Promise { diff --git a/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.unit.spec.ts b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.unit.spec.ts index 28eed360..fdf3af91 100644 --- a/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.unit.spec.ts +++ b/src/modules/features/SetupWizard/dialogs/p2pSetupConnectionProbe.unit.spec.ts @@ -1,7 +1,69 @@ import { describe, expect, it, vi } from "vitest"; -import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe"; +import { ACTIVE_P2P_RELAY_BINDING_CONFLICT, type P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p"; +import { + coordinateP2PSetupConnectionProbe, + probeP2PSetupConnection, + type P2PSetupConnectionProbeResult, +} from "./p2pSetupConnectionProbe"; describe("P2P setup connection probe", () => { + it("uses a compatible active signalling connection without constructing a trial", async () => { + const runOwnedTrial = vi.fn(async (): Promise => ({ ok: true })); + const admission: P2PConnectionProbeAdmission = { + run: vi.fn(async () => ({ status: "observed-active" }) as const), + }; + + await expect( + coordinateP2PSetupConnectionProbe(admission, { P2P_relays: "wss://relay.example.com" }, runOwnedTrial) + ).resolves.toEqual({ ok: true }); + + expect(admission.run).toHaveBeenCalledOnce(); + expect(runOwnedTrial).not.toHaveBeenCalled(); + }); + + it("preserves the typed blocked reason without opening an incompatible trial", async () => { + const runOwnedTrial = vi.fn(async (): Promise => ({ ok: true })); + const admission: P2PConnectionProbeAdmission = { + run: vi.fn( + async () => + ({ + status: "blocked", + reason: ACTIVE_P2P_RELAY_BINDING_CONFLICT, + }) as const + ), + }; + + await expect( + coordinateP2PSetupConnectionProbe( + admission, + { P2P_relays: "wss://another-relay.example.com" }, + runOwnedTrial + ) + ).resolves.toEqual({ + ok: false, + kind: "blocked", + reason: ACTIVE_P2P_RELAY_BINDING_CONFLICT, + }); + + expect(admission.run).toHaveBeenCalledOnce(); + expect(runOwnedTrial).not.toHaveBeenCalled(); + }); + + it("runs and returns the complete owned trial continuation when no room is active", async () => { + const trialResult = { ok: false, reason: "relay unavailable" } as const; + const runOwnedTrial = vi.fn(async (): Promise => trialResult); + const admission: P2PConnectionProbeAdmission = { + run: vi.fn(async (_settings, trial) => ({ status: "trial", result: await trial() }) as const), + }; + + await expect( + coordinateP2PSetupConnectionProbe(admission, { P2P_relays: "wss://relay.example.com" }, runOwnedTrial) + ).resolves.toEqual(trialResult); + + expect(admission.run).toHaveBeenCalledOnce(); + expect(runOwnedTrial).toHaveBeenCalledOnce(); + }); + it("accepts an empty room after the signalling connection opens", async () => { const replicator = { knownAdvertisements: [], diff --git a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts index 66850c16..2c9ef58e 100644 --- a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts +++ b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts @@ -4,7 +4,9 @@ import type { EncryptionSettings, ObsidianLiveSyncSettings, P2PConnectionInfo, + P2PSyncSetting, } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; +import type { P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p"; export const TYPE_IDENTICAL = "identical"; export const TYPE_INDEPENDENT = "independent"; @@ -119,5 +121,9 @@ export type SetupRemoteCouchDBInitialData = { }; export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo; +export type SetupRemoteP2PInitialData = { + settings: P2PSyncSetting; + connectionProbe: P2PConnectionProbeAdmission; +}; export type ScanQRCodeResultType = typeof TYPE_CLOSE;