diff --git a/docs/adr/2026_08_p2p_transport_compatibility.md b/docs/adr/2026_08_p2p_transport_compatibility.md index d0a95c80..c9691c28 100644 --- a/docs/adr/2026_08_p2p_transport_compatibility.md +++ b/docs/adr/2026_08_p2p_transport_compatibility.md @@ -46,22 +46,19 @@ LiveSync will expose a separate `Connection path` choice: - `Automatic` retains normal ICE selection and is the default. - `TURN relay only` supplies `iceTransportPolicy: 'relay'` and prevents direct or server-reflexive candidates from being selected. -`TURN relay only` is enabled only when at least one syntactically valid `turn:` or `turns:` URL is configured. If the last valid TURN URL is removed while relay-only mode is selected, saving the settings restores `Automatic` and displays a concise explanation. +`TURN relay only` is enabled only when at least one syntactically valid `turn:` or `turns:` URL is configured. If the last valid TURN URL is removed while relay-only mode is selected, the dialogue restores `Automatic` and displays a concise explanation. -The route policy is stored per P2P profile on the current device and is omitted from Setup URIs. It is a diagnostic and compatibility choice for the current device and network; forcing every synchronising device through TURN merely because one mobile path needs it would add avoidable latency, bandwidth cost, and metadata exposure. +The route policy is an ordinary P2P profile property. It is retained in P2P connection strings and encrypted Setup URIs so that an imported compatibility profile has reproducible transport behaviour. + +Multiple P2P profiles may intentionally use the same Group ID, passphrase, and relay list while selecting different compatibility settings. For example, one profile may use `Standard` and `Automatic`, while another uses `Maximum compatibility` and `TURN relay only`. Only the selected P2P profile joins the group, so each device can select the profile appropriate to its current network without a separate device-local override system. No `Direct only` choice will be added. `Automatic` already prefers viable non-relayed candidates, and preventing TURN fallback would mainly create another failure mode. ### TURN server presentation -The first settings revision retains the existing storage contract of one credential shared by a list of TURN URLs. The dialogue will present it as a profile rather than as one comma-separated text field: +The first settings revision retains the existing storage and dialogue contract of one comma-separated TURN URL list, one username, and one credential. The connection-path choice is presented separately under `Connection compatibility`, while the TURN values remain under `Advanced Settings`. -- an ordered list of `turn:` and `turns:` URL rows; -- one username; -- one credential; and -- the connection-path choice below the profile. - -The interface may parse and serialise the existing comma-separated value so older profiles and Setup URIs remain compatible. A structured list of multiple credential profiles is deferred until a provider or self-hosted use case requires different credentials in the same P2P profile. +A future interface may present the existing comma-separated value as ordered `turn:` and `turns:` URL rows without changing its serialised representation. A structured list of multiple credential profiles is deferred until a provider or self-hosted use case requires different credentials in the same P2P profile. Static long-term credentials are the supported first stage. Managed providers may return short-lived credentials, but LiveSync must not store a provider API token or a Coturn shared authentication secret. A future managed-credential design needs a separately trusted HTTPS endpoint, expiry handling, refresh behaviour, failure reporting, and a clear Setup URI policy. It is not represented as another static password field. @@ -134,6 +131,10 @@ The bound controls outgoing messages. This would leave larger messages from anot TURN is normally a fallback. Forcing it by default adds latency and bandwidth cost, and exposes more connection metadata even when a direct path works. +### Store the connection path in a device-local overlay + +A second layer of device-specific profile overrides would make imported profile behaviour less reproducible and add another identity, mapping, and lifecycle contract. Separate named P2P profiles already let each device select an explicit transport policy, including when those profiles share the same Group ID and credentials. + ### Automatically decrease the payload after a transfer failure A transfer failure does not identify message size as the cause. Reusing a possibly wedged ordered channel would also make the retry inconclusive, while rebuilding the connection expands the lifecycle and user-notification design. @@ -152,17 +153,18 @@ A layer-4 TLS router could share one public address between distinct CouchDB and ## Verification -The implementation stage must add focused tests before production changes: +The first implementation stage must add focused tests before production changes: - settings-schema defaults for absent keys; -- Setup URI round trips which retain the message-size preset but omit the device-local connection path; +- P2P connection-string and Setup URI round trips which retain both transport compatibility settings; - compatibility parsing and serialisation of the existing TURN URL string; - mapping each message-size preset to the exact Commonlib wire bound; - mapping relay-only mode to `iceTransportPolicy: 'relay'`; - rejection or automatic reset of relay-only mode without a valid TURN URL; -- room replacement after either effective transport setting changes; -- a disposable TURN allocation check using injected WebRTC boundaries; and -- a real transport test only for the device- or network-owned behaviour which deterministic injection cannot prove. +- room replacement after either effective transport setting changes; and +- the real Obsidian dialogue, profile, and connection-string round trip. + +The future TURN allocation action requires its own focused tests using injected WebRTC boundaries, followed by a real transport test only for the device- or network-owned behaviour which deterministic injection cannot prove. The Coturn example is checked independently with `docker compose config`. Runtime verification uses a real Coturn allocation from outside the server network and confirms both UDP and TCP client paths before it is presented as a known-working deployment. @@ -170,6 +172,7 @@ The Coturn example is checked independently with `docker compose config`. Runtim - Users gain a small compatibility ladder without learning WebRTC internals. - A conservative message size affects throughput wherever it is selected or imported, and must be applied to every participating device to protect all transfer directions. +- Profiles may intentionally share the same P2P group identity while offering different transport compatibility choices; the selected profile determines the active connection behaviour. - TURN can be forced for diagnosis or hostile networks without making relay use the global default. - Static and managed TURN credentials have separate, explicit responsibility boundaries. - Browser-specific heuristics, automatic payload fallback, and low-level transport knobs remain out of scope. diff --git a/docs/p2p.md b/docs/p2p.md index ff47468a..23253771 100644 --- a/docs/p2p.md +++ b/docs/p2p.md @@ -44,6 +44,17 @@ Both settings contain server addresses, but they are not interchangeable. A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service. +## Connection compatibility profiles + +`P2P Configuration` includes a separate `Connection compatibility` section. Its defaults preserve the existing transport behaviour: + +- **P2P message size** defaults to **Standard**. **Reduced**, **Conservative**, and **Maximum compatibility** progressively limit outgoing P2P messages when a network path appears to drop larger WebRTC messages. This is not a Vault Chunk size or an IP MTU. Smaller values add framing and processing overhead. +- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available only when the profile contains at least one valid `turn:` or `turns:` URL. + +The sending device controls its outgoing message size. Select the same conservative preset on every device which may send across the constrained path. Existing devices do not receive the choice retrospectively merely because another device changed it. + +Both compatibility choices belong to the saved P2P profile and are retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID, passphrase, and relay list while selecting different compatibility choices. Only the selected P2P profile joins the group. + ## P2P Status The **P2P Status** pane is the current Obsidian interface for P2P connections. diff --git a/docs/settings.md b/docs/settings.md index 968aba0a..830c7e66 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -465,6 +465,22 @@ Setting key: P2P_turnCredential The password or credential for authentication with the TURN server. +#### P2P message size + +Setting key: P2P_maxWirePayloadBytes + +This profile setting limits each outgoing Commonlib RPC message before Trystero applies its own framing. It is not a Vault Chunk size, an IP MTU, or an SCTP fragment size. The available presets are **Standard** (15,360 bytes), **Reduced** (2,048 bytes), **Conservative** (1,024 bytes), and **Maximum compatibility** (800 bytes). Smaller values trade throughput for compatibility on paths which appear to drop larger WebRTC messages. + +The sender controls the size of its outgoing messages. Select the same conservative preset on every device which may send across the constrained path. Existing profiles without this key use **Standard**. P2P connection strings and encrypted Setup URIs retain the selected preset. + +#### Connection path + +Setting key: P2P_connectionPath + +**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available only when the profile contains at least one valid `turn:` or `turns:` URL. Removing the last valid TURN URL while relay-only mode is selected restores **Automatic** and displays a Notice. + +This choice belongs to the P2P profile and is retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID and credentials with different compatibility choices; only the selected P2P profile is active. + ## 4. Sync Settings ### 1. Synchronisation Preset diff --git a/docs/tips/p2p-sync-tips.md b/docs/tips/p2p-sync-tips.md index 34fd6c75..74a28370 100644 --- a/docs/tips/p2p-sync-tips.md +++ b/docs/tips/p2p-sync-tips.md @@ -36,11 +36,20 @@ Try these in order: 1. Put both devices on the same ordinary network and retry. 2. Remove a VPN temporarily if it blocks peer traffic, or use a trusted VPN such as Tailscale when it provides a reachable path between the devices. 3. In `P2P Configuration` -> `Advanced Settings`, configure a trusted TURN service. +4. Under `Connection compatibility`, select `TURN relay only` to test the configured TURN path without direct ICE candidates. TURN is a fallback for encrypted WebRTC traffic. It is different from the required signalling relay. The project does not operate an official TURN service. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. For a small self-hosted deployment, the repository includes an optional [Coturn Compose starter](../../docker/coturn/README.md). It uses static credentials and does not include TLS or a managed credential service; review its network and security boundaries before exposing it. +## A connection opens but a transfer stalls + +If peers can connect but a transfer repeatedly stalls on one network path, try the `P2P message size` presets under `Connection compatibility`. Start with `Reduced`, then try `Conservative` and `Maximum compatibility` only if needed. + +The preset limits outgoing messages, so select the same value on every device which may send across the affected path. Smaller values add overhead and do not prove that packet fragmentation was the cause. Return to `Standard` when the path works reliably without the compatibility setting. + +Compatibility choices are saved with the P2P profile. You may keep separate standard and compatibility profiles with the same Group ID and credentials, then select the profile appropriate to the current network. + ## A connected peer does not receive later edits An open signalling connection does not automatically move every change. diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 682e4366..0eac51b6 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -32,6 +32,20 @@ export const liveSyncProvisionalEnglishMessages = { "Learn more about signalling and TURN": "Learn more about signalling and TURN", "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.": "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.", + "Connection compatibility": "Connection compatibility", + "P2P message size": "P2P message size", + Standard: "Standard", + Reduced: "Reduced", + Conservative: "Conservative", + "Maximum compatibility": "Maximum compatibility", + "Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.": + "Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.", + "Connection path": "Connection path", + "TURN relay only": "TURN relay only", + "TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.": + "TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.", + "TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.": + "TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.", "Announce changes": "Announce changes", "Announce changes automatically after connecting": "Announce changes automatically after connecting", "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.": diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte index 89a16162..13293053 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte @@ -11,8 +11,13 @@ import { DEFAULT_SETTINGS, P2P_DEFAULT_SETTINGS, + P2PConnectionPaths, + P2PMessageSizePresets, PREFERRED_BASE, RemoteTypes, + hasValidP2PTurnServerUrl, + normaliseP2PConnectionPath, + normaliseP2PMaxWirePayloadBytes, type EntryDoc, type ObsidianLiveSyncSettings, type P2PConnectionInfo, @@ -41,6 +46,8 @@ const context = getDialogContext(); let error = $state(""); + let connectionPathResetNotice = $state(false); + const hasValidTurnServer = $derived(hasValidP2PTurnServerUrl(syncSetting.P2P_turnServers ?? "")); type Props = GuestDialogProps; const { setResult, getInitialData }: Props = $props(); @@ -67,6 +74,12 @@ ...P2P_DEFAULT_SETTINGS, ...syncSetting, P2P_Enabled: true, + P2P_maxWirePayloadBytes: normaliseP2PMaxWirePayloadBytes(syncSetting.P2P_maxWirePayloadBytes), + P2P_connectionPath: + normaliseP2PConnectionPath(syncSetting.P2P_connectionPath) === P2PConnectionPaths.Relay && + hasValidTurnServer + ? P2PConnectionPaths.Relay + : P2PConnectionPaths.Automatic, }; const trialSettings: P2PSyncSetting = { ...connSetting, @@ -144,6 +157,13 @@ syncSetting.P2P_relays = P2P_DEFAULT_SETTINGS.P2P_relays; } + $effect(() => { + if (!hasValidTurnServer && syncSetting.P2P_connectionPath === P2PConnectionPaths.Relay) { + syncSetting.P2P_connectionPath = P2PConnectionPaths.Automatic; + connectionPathResetNotice = true; + } + }); + let processing = $state(false); function generateDefaultGroupId() { syncSetting.P2P_roomID = generateP2PRoomId(); @@ -264,6 +284,50 @@ "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." )} + + + + + + {translateMessage( + "Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required." + )} + + + + + + {translateMessage( + "TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings." + )} + + + {translateMessage( + "TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic." + )} + + {translateMessage( diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts index 86af117a..25e9f882 100644 --- a/test/e2e-obsidian/scripts/dialog-mounts.ts +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -30,6 +30,7 @@ type DialogueRunState = { type SetupManagerHandle = { constructor: { name: string }; onSelectServer?: (settings: unknown, remoteType: string) => Promise; + onConfirmApplySettingsFromWizard?: (settings: unknown, userMode: string, activate?: boolean) => Promise; _askUseRemoteConfiguration?: (settings: unknown, preferred: unknown) => Promise; _checkAndAskResolvingMismatchedTweaks?: (preferred: unknown) => Promise; __addLog?: (message: string) => void; @@ -90,6 +91,44 @@ async function openRemoteSelectionDialogue(): Promise { }); } +async function openP2PRemoteSelectionDialogueForInspection(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate((stateKey) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager"); + if ( + typeof manager?.onSelectServer !== "function" || + typeof manager.onConfirmApplySettingsFromWizard !== "function" + ) { + throw new Error("Could not find the P2P setup workflow"); + } + const originalConfirm = manager.onConfirmApplySettingsFromWizard; + const state: DialogueRunState = { kind: "p2p-compatibility-settings", done: false }; + (globalThis as unknown as Record)[stateKey] = state; + manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => { + state.expected = settings; + return true; + }; + void manager + .onSelectServer(plugin.core.settings, "unknown") + .then( + (result) => { + state.result = result; + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ) + .finally(() => { + manager.onConfirmApplySettingsFromWizard = originalConfirm; + }); + }, dialogRunStateKey); + }); +} + async function openSetupUriDialogue(): Promise { const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { return await page.evaluate( @@ -433,6 +472,158 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise { + await openP2PRemoteSelectionDialogueForInspection(); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const remoteSelection = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); + await remoteSelection + .locator("label") + .filter({ hasText: "Peer-to-Peer (P2P)" }) + .locator('input[type="radio"]') + .first() + .check({ timeout: uiTimeoutMs }); + await remoteSelection + .getByRole("button", { name: "Continue to P2P setup", exact: true }) + .click({ timeout: uiTimeoutMs }); + }); + + const screenshotPath = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "setup-p2p-compatibility-dialogue.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility"); + await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase"); + await modal.locator('input[name="p2p-device-peer-id"]').fill("e2e-device"); + + const compatibility = modal.locator("details").filter({ + has: page.locator("summary").filter({ hasText: "Connection compatibility" }), + }); + await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) { + await compatibility.locator("summary").click({ timeout: uiTimeoutMs }); + } + const parentSection = await compatibility.evaluate( + (element) => + element.parentElement?.closest("details")?.querySelector(":scope > summary")?.textContent?.trim() ?? + "" + ); + if (parentSection === "Advanced Settings") { + throw new Error("P2P connection compatibility controls were placed under Advanced Settings."); + } + + const messageSize = compatibility.getByLabel("P2P message size", { exact: true }); + await messageSize.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const messageSizeOptions = await messageSize.locator("option").evaluateAll((options) => + options.map((option) => ({ + label: option.textContent?.trim() ?? "", + value: (option as HTMLOptionElement).value, + })) + ); + const expectedMessageSizeOptions = [ + { label: "Standard", value: "15360" }, + { label: "Reduced", value: "2048" }, + { label: "Conservative", value: "1024" }, + { label: "Maximum compatibility", value: "800" }, + ]; + if (JSON.stringify(messageSizeOptions) !== JSON.stringify(expectedMessageSizeOptions)) { + throw new Error(`Unexpected P2P message-size presets: ${JSON.stringify(messageSizeOptions)}`); + } + + const connectionPath = compatibility.getByLabel("Connection path", { exact: true }); + await connectionPath.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const relayOnly = connectionPath.locator('option[value="relay"]'); + if (!(await relayOnly.evaluate((option) => (option as HTMLOptionElement).disabled))) { + throw new Error("TURN relay only was enabled without a valid TURN server URL."); + } + + const advanced = modal.locator("details").filter({ + has: page.locator("summary").filter({ hasText: "Advanced Settings" }), + }); + if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) { + await advanced.locator("summary").click({ timeout: uiTimeoutMs }); + } + const turnServers = advanced.getByLabel("TURN Server URLs (comma-separated)", { exact: true }); + await turnServers.fill("turn:turn.example.com:3478"); + const relayOnlyElement = await relayOnly.elementHandle(); + const connectionPathElement = await connectionPath.elementHandle(); + if (relayOnlyElement === null || connectionPathElement === null) { + throw new Error("P2P connection-path controls were removed whilst validating TURN settings."); + } + await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, { + timeout: uiTimeoutMs, + }); + await connectionPath.selectOption("relay"); + + await turnServers.fill(""); + await page.waitForFunction( + (element) => (element as HTMLSelectElement).value === "automatic", + connectionPathElement, + { timeout: uiTimeoutMs } + ); + const resetNotice = compatibility.locator(".sls-info-note-notice").filter({ + hasText: + "TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.", + }); + await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await resetNotice + .locator(".sls-signal-word-notice") + .filter({ hasText: "NOTICE" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + + await turnServers.fill("turn:turn.example.com:3478"); + await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, { + timeout: uiTimeoutMs, + }); + await connectionPath.selectOption("relay"); + await messageSize.selectOption("800"); + await advanced.locator("summary").click({ timeout: uiTimeoutMs }); + await compatibility.scrollIntoViewIfNeeded(); + } + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }), + }); + await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const state = await assertDialogueRunCompleted(); + if (state.result !== true || typeof state.expected !== "object" || state.expected === null) { + throw new Error("The P2P compatibility settings were not passed to the setup workflow."); + } + const settings = state.expected as Record; + if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") { + throw new Error( + `The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({ + P2P_maxWirePayloadBytes: settings.P2P_maxWirePayloadBytes, + P2P_connectionPath: settings.P2P_connectionPath, + })}` + ); + } + const activeId = settings.P2P_ActiveRemoteConfigurationId; + const configurations = settings.remoteConfigurations; + if (typeof activeId !== "string" || typeof configurations !== "object" || configurations === null) { + throw new Error("The P2P setup workflow did not create an active remote configuration."); + } + const active = (configurations as Record)[activeId]; + if (typeof active?.uri !== "string") { + throw new Error("The active P2P remote configuration did not contain a connection string."); + } + const query = new URL(active.uri).searchParams; + if (query.get("maxWirePayloadBytes") !== "800" || query.get("connectionPath") !== "relay") { + throw new Error(`The P2P connection string omitted compatibility settings: ${active.uri}`); + } + return screenshotPath; +} + async function verifySetupUriDialogue(mode: DialogueMode): Promise { await openSetupUriDialogue(); const screenshotPath = await captureObsidianDialogue( @@ -1066,6 +1257,10 @@ async function main(): Promise { console.log( `CouchDB settings mode exposed explicit connection, unverified-save, and server-check actions. Screenshot: ${couchDBScreenshot}` ); + const p2pCompatibilityScreenshot = await verifyP2PCompatibilitySettingsDialogue(); + console.log( + `P2P compatibility presets, TURN validation, and profile round-trip passed. Screenshot: ${p2pCompatibilityScreenshot}` + ); const setupUriScreenshot = await verifySetupUriDialogue("desktop"); console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`); await verifyCompatibleAlignmentSettingDefault();