diff --git a/README.md b/README.md index b293b2b4..c26fc257 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling de - No central data-storage server is required, but a signalling relay is still required for peer discovery. - At least one device containing the required data must be online while another device synchronises. - Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md). + - If you would like to see how P2P setup and connectivity behave on your devices, try the guided connection check in the experimental [WebPeer browser utility](https://vrtmrz.github.io/obsidian-livesync/webpeer/). It uses an empty Vault and does not test note synchronisation. See the [WebPeer documentation](src/apps/webpeer/README.md) for its scope and limitations. This plug-in may be particularly useful for researchers, engineers, and developers who need to keep their notes fully self-hosted for security reasons. It is also suitable for anyone seeking the peace of mind that comes with knowing their notes remain entirely private. diff --git a/docs/adr/2026_07_p2p_connection_check.md b/docs/adr/2026_07_p2p_connection_check.md new file mode 100644 index 00000000..10105c41 --- /dev/null +++ b/docs/adr/2026_07_p2p_connection_check.md @@ -0,0 +1,103 @@ +# Architectural Decision Record: Browser-assisted P2P Connection Check + +## Status + +Accepted — WebPeer provides a disposable, browser-observed connection check with a clean one-device path and an optional same-room second-device attempt. + +## Context + +P2P availability depends on the browser, device, signalling relay, NAT, firewall, and current network. A Setup URI can make a trial easier, but a connection observed between a browser and one device does not prove that two other devices can connect to each other, and a WebRTC connection does not prove that LiveSync documents can be synchronised correctly. + +Showing this experiment inside the ordinary LiveSync interface would mix a temporary connectivity check with the state of a real Vault. It would also make a browser-owned diagnostic result appear to be a plug-in-owned synchronisation result. + +The Commonlib diagnostic counters are cumulative within one JavaScript realm. A negotiation may create more than one `RTCPeerConnection`, a failed attempt may later be followed by a successful retry, and closing a successful connection is not a failure. The counters therefore cannot be interpreted as peer counts or as mutually exclusive outcomes. + +## Decision + +WebPeer provides a separate page labelled 'P2P connection check'. It acts as a temporary reference peer and creates a fresh, random P2P configuration locally in the browser. The page displays an encrypted Setup URI, a QR code containing that URI, and the separate Setup URI passphrase. + +The initial implementation is part of the WebPeer production build and is served over HTTPS or from `localhost`. A downloaded copy of `check.html` alone is not a supported standalone application because the build uses separate module assets and origin-scoped browser storage. + +Each check has these boundaries: + +| Participant | Responsibility | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| Browser page | Joins the generated room as the diagnostic reference peer and displays its own diagnostic counters. | +| Desktop or mobile LiveSync | Opens the generated Setup URI in a dedicated empty Vault and attempts the ordinary P2P connection. | +| User | Uses an empty Vault for every device and chooses either a fresh room or the explicitly less isolated same-room second-device attempt. | + +The generated device configuration: + +- uses a dedicated P2P application identifier so that test rooms do not overlap ordinary LiveSync rooms; +- uses random room, P2P, Vault-encryption, and Setup URI secrets; +- enables P2P auto-start so that a dedicated empty test Vault attempts the connection after setup; +- does not enable automatic broadcasting, peer acceptance, watching, or replication; +- omits device-local names from the Setup URI; and +- enables diagnostic WebRTC wrapping only on the browser reference peer. + +The browser joins the room only after the user explicitly starts monitoring. Preparing the Setup URI and QR code does not contact the signalling relay. The page must remain open while monitoring. + +The primary path checks one target device. The user selects either desktop or mobile before generating the configuration. Starting a fresh check reloads the page, creates a new room, and resets the page-scoped diagnostic baseline; this remains the clearest way to compare devices. + +After the first connection succeeds, the page can scroll back to the existing QR code without generating new credentials. It also offers an optional same-room second-device attempt. This keeps the browser and first device connected, records the current diagnostic totals and active `RTCPeerConnection` identifiers as a local baseline, and reuses the exact Setup URI for another empty Vault. It reports an additional connection only when both the successful-connection total and the number of simultaneous active connections increase, with an active connection identifier absent from the baseline. A reconnect which replaces the first connection can therefore increase negotiation counters without satisfying the additional-connection result. + +The same-room result remains a convenience rather than an isolated comparison. Its totals are still cumulative, a connection is not a device identity, it depends on the first device remaining connected, and unusual reconnect timing can be harder to interpret than a fresh room. The interface keeps the fresh-check action available and explains this limitation beside the additional result. + +The page displays the following Commonlib diagnostic totals: + +- new connection states; +- successful connection states; +- failed connection states; and +- closed connection states. + +A successful total greater than zero proves that this browser and the target device established at least one WebRTC connection in the generated room. That successful result remains valid if a later closed or failed total increases. A failed total without a successful total describes a failed attempt, not a final verdict, because the transport may retry. If no success has been observed after the documented observation period, the result is inconclusive for P2P on the current network. The page recommends CouchDB for predictable synchronisation when repeated checks on the intended networks do not connect. + +The page does not claim to verify document transfer, conflict handling, sustained connectivity, background execution, or a direct desktop-to-mobile path. An optional final test can use two disposable empty Vaults and a note round trip directly between the user's devices. That direct test is more representative, but its result cannot be observed reliably by the browser page without a separate reporting protocol. + +## Security and privacy + +All credentials are generated with Web Crypto in the browser. The Setup URI is encrypted, its passphrase is shown separately, and neither value is uploaded merely by preparing the check. Their read-only fields disable browser autocomplete and spell-check; copying either value to the system clipboard remains an explicit user action. Starting monitoring uses the configured signalling relay, which is the existing public relay by default, and exposes the usual P2P signalling metadata to that service. WebRTC and optional relay or TURN behaviour retain their existing Commonlib security and privacy properties. + +The production page does not accept a relay override from a deployed origin. A `relay` query parameter is honoured only when the page itself is served from a loopback host, and only for `ws:` or `wss:` URLs. This narrow boundary allows the real-Obsidian E2E scenario to use the local Compose relay without making an arbitrary relay query part of the hosted user workflow. + +The generated configuration is disposable. It must be used only in a dedicated empty Vault, must not replace a production Vault's settings, and must not be retained as the credentials for a later production setup. The browser page stores no ordinary Vault files for this workflow, although browser-local database infrastructure is opened to satisfy the existing WebPeer runtime contract. + +## Alternatives rejected + +### Display the result in the LiveSync plug-in + +The observation belongs to the browser reference peer and does not prove LiveSync document synchronisation. Keeping the result beside the QR code makes the evidence and its owner explicit. + +### Use browser, desktop, and mobile simultaneously as the primary comparison + +The diagnostic totals are cumulative and do not attribute an `RTCPeerConnection` to a named target device. Separate random rooms provide a clearer result. Same-room reuse is retained only as an explicit convenience, with a counter baseline and another simultaneous active connection required for its result. + +### Treat any failure increment as a final failure + +Negotiation can fail and then retry successfully. A later successful increment takes precedence over earlier failure increments. + +### Treat browser success as proof of device-to-device synchronisation + +Browser-to-device success is a useful preflight check, but it does not exercise the exact desktop-to-mobile network path or a LiveSync note round trip. + +### Put the Setup URI passphrase in the QR code + +Keeping the encrypted URI and passphrase separate preserves the existing Setup URI boundary and avoids turning one captured QR code into the complete configuration secret. + +### Distribute one standalone downloaded HTML file + +WebPeer already depends on a compiled module graph, origin-scoped storage, and secure browser capabilities. A hosted production build reuses those boundaries and can be updated consistently; a single self-contained file would require a separate bundling and trust model. + +## Verification + +Unit tests decode each generated Setup URI and verify its isolation identifier, random credentials, remote-profile selection, automatic-start policy, absence of a device-local name, and browser-only diagnostic setting. Pure outcome tests cover waiting, failed attempts, later success, and closure after success. + +The WebPeer browser smoke test verifies that the production page can prepare a desktop or mobile check, render the QR code, display a separately formatted passphrase, expose zeroed counters without contacting the signalling relay, and return to the unchanged QR code. Pure tests verify that a reconnect which replaces the first connection does not satisfy the same-room additional-connection rule. The focused real-Obsidian E2E test serves the production page from loopback, starts the local Compose Nostr relay, applies the browser-generated Setup URI through visible onboarding in one empty Vault, records the same-room baseline, reuses the unchanged URI in a second empty Vault, and requires the browser to observe another simultaneous active connection before retaining a result screenshot. Existing Commonlib tests remain authoritative for diagnostic WebRTC wrapping, Trystero negotiation, and P2P lifecycle behaviour. + +## Consequences + +- Users can assess whether P2P is plausible on each intended device and network before relying on it. +- Results remain visibly scoped to connectivity rather than synchronisation correctness. +- A fresh test requires a fresh page session and disposable empty Vault; same-room reuse requires another empty Vault and retains cumulative counters. +- CouchDB remains the recommended predictable option when representative P2P checks repeatedly fail. +- Direct device-to-device note transfer remains a separate, optional verification step. diff --git a/package.json b/package.json index 5f2074ca..080b1632 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,9 @@ "test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts", "test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts", "test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts", + "pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer", + "test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts", + "test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p", "test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts", "test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts", "test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts", diff --git a/src/apps/webpeer/README.md b/src/apps/webpeer/README.md index 4f6d9764..8441896a 100644 --- a/src/apps/webpeer/README.md +++ b/src/apps/webpeer/README.md @@ -29,6 +29,28 @@ Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, then open `index Keep the page open while WebPeer is expected to announce or transfer changes. +## P2P connection check + +`check.html` provides a disposable preflight check for P2P connectivity. It creates a random encrypted Setup URI and QR code locally, joins the generated room as a browser reference peer only after explicit confirmation, and displays the browser's WebRTC diagnostic totals beside the QR code. + +Use a dedicated empty Vault for every device: + +1. Select desktop or mobile, then prepare the check. +2. Start the browser connection monitor. +3. Open or scan the Setup URI in an empty Vault with Self-hosted LiveSync installed and enabled. +4. Enter the separately displayed Setup URI passphrase. +5. Keep both peers open for the observation period and watch the successful-connection total. +6. Use **Show the Setup QR again** to return to the existing configuration without regenerating it. +7. For the clearest device comparison, start a fresh check before testing the other device. + +A successful total greater than zero means that the browser and target established at least one WebRTC connection. It does not verify note synchronisation, sustained connectivity, or a direct desktop-to-mobile path. If representative checks repeatedly do not connect, CouchDB is the more predictable synchronisation option. An optional final check can use two disposable empty Vaults to verify a note round trip directly between the user's devices. + +After the first device connects, **Try another device without resetting** keeps the browser, room, credentials, first device, and cumulative counters in place. The page records a local baseline, shows the same QR code for another empty Vault, and reports an additional connection only after both a new successful connection state and another simultaneous active connection appear. Keep the first device connected. Connections are not device identities, so this same-room route is convenient but a fresh check remains easier to interpret because reconnect activity is isolated. + +Serve the production build over HTTPS or from `localhost`. Downloading `check.html` alone is not supported because the page uses built module assets and origin-scoped browser storage. The generated credentials are temporary and must not replace the settings of a production Vault. + +The detailed scope and result semantics are recorded in the [browser-assisted P2P connection-check ADR](../../../docs/adr/2026_07_p2p_connection_check.md). + ## Storage and lifecycle WebPeer stores its settings, metadata, and chunks in storage belonging to the page origin. Consequently: @@ -79,11 +101,21 @@ npm run test:unit --workspace webpeer npm run test:browser --workspace webpeer ``` +The focused browser-to-Obsidian E2E test builds both production artefacts, manages the local Compose P2P relay, applies the browser-generated Setup URI to two isolated empty Vaults without resetting the browser room, and retains the successful additional-device result under the Obsidian diagnostics directory: + +```bash +npm run test:e2e:obsidian:p2p-connection-check:services +``` + +Configure `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the E2E runner cannot discover them automatically. + The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary. ## Composition -`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls. +The WebPeer production bundle has two HTML entry points. `index.html` loads `src/main.ts` and the ordinary browser peer, while `check.html` loads `src/check.ts` and the isolated P2P connection check. The ordinary page links to the check, so it is a WebPeer feature without sharing its saved settings or runtime session. + +`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls. The connection-check modules create an isolated runtime with in-memory test settings and derive user-visible results from Commonlib's browser-side diagnostic events. Both entry points use the same WebPeer visual foundation. ## Licence diff --git a/src/apps/webpeer/check.html b/src/apps/webpeer/check.html new file mode 100644 index 00000000..785d748c --- /dev/null +++ b/src/apps/webpeer/check.html @@ -0,0 +1,18 @@ + + + + + + + + P2P connection check · Self-hosted LiveSync + + + +
+ + + diff --git a/src/apps/webpeer/index.html b/src/apps/webpeer/index.html index 17531554..f2643259 100644 --- a/src/apps/webpeer/index.html +++ b/src/apps/webpeer/index.html @@ -6,7 +6,11 @@ - Peer-to-Peer Daemon on Browser + + WebPeer · Self-hosted LiveSync @@ -14,4 +18,4 @@ - \ No newline at end of file + diff --git a/src/apps/webpeer/src/P2PCheck.svelte b/src/apps/webpeer/src/P2PCheck.svelte new file mode 100644 index 00000000..670b306f --- /dev/null +++ b/src/apps/webpeer/src/P2PCheck.svelte @@ -0,0 +1,601 @@ + + + + + + +
+
+ Self-hosted LiveSync · WebPeer +

P2P connection check

+

+ See whether one LiveSync device can establish a WebRTC connection to this browser on + the current network. +

+
+ + Empty test Vaults only. + This is a disposable connectivity check, not a synchronisation or backup test. +
+
+ +
+ +
+

Choose one target

+

Which device will connect to this browser?

+

+ Check desktop and mobile separately. A fresh test gives each device its own random + room and clear diagnostic counters. +

+ +
+ Target device + + +
+ + +

+ Preparing generates and encrypts everything in this browser. It does not contact + the signalling relay. +

+ {#if preparationError} + + {/if} +
+
+ + {#if setup} +
+ +
+

Set up the empty Vault

+

+ {additionalDeviceAttempt + ? "Use this same one-off configuration on another device" + : `Open this one-off configuration on ${targetLabel}`} +

+ {#if additionalDeviceAttempt} +

+ Keep the browser and first device open. In another new empty Vault, scan + this same QR code or open the same Setup URI, then enter the same separate + passphrase. +

+

+ The room, credentials, connection, and existing counters have not been + reset. The additional-device result uses the values recorded when you + selected the button. +

+ {:else} +

+ In a new empty Vault with Self-hosted LiveSync installed and enabled, scan + the QR code or open the Setup URI. Enter the separate passphrase when + prompted. +

+ {/if} + +
+
+ {additionalDeviceAttempt +

+ {additionalDeviceAttempt + ? "This is the original encrypted Setup URI; it was not regenerated." + : "QR contains the encrypted Setup URI only."} +

+
+ +
+ +
+ + +
+

Type this when LiveSync asks to decrypt the Setup URI.

+ + + +
+ + Open in Obsidian +
+ {#if copyError} + + {/if} +
+
+ +
+
+
Target
+
{targetLabel}
+
+
+
Test Group ID
+
{setup.groupId}
+
+
+
Signalling relay
+
{setup.relay}
+
+
+
+
+ +
+ +
+

Watch the browser diagnostics

+

Start monitoring, then complete setup on {targetLabel}

+

+ Starting monitoring joins the configured signalling relay. Keep this page and + the target Vault open for at least {observationSeconds} seconds. +

+ + + +
+ +
+

{outcomeCopy.title}

+

{outcomeCopy.body}

+ {#if monitorActive} + {elapsedSeconds}s observed · {activeConnections} currently connected + {/if} + {#if monitorError} + {monitorError} + {/if} +
+
+ +
+
+ New + {diagnostics.totalNewConnections} + connection states +
+
+ Successful + {diagnostics.totalSuccessfulConnections} + connection states +
+
+ Failed + {diagnostics.totalFailedConnections} + connection states +
+
+ Closed + {diagnostics.totalClosedConnections} + connection states +
+
+ +

+ These are negotiation events, not device counts. Successful > 0 + is the connection signal; later failures or closures do not erase it. +

+ + {#if additionalProgress && additionalOutcomeCopy} +
+ +
+

Another device in this room

+

{additionalOutcomeCopy.title}

+

{additionalOutcomeCopy.body}

+ + {additionalElapsedSeconds}s observed · + + +{additionalProgress.successfulConnections} successful + + · + + +{additionalProgress.activeConnections} active + + +
+
+

+ Same-room counters remain cumulative and connections are not device counts. + The first device must stay connected; a fresh room remains the clearest + per-device comparison. +

+ {/if} + +
+ + {#if outcome === "connected" && !additionalDeviceAttempt} + + {/if} + +
+
+
+ + + {/if} + + +
diff --git a/src/apps/webpeer/src/P2PCheckSession.ts b/src/apps/webpeer/src/P2PCheckSession.ts new file mode 100644 index 00000000..2c49569b --- /dev/null +++ b/src/apps/webpeer/src/P2PCheckSession.ts @@ -0,0 +1,70 @@ +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + EVENT_SERVER_STATUS, + type P2PServerInfo, +} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; +import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; + +import { WEBPEER_SETTINGS_KEY } from "./WebPeerPersistence"; +import { WebPeerRuntime } from "./WebPeerRuntime"; + +export const P2P_CHECK_SYSTEM_VAULT_NAME = "p2p-livesync-connection-check"; + +function createMemoryStore(settings: ObsidianLiveSyncSettings): SimpleStore { + const values = new Map([[WEBPEER_SETTINGS_KEY, settings]]); + return { + db: Promise.resolve(undefined), + get: async (key) => values.get(key), + set: async (key, value) => { + values.set(key, value); + }, + delete: async (key) => { + values.delete(key); + }, + keys: async (from, to, count) => { + const selected = [...values.keys()] + .sort() + .filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to)); + return count === undefined ? selected : selected.slice(0, count); + }, + }; +} + +export class P2PCheckSession { + private runtime?: WebPeerRuntime; + private removeStatusListener?: () => void; + + async start( + settings: ObsidianLiveSyncSettings, + browserDeviceName: string, + onStatus: (status: P2PServerInfo) => void + ): Promise { + if (this.runtime) { + throw new Error("This P2P connection-check session has already started"); + } + + const runtime = new WebPeerRuntime({ + store: createMemoryStore(settings), + deviceName: browserDeviceName, + systemVaultName: P2P_CHECK_SYSTEM_VAULT_NAME, + }); + this.runtime = runtime; + this.removeStatusListener = runtime.events.onEvent(EVENT_SERVER_STATUS, onStatus); + + try { + await runtime.start(); + await runtime.currentReplicator.makeSureOpened(); + } catch (error) { + await this.stop(); + throw error; + } + } + + async stop(): Promise { + this.removeStatusListener?.(); + this.removeStatusListener = undefined; + const runtime = this.runtime; + this.runtime = undefined; + await runtime?.shutdown(); + } +} diff --git a/src/apps/webpeer/src/P2PCheckSetup.ts b/src/apps/webpeer/src/P2PCheckSetup.ts new file mode 100644 index 00000000..9a5c8b69 --- /dev/null +++ b/src/apps/webpeer/src/P2PCheckSetup.ts @@ -0,0 +1,166 @@ +import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { + P2P_DEFAULT_SETTINGS, + PREFERRED_BASE, + createNewVaultSettings, + type ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { generateP2PRoomId } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; + +export const P2P_CHECK_APP_ID = "self-hosted-livesync-p2p-check-v1"; +export const P2P_CHECK_REMOTE_NAME = "P2P connection check"; + +export type P2PCheckTarget = "desktop" | "mobile"; + +export interface GeneratedP2PCheckSetup { + readonly target: P2PCheckTarget; + readonly setupURI: string; + readonly setupPassphrase: string; + readonly groupId: string; + readonly relay: string; + readonly browserDeviceName: string; + readonly browserSettings: ObsidianLiveSyncSettings; +} + +export interface P2PCheckSetupOptions { + readonly relay?: string; +} + +export interface P2PCheckPageLocation { + readonly hostname: string; + readonly search: string; +} + +interface SharedP2PCheckCredentials { + readonly groupId: string; + readonly p2pPassphrase: string; + readonly vaultPassphrase: string; +} + +const READABLE_SECRET_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"; +const RANDOM_BYTE_ACCEPTANCE_LIMIT = + Math.floor(256 / READABLE_SECRET_ALPHABET.length) * READABLE_SECRET_ALPHABET.length; + +function generateReadableSecret(length: number): string { + const crypto = compatGlobal.crypto; + if (!crypto) { + throw new Error("Web Crypto is required to prepare a P2P connection check"); + } + + let result = ""; + const bytes = new Uint8Array(Math.max(16, length)); + while (result.length < length) { + crypto.getRandomValues(bytes); + for (const byte of bytes) { + if (byte >= RANDOM_BYTE_ACCEPTANCE_LIMIT) { + continue; + } + result += READABLE_SECRET_ALPHABET[byte % READABLE_SECRET_ALPHABET.length]; + if (result.length === length) { + break; + } + } + } + return result; +} + +function generateSetupPassphrase(): string { + return generateReadableSecret(16).match(/.{4}/g)!.join("-"); +} + +function createSettings( + credentials: SharedP2PCheckCredentials, + options: { + readonly autoStart: boolean; + readonly relay: string; + readonly useDiagnostics: boolean; + } +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, { + isConfigured: true, + encrypt: true, + passphrase: credentials.vaultPassphrase, + usePathObfuscation: true, + P2P_Enabled: true, + P2P_AppID: P2P_CHECK_APP_ID, + P2P_roomID: credentials.groupId, + P2P_passphrase: credentials.p2pPassphrase, + P2P_relays: options.relay, + P2P_AutoStart: options.autoStart, + P2P_AutoBroadcast: false, + P2P_DevicePeerName: "", + P2P_useDiagRTC: options.useDiagnostics, + }); + upsertRemoteConfigurationInPlace(settings, "p2p", { + name: P2P_CHECK_REMOTE_NAME, + activate: true, + activateForP2P: true, + }); + return settings; +} + +export function resolveLocalP2PCheckRelayOverride(location: P2PCheckPageLocation): string | undefined { + const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + if (!loopbackHosts.has(location.hostname.toLowerCase())) { + return undefined; + } + + const requestedRelay = new URLSearchParams(location.search).get("relay")?.trim(); + if (!requestedRelay) { + return undefined; + } + + try { + const relay = new URL(requestedRelay); + if (relay.protocol !== "ws:" && relay.protocol !== "wss:") { + return undefined; + } + return relay.href; + } catch { + return undefined; + } +} + +export async function generateP2PCheckSetup( + target: P2PCheckTarget, + options: P2PCheckSetupOptions = {} +): Promise { + const relay = options.relay?.trim() || P2P_DEFAULT_SETTINGS.P2P_relays; + const credentials: SharedP2PCheckCredentials = { + groupId: generateP2PRoomId(), + p2pPassphrase: generateReadableSecret(32), + vaultPassphrase: generateReadableSecret(32), + }; + const deviceSettings = createSettings(credentials, { + autoStart: true, + relay, + useDiagnostics: false, + }); + const browserSettings = createSettings(credentials, { + autoStart: false, + relay, + useDiagnostics: true, + }); + browserSettings.suspendParseReplicationResult = true; + + const setupPassphrase = generateSetupPassphrase(); + const setupURI = await encodeSettingsToSetupURI( + deviceSettings, + setupPassphrase, + ["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"], + true + ); + + return { + target, + setupURI: setupURI.trim(), + setupPassphrase, + groupId: credentials.groupId, + relay: deviceSettings.P2P_relays, + browserDeviceName: `p2p-check-browser-${target}-${credentials.groupId.slice(-3)}`, + browserSettings, + }; +} diff --git a/src/apps/webpeer/src/P2PCheckState.ts b/src/apps/webpeer/src/P2PCheckState.ts new file mode 100644 index 00000000..18d055da --- /dev/null +++ b/src/apps/webpeer/src/P2PCheckState.ts @@ -0,0 +1,129 @@ +import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer"; + +export const P2P_CHECK_OBSERVATION_MILLISECONDS = 60_000; + +export type P2PCheckDiagnostics = P2PServerInfo["diag"]; +export type P2PCheckOutcome = "idle" | "waiting" | "connecting" | "retrying" | "connected" | "inconclusive" | "error"; +export type P2PAdditionalCheckOutcome = "waiting" | "negotiating" | "connected" | "inconclusive"; + +export interface P2PAdditionalCheckBaseline { + readonly activeConnectionIds: readonly string[]; + readonly totalClosedConnections: number; + readonly totalFailedConnections: number; + readonly totalNewConnections: number; + readonly totalSuccessfulConnections: number; +} + +export interface P2PAdditionalCheckProgress { + readonly activeConnections: number; + readonly closedConnections: number; + readonly failedConnections: number; + readonly newConnections: number; + readonly newActiveConnectionIds: readonly string[]; + readonly outcome: P2PAdditionalCheckOutcome; + readonly successfulConnections: number; +} + +export const EMPTY_P2P_CHECK_DIAGNOSTICS: P2PCheckDiagnostics = { + totalNewConnections: 0, + totalFailedConnections: 0, + totalSuccessfulConnections: 0, + totalClosedConnections: 0, + details: {}, +}; + +export function deriveP2PCheckOutcome( + diagnostics: P2PCheckDiagnostics, + monitorActive: boolean, + elapsedMilliseconds: number, + monitorError = false +): P2PCheckOutcome { + if (monitorError) { + return "error"; + } + if (diagnostics.totalSuccessfulConnections > 0) { + return "connected"; + } + if (!monitorActive) { + return "idle"; + } + if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) { + return "inconclusive"; + } + if (diagnostics.totalFailedConnections > 0) { + return "retrying"; + } + if (diagnostics.totalNewConnections > 0 || Object.keys(diagnostics.details).length > 0) { + return "connecting"; + } + return "waiting"; +} + +export function countActiveP2PConnections(diagnostics: P2PCheckDiagnostics): number { + return Object.values(diagnostics.details).filter(({ connectionState }) => connectionState === "connected").length; +} + +function activeConnectionIds(diagnostics: P2PCheckDiagnostics): string[] { + return Object.entries(diagnostics.details) + .filter(([, { connectionState }]) => connectionState === "connected") + .map(([connectionId]) => connectionId); +} + +export function captureP2PAdditionalCheckBaseline(diagnostics: P2PCheckDiagnostics): P2PAdditionalCheckBaseline { + return { + activeConnectionIds: activeConnectionIds(diagnostics), + totalClosedConnections: diagnostics.totalClosedConnections, + totalFailedConnections: diagnostics.totalFailedConnections, + totalNewConnections: diagnostics.totalNewConnections, + totalSuccessfulConnections: diagnostics.totalSuccessfulConnections, + }; +} + +function counterIncrease(current: number, baseline: number): number { + return Math.max(0, current - baseline); +} + +export function deriveP2PAdditionalCheckProgress( + diagnostics: P2PCheckDiagnostics, + baseline: P2PAdditionalCheckBaseline, + elapsedMilliseconds: number +): P2PAdditionalCheckProgress { + const baselineConnectionIds = new Set(baseline.activeConnectionIds); + const currentActiveConnectionIds = activeConnectionIds(diagnostics); + const newActiveConnectionIds = currentActiveConnectionIds.filter( + (connectionId) => !baselineConnectionIds.has(connectionId) + ); + const activeConnections = counterIncrease(currentActiveConnectionIds.length, baseline.activeConnectionIds.length); + const newConnections = counterIncrease(diagnostics.totalNewConnections, baseline.totalNewConnections); + const successfulConnections = counterIncrease( + diagnostics.totalSuccessfulConnections, + baseline.totalSuccessfulConnections + ); + const failedConnections = counterIncrease(diagnostics.totalFailedConnections, baseline.totalFailedConnections); + const closedConnections = counterIncrease(diagnostics.totalClosedConnections, baseline.totalClosedConnections); + + let outcome: P2PAdditionalCheckOutcome = "waiting"; + if (successfulConnections > 0 && activeConnections > 0 && newActiveConnectionIds.length > 0) { + outcome = "connected"; + } else if (elapsedMilliseconds >= P2P_CHECK_OBSERVATION_MILLISECONDS) { + outcome = "inconclusive"; + } else if ( + newConnections > 0 || + successfulConnections > 0 || + failedConnections > 0 || + closedConnections > 0 || + newActiveConnectionIds.length > 0 + ) { + outcome = "negotiating"; + } + + return { + activeConnections, + closedConnections, + failedConnections, + newConnections, + newActiveConnectionIds, + outcome, + successfulConnections, + }; +} diff --git a/src/apps/webpeer/src/SyncMain.svelte b/src/apps/webpeer/src/SyncMain.svelte index a55909ad..2e667bc9 100644 --- a/src/apps/webpeer/src/SyncMain.svelte +++ b/src/apps/webpeer/src/SyncMain.svelte @@ -26,90 +26,84 @@ }); -
-
- {#await synchronised then activeRuntime} - - - {:catch error} -

{error instanceof Error ? error.message : String(error)}

- {/await} -
-
-
- {statusLine} -
-
- {#each $logs as log} -

{log}

- {/each} -
-
-
+ + + - +
+
+
+ + + Self-hosted LiveSync + WebPeer + A browser-hosted peer for temporary P2P transfers. + +
+ + + + + Disposable connectivity check + Try the P2P connection check + + + +
+ +
+
+
+

Browser peer controls

+ Stored in this browser +
+ + {#await synchronised then activeRuntime} + + + {:catch error} + + {/await} +
+ + +
+ +
+ WebPeer is experimental browser software. Keep this page open while it is connected or + transferring changes. +
+
diff --git a/src/apps/webpeer/src/WebPeerRuntime.ts b/src/apps/webpeer/src/WebPeerRuntime.ts index d47c649b..4aa323bc 100644 --- a/src/apps/webpeer/src/WebPeerRuntime.ts +++ b/src/apps/webpeer/src/WebPeerRuntime.ts @@ -22,6 +22,8 @@ import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersisten export interface WebPeerRuntimeOptions { context?: ServiceContext; store?: SimpleStore; + deviceName?: string; + systemVaultName?: string; } function addToList(item: string, list: string): string { @@ -55,12 +57,12 @@ export class WebPeerRuntime { private startPromise?: Promise; private shutdownPromise?: Promise; - constructor(options: WebPeerRuntimeOptions = {}) { + constructor(private readonly options: WebPeerRuntimeOptions = {}) { const persistence = createWebPeerPersistence(options.store); this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage }); this.services = createLiveSyncBrowserServiceHub({ context: this.context, - getSystemVaultName: () => WEBPEER_STORE_NAME, + getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME, settings: persistence.settings, restart: { schedule: () => this.scheduleRestart(), @@ -104,6 +106,10 @@ export class WebPeerRuntime { private async startRuntime(): Promise { await this.services.setting.loadSettings(); + const deviceName = this.options.deviceName?.trim(); + if (deviceName) { + this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName); + } const opened = await this.services.database.openDatabase({ replicator: this.services.replicator, databaseEvents: this.services.databaseEvents, diff --git a/src/apps/webpeer/src/app.css b/src/apps/webpeer/src/app.css index 85b8da69..1349529c 100644 --- a/src/apps/webpeer/src/app.css +++ b/src/apps/webpeer/src/app.css @@ -1,112 +1,505 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - --background-primary: #ffffff; - --background-primary-alt: #e9e9e9; - --size-4-1: 0.25em; - --tag-background: #f0f0f0; - --tag-border-width: 1px; - --tag-border-color: #cfffdd; - --background-modifier-success: #d4f3e9; - --background-secondary: #f0f0f0; - --background-modifier-error: #f8d7da; - --background-modifier-error-hover: #f5c6cb; - --interactive-accent: #007bff; - --interactive-accent-hover: #0056b3; - --text-normal: #333; - --text-warning: #f0ad4e; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} - -a:hover { - color: #535bf2; -} - -body { - margin: 0; - padding: 0; - display: flex; - min-width: 320px; - min-height: 100vh; - box-sizing: border-box; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -.card { - padding: 2em; -} - #app { - margin: 0; - padding: 0; - text-align: center; - display: flex; - flex-grow: 1; + min-height: 100vh; } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; +.webpeer-shell { + width: min(92rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 1.5rem; } -button:hover { - border-color: #646cff; +.webpeer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.25rem; + margin-bottom: 1.25rem; + padding: 1rem 1.1rem 1rem 1.25rem; + border: 1px solid rgba(204, 214, 227, 0.9); + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow); + backdrop-filter: blur(14px); } -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; +.brand-lockup { + display: flex; + align-items: center; + gap: 0.9rem; + min-width: 0; } -input, -select { - border-radius: 8px; - border: 1px solid #1a1a1a; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - transition: border-color 0.25s; +.brand-lockup > span:last-child, +.connection-check-link > span:nth-child(2) { + display: grid; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } +.brand-lockup small, +.connection-check-link small, +.section-kicker, +.panel-meta p { + color: var(--blue); + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.11em; + text-transform: uppercase; +} - a:hover { - color: #747bff; - } +.brand-lockup strong { + color: var(--navy); + font-size: 1.45rem; + line-height: 1.05; + letter-spacing: -0.03em; +} - button { - background-color: #f9f9f9; - } -} \ No newline at end of file +.brand-description { + margin-top: 0.18rem; + overflow: hidden; + color: var(--muted); + font-size: 0.82rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-mark { + position: relative; + display: grid; + place-items: center; + width: 3.2rem; + height: 3.2rem; + flex: 0 0 auto; + border-radius: 1rem; + background: var(--navy); + box-shadow: 0 10px 24px rgba(18, 35, 63, 0.2); +} + +.brand-mark::before, +.brand-mark::after, +.brand-mark span { + position: absolute; + border: 2px solid #ffffff; + border-radius: 50%; + content: ""; +} + +.brand-mark::before { + width: 1.6rem; + height: 1.6rem; + opacity: 0.45; +} + +.brand-mark::after { + width: 0.7rem; + height: 0.7rem; + border-color: #8eb1ff; + background: #8eb1ff; +} + +.brand-mark span { + width: 2.25rem; + height: 2.25rem; + border-width: 1px; + opacity: 0.28; +} + +.connection-check-link { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + max-width: 25rem; + padding: 0.75rem 0.85rem; + color: #ffffff; + border-radius: 0.95rem; + background: var(--navy); + box-shadow: 0 10px 26px rgba(18, 35, 63, 0.16); + text-decoration: none; + transition: + background 140ms ease, + transform 140ms ease, + box-shadow 140ms ease; +} + +.connection-check-link:hover { + color: #ffffff; + background: #1d385f; + box-shadow: 0 13px 30px rgba(18, 35, 63, 0.22); + transform: translateY(-1px); +} + +.connection-check-link small { + color: #8eb1ff; +} + +.connection-check-link strong { + margin-top: 0.12rem; + font-size: 0.9rem; + line-height: 1.2; +} + +.check-mark { + display: grid; + place-items: center; + width: 2.15rem; + height: 2.15rem; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 0.7rem; + color: #b8ceff; + background: rgba(255, 255, 255, 0.08); + font-size: 1.15rem; +} + +.check-arrow { + color: #b8ceff; + font-size: 1.15rem; +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(22rem, 0.8fr); + align-items: start; + gap: 1.25rem; +} + +.workspace-card { + min-width: 0; + border: 1px solid rgba(204, 214, 227, 0.9); + border-radius: 1.35rem; + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--shadow); +} + +.control-card { + padding: clamp(1.35rem, 3vw, 2rem); +} + +.panel-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--line); +} + +.panel-meta p { + margin: 0; +} + +.panel-meta span { + padding: 0.35rem 0.55rem; + color: #48627f; + border: 1px solid #cfdae7; + border-radius: 999px; + background: #f5f8fb; + font-size: 0.72rem; + font-weight: 700; +} + +.control-card article { + color: var(--ink); +} + +.control-card article > h1 { + margin: 0 0 1.15rem; + color: var(--navy); + font-size: clamp(2rem, 5vw, 3.5rem); + line-height: 1; + letter-spacing: -0.045em; +} + +.control-card h2 { + margin: 1.45rem 0 0.75rem; + padding-bottom: 0.45rem; + color: var(--navy); + border-bottom-color: var(--line); + font-size: 1.15rem; + letter-spacing: -0.015em; +} + +.control-card p { + color: var(--muted); + line-height: 1.62; +} + +.control-card .panel-meta p { + color: var(--blue); + line-height: 1.2; +} + +.control-card details { + margin: 0.8rem 0 1rem; + padding: 0.85rem 1rem; + border: 1px solid var(--line); + border-radius: 0.9rem; + background: var(--surface-subtle); +} + +.control-card details[open] { + background: #fbfcfe; +} + +.control-card summary { + padding: 0.12rem 0; +} + +.control-card details[open] > summary { + margin-bottom: 0.7rem; +} + +.control-card table { + width: 100%; + border-spacing: 0 0.35rem; +} + +.control-card th, +.control-card td { + padding: 0.55rem 0.4rem; + vertical-align: top; +} + +.control-card th { + width: 32%; + color: #40536b; + font-size: 0.82rem; + text-align: left; +} + +.control-card td { + color: var(--ink); +} + +.control-card input:not([type="checkbox"]):not([type="radio"]) { + min-width: min(100%, 18rem); + max-width: 100%; + color: var(--ink); + background: var(--surface); +} + +.control-card input[type="checkbox"] { + width: 1.1rem; + height: 1.1rem; +} + +.control-card label.is-dirty { + border-radius: 0.55rem; + background: var(--red-soft); +} + +.control-card .important { + color: var(--red); +} + +.control-card .important-sub { + color: var(--amber); +} + +.control-card .browser-p2p-transport-settings { + margin-top: 1.3rem; + padding-top: 0.3rem; + border-top: 1px solid var(--line); +} + +.control-card .browser-p2p-transport-settings label { + padding: 0.25rem; + border-radius: 0.55rem; +} + +.control-card .browser-p2p-transport-settings span { + color: #40536b; + font-size: 0.82rem; + font-weight: 700; +} + +.runtime-error { + padding: 1rem; + color: var(--red); + border: 1px solid #efb1b1; + border-radius: 0.9rem; + background: var(--red-soft); +} + +.runtime-error p { + margin: 0.35rem 0 0; +} + +.log-card { + position: sticky; + top: 1.25rem; + overflow: hidden; + background: var(--navy); +} + +.log-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.section-kicker { + margin: 0 0 0.25rem; + color: #8eb1ff; +} + +.log-header h2 { + margin: 0; + color: #ffffff; + font-size: 1.4rem; + letter-spacing: -0.025em; +} + +.status-pill { + display: flex; + align-items: center; + gap: 0.45rem; + max-width: 55%; + padding: 0.42rem 0.62rem; + overflow: hidden; + color: #dbe6f5; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + font-size: 0.72rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-pill > span { + width: 0.48rem; + height: 0.48rem; + flex: 0 0 auto; + border-radius: 50%; + background: #8eb1ff; + box-shadow: 0 0 0 0.2rem rgba(142, 177, 255, 0.13); +} + +.logslist { + min-height: 34rem; + max-height: calc(100vh - 10rem); + padding: 1rem 1.1rem 1.2rem; + overflow: auto; + color: #cad7e8; + background: + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), + var(--navy); + background-size: 100% 1.8rem; + scrollbar-color: #4e6280 transparent; +} + +.log-entry, +.empty-log { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.72rem; + line-height: 1.8rem; + text-align: left; +} + +.log-entry { + color: #cad7e8; +} + +.empty-log { + color: #8294ac; +} + +.webpeer-footer { + max-width: 48rem; + margin: 1.3rem auto 0; + color: var(--muted); + font-size: 0.78rem; + text-align: center; +} + +@media (max-width: 1050px) { + .workspace-grid { + grid-template-columns: 1fr; + } + + .log-card { + position: static; + } + + .logslist { + min-height: 18rem; + max-height: 28rem; + } +} + +@media (max-width: 700px) { + .webpeer-shell { + width: min(100% - 1rem, 46rem); + padding-top: 0.5rem; + } + + .webpeer-header { + align-items: stretch; + flex-direction: column; + padding: 1rem; + } + + .connection-check-link { + width: 100%; + max-width: none; + } + + .control-card { + padding: 1rem; + } + + .control-card table.settings, + .control-card table.settings tbody, + .control-card table.settings tr, + .control-card table.settings th, + .control-card table.settings td { + display: block; + width: 100%; + } + + .control-card table.settings tr { + padding: 0.6rem 0; + border-bottom: 1px solid var(--line); + } + + .control-card table.settings th, + .control-card table.settings td { + min-height: 0; + padding: 0.2rem 0; + } + + .control-card table.peers { + display: block; + overflow-x: auto; + } + + .panel-meta { + align-items: flex-start; + flex-direction: column; + gap: 0.5rem; + } +} + +@media (max-width: 430px) { + .brand-description { + white-space: normal; + } + + .panel-meta span { + border-radius: 0.55rem; + } + + .log-header { + flex-direction: column; + } + + .status-pill { + max-width: 100%; + } +} diff --git a/src/apps/webpeer/src/check.css b/src/apps/webpeer/src/check.css new file mode 100644 index 00000000..c51a377c --- /dev/null +++ b/src/apps/webpeer/src/check.css @@ -0,0 +1,631 @@ +.setup-card h2:focus-visible { + outline: 3px solid rgba(42, 102, 220, 0.38); + outline-offset: 2px; +} + +.check-shell { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 4.5rem 0 3rem; +} + +.hero { + max-width: 51rem; + margin: 0 auto 2.4rem; + text-align: center; +} + +.eyebrow, +.section-kicker { + color: var(--blue); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.eyebrow { + text-decoration: none; +} + +.eyebrow:hover { + text-decoration: underline; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin: 0.8rem 0 1rem; + color: var(--navy); + font-size: clamp(2.45rem, 7vw, 4.5rem); + line-height: 0.98; + letter-spacing: -0.045em; +} + +.hero-copy { + max-width: 43rem; + margin: 0 auto 1.5rem; + color: var(--muted); + font-size: clamp(1.05rem, 2.2vw, 1.3rem); +} + +.scope-note { + display: inline-flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.35rem; + padding: 0.75rem 1rem; + border: 1px solid #e8d8a7; + border-radius: 999px; + color: #765119; + background: #fff9e8; + font-size: 0.92rem; +} + +.step-card, +.next-step { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 1.35rem; + margin-top: 1.25rem; + padding: clamp(1.35rem, 4vw, 2.4rem); + border: 1px solid rgba(204, 214, 227, 0.9); + border-radius: 1.35rem; + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--shadow); +} + +.step-number { + display: grid; + place-items: center; + width: 2.35rem; + height: 2.35rem; + border-radius: 50%; + color: #ffffff; + background: var(--navy); + font-weight: 800; +} + +.step-content { + min-width: 0; +} + +.section-kicker { + margin-bottom: 0.35rem; +} + +h2 { + margin-bottom: 0.65rem; + color: var(--navy); + font-size: clamp(1.35rem, 3vw, 1.85rem); + line-height: 1.2; + letter-spacing: -0.02em; +} + +h3 { + color: var(--navy); +} + +.section-copy, +.field-help, +.privacy-line, +.counter-note, +.next-step p, +footer { + color: var(--muted); +} + +.section-copy { + max-width: 51rem; + margin-bottom: 1.4rem; +} + +.target-picker { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; + margin: 0 0 1.2rem; + padding: 0; + border: 0; +} + +.target-option { + display: flex; + align-items: center; + gap: 0.8rem; + min-height: 5.3rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 0.9rem; + background: #fbfcfe; + cursor: pointer; + transition: + border-color 140ms ease, + background 140ms ease, + transform 140ms ease; +} + +.target-option:hover { + border-color: #9cb7eb; + transform: translateY(-1px); +} + +.target-option.chosen { + border-color: var(--blue); + background: var(--blue-soft); + box-shadow: inset 0 0 0 1px var(--blue); +} + +.target-option input { + width: 1rem; + height: 1rem; + accent-color: var(--blue); +} + +.target-option strong, +.target-option small { + display: block; +} + +.target-option small { + margin-top: 0.2rem; + color: var(--muted); +} + +.target-icon { + color: var(--blue); + font-size: 1.6rem; +} + +.target-icon.mobile { + font-size: 1.9rem; +} + +.primary-action, +.secondary-action, +.credential-panel button { + border: 1px solid transparent; + padding: 0.72rem 1rem; +} + +.primary-action { + color: #ffffff; + background: var(--blue); + box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18); +} + +.primary-action:hover:not(:disabled) { + background: var(--blue-dark); +} + +.privacy-line { + margin: 0.8rem 0 0; + font-size: 0.83rem; +} + +.inline-error { + margin: 0.8rem 0 0; + color: var(--red); + font-weight: 650; +} + +.setup-grid { + display: grid; + grid-template-columns: minmax(14rem, 0.72fr) minmax(0, 1.28fr); + gap: 1.25rem; +} + +.qr-panel, +.credential-panel { + border: 1px solid var(--line); + border-radius: 1rem; + background: #fbfcfe; +} + +.qr-panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.qr-panel img { + display: block; + width: min(100%, 31rem); + height: auto; + border: 0.7rem solid #ffffff; + image-rendering: pixelated; +} + +.qr-panel p { + margin: 0.6rem 0 0; + color: var(--muted); + font-size: 0.8rem; + text-align: center; +} + +.credential-panel { + padding: 1.2rem; +} + +.credential-panel label { + display: block; + margin: 0 0 0.45rem; + color: var(--navy); + font-size: 0.83rem; + font-weight: 800; +} + +.credential-panel label:not(:first-child) { + margin-top: 1.1rem; +} + +.credential-panel input, +.credential-panel textarea { + width: 100%; + border: 1px solid #bdc9d8; + border-radius: 0.65rem; + padding: 0.75rem 0.85rem; + color: #213048; + background: #ffffff; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.86rem; +} + +.credential-panel input { + font-size: 1.02rem; + font-weight: 750; + letter-spacing: 0.06em; +} + +.credential-panel textarea { + min-height: 7rem; + resize: vertical; + overflow-wrap: anywhere; +} + +.copy-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.55rem; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin-top: 0.65rem; +} + +.credential-panel button, +.secondary-action { + color: #26405e; + border-color: #bdc9d8; + background: #ffffff; +} + +.credential-panel button:hover, +.secondary-action:hover:not(:disabled) { + border-color: var(--blue); + color: var(--blue-dark); +} + +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.68rem 1rem; + color: #ffffff; + background: var(--navy); + text-decoration: none; +} + +.button-link:hover { + background: #1d385f; +} + +.field-help { + margin: 0.4rem 0 0; + font-size: 0.8rem; +} + +.reuse-note { + margin: -0.35rem 0 1.2rem; + padding: 0.8rem 0.9rem; + border: 1px solid #acc1ec; + border-radius: 0.8rem; + color: #34547c; + background: var(--blue-soft); + font-size: 0.84rem; +} + +.session-details { + display: grid; + grid-template-columns: 0.8fr 1fr 1.5fr; + gap: 0.7rem; + margin: 1rem 0 0; +} + +.session-details div { + min-width: 0; + padding: 0.7rem 0.8rem; + border-radius: 0.7rem; + background: #f2f5f9; +} + +.session-details dt { + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; +} + +.session-details dd { + margin: 0.25rem 0 0; + overflow-wrap: anywhere; + color: var(--navy); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.8rem; +} + +.monitor-action { + margin-bottom: 1rem; +} + +.outcome { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.8rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 0.9rem; + background: #f5f7fa; +} + +.outcome.success { + border-color: #9ad6c2; + background: var(--green-soft); +} + +.outcome.warning { + border-color: #e7cd83; + background: var(--amber-soft); +} + +.outcome.error { + border-color: #efb1b1; + background: var(--red-soft); +} + +.outcome.progress { + border-color: #acc1ec; + background: var(--blue-soft); +} + +.outcome-mark { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + color: #ffffff; + background: #7b8796; + font-weight: 900; +} + +.outcome.success .outcome-mark { + background: var(--green); +} + +.outcome.warning .outcome-mark { + background: var(--amber); +} + +.outcome.error .outcome-mark { + background: var(--red); +} + +.outcome.progress .outcome-mark { + background: var(--blue); +} + +.outcome h3 { + margin: 0 0 0.2rem; + font-size: 1rem; +} + +.outcome p, +.outcome small, +.outcome code { + display: block; + margin: 0; + color: #4f6074; +} + +.outcome small { + margin-top: 0.45rem; + font-weight: 700; +} + +.outcome code { + margin-top: 0.45rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.65rem; + margin-top: 0.8rem; +} + +.metrics article { + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: #fbfcfe; +} + +.metrics span, +.metrics small { + display: block; + color: var(--muted); + font-size: 0.72rem; +} + +.metrics span { + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.metrics strong { + display: block; + margin: 0.2rem 0; + color: var(--navy); + font-size: 1.8rem; + line-height: 1; +} + +.metrics .successful strong { + color: var(--green); +} + +.metrics .failed strong { + color: var(--red); +} + +.counter-note { + margin: 0.7rem 0 1rem; + font-size: 0.82rem; +} + +.additional-outcome { + margin-top: 1rem; +} + +.additional-outcome .section-kicker { + margin-bottom: 0.25rem; +} + +.additional-caveat { + margin-bottom: 1rem; +} + +.session-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.next-step { + display: block; + margin-top: 1.25rem; + color: #ecf2fb; + border-color: transparent; + background: var(--navy); +} + +.next-step h2, +.next-step h3 { + color: #ffffff; +} + +.next-step .section-kicker { + color: #87adff; +} + +.next-step p { + color: #c5d0df; +} + +.next-step-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem; +} + +.next-step-grid h3 { + margin-bottom: 0.35rem; + font-size: 1rem; +} + +.next-step-grid p { + margin-bottom: 0; +} + +footer { + max-width: 48rem; + margin: 1.6rem auto 0; + font-size: 0.8rem; + text-align: center; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 760px) { + .check-shell { + width: min(100% - 1rem, 46rem); + padding-top: 2.5rem; + } + + .scope-note { + border-radius: 0.8rem; + } + + .step-card { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .step-number { + width: 2rem; + height: 2rem; + } + + .target-picker, + .setup-grid, + .next-step-grid { + grid-template-columns: 1fr; + } + + .session-details { + grid-template-columns: 1fr; + } + + .metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 430px) { + .copy-row { + grid-template-columns: 1fr; + } + + .copy-row button, + .button-row > *, + .session-actions > *, + .primary-action, + .secondary-action { + width: 100%; + } +} diff --git a/src/apps/webpeer/src/check.ts b/src/apps/webpeer/src/check.ts new file mode 100644 index 00000000..da4c5568 --- /dev/null +++ b/src/apps/webpeer/src/check.ts @@ -0,0 +1,12 @@ +import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { mount } from "svelte"; + +import P2PCheck from "./P2PCheck.svelte"; +import "./theme.css"; +import "./check.css"; + +const app = mount(P2PCheck, { + target: _activeDocument.getElementById("app")!, +}); + +export default app; diff --git a/src/apps/webpeer/src/main.ts b/src/apps/webpeer/src/main.ts index fa028b30..c762ae2e 100644 --- a/src/apps/webpeer/src/main.ts +++ b/src/apps/webpeer/src/main.ts @@ -1,4 +1,5 @@ import { mount } from "svelte"; +import "./theme.css"; import "./app.css"; import App from "./App.svelte"; import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; diff --git a/src/apps/webpeer/src/theme.css b/src/apps/webpeer/src/theme.css new file mode 100644 index 00000000..860d92f7 --- /dev/null +++ b/src/apps/webpeer/src/theme.css @@ -0,0 +1,170 @@ +:root { + font-family: + Inter, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + color: #17253b; + background: #f3f6fa; + line-height: 1.5; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + --ink: #17253b; + --muted: #5d6c80; + --line: #d8e0ea; + --navy: #12233f; + --blue: #2a66dc; + --blue-dark: #1e4fae; + --blue-soft: #eaf1ff; + --green: #14795c; + --green-soft: #e5f6ef; + --amber: #9c6510; + --amber-soft: #fff4d6; + --red: #a33a3a; + --red-soft: #ffeded; + --surface: #ffffff; + --surface-subtle: #f8fafc; + --shadow: 0 18px 55px rgba(24, 42, 72, 0.08); + + /* Browser-hosted aliases required by the shared Obsidian P2P controls. */ + --background-primary: var(--surface); + --background-primary-alt: #edf1f6; + --background-secondary: #f2f5f9; + --background-modifier-border: var(--line); + --background-modifier-success: var(--green-soft); + --background-modifier-error: var(--red-soft); + --background-modifier-error-hover: #f8dada; + --interactive-accent: var(--blue); + --interactive-accent-hover: var(--blue-dark); + --text-normal: var(--ink); + --text-muted: var(--muted); + --text-error: var(--red); + --text-warning: var(--amber); + --input-height: 2.75rem; + --size-4-1: 0.25rem; + --tag-background: #f0f3f7; + --tag-border-width: 1px; + --tag-border-color: var(--line); +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + color: var(--ink); + background: + radial-gradient(circle at 12% 8%, rgba(66, 117, 222, 0.11), transparent 26rem), + #f3f6fa; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +.button-link { + min-height: 2.75rem; + border-radius: 0.7rem; + font-weight: 700; +} + +button { + border: 1px solid #bdc9d8; + padding: 0.68rem 1rem; + color: #26405e; + background: var(--surface); + cursor: pointer; + transition: + border-color 140ms ease, + color 140ms ease, + background 140ms ease, + transform 140ms ease; +} + +button:hover:not(:disabled) { + border-color: var(--blue); + color: var(--blue-dark); +} + +button.mod-cta { + color: #ffffff; + border-color: var(--blue); + background: var(--blue); + box-shadow: 0 8px 20px rgba(42, 102, 220, 0.18); +} + +button.mod-cta:hover:not(:disabled) { + color: #ffffff; + border-color: var(--blue-dark); + background: var(--blue-dark); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +summary:focus-visible, +textarea:focus-visible { + outline: 3px solid rgba(42, 102, 220, 0.38); + outline-offset: 2px; +} + +input:not([type="checkbox"]):not([type="radio"]), +select, +textarea { + border: 1px solid #bdc9d8; + border-radius: 0.65rem; + padding: 0.68rem 0.8rem; + color: var(--ink); + background: var(--surface); +} + +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--blue); +} + +a { + color: var(--blue); +} + +summary { + color: var(--navy); + cursor: pointer; + font-weight: 750; +} + +::selection { + color: var(--navy); + background: #cddcff; +} + +@media (prefers-reduced-motion: reduce) { + #app *, + #app *::before, + #app *::after { + scroll-behavior: auto; + transition: none; + } +} diff --git a/src/apps/webpeer/vite.config.ts b/src/apps/webpeer/vite.config.ts index 75fc3f62..f6ebab41 100644 --- a/src/apps/webpeer/vite.config.ts +++ b/src/apps/webpeer/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ rollupOptions: { input: { index: "index.html", + check: "check.html", // uitest: "uitest.html", }, }, diff --git a/test/apps/webpeer/P2PCheckSetup.unit.spec.ts b/test/apps/webpeer/P2PCheckSetup.unit.spec.ts new file mode 100644 index 00000000..a3114ada --- /dev/null +++ b/test/apps/webpeer/P2PCheckSetup.unit.spec.ts @@ -0,0 +1,131 @@ +import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; +import { describe, expect, it } from "vitest"; + +import { + P2P_CHECK_APP_ID, + generateP2PCheckSetup, + resolveLocalP2PCheckRelayOverride, +} from "@/apps/webpeer/src/P2PCheckSetup"; + +describe("P2P connection-check setup", () => { + it.each(["desktop", "mobile"] as const)( + "creates an isolated, disposable %s Setup URI and diagnostic browser peer", + async (target) => { + const generated = await generateP2PCheckSetup(target); + const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase); + + expect(decoded).not.toBe(false); + if (decoded === false) { + throw new Error("The generated Setup URI could not be decoded"); + } + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + + expect(generated.target).toBe(target); + expect(generated.setupPassphrase).toMatch(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/); + expect(generated.setupURI).toMatch(/^obsidian:\/\/setuplivesync\?settings=/); + expect(effective).toEqual( + expect.objectContaining({ + remoteType: REMOTE_P2P, + isConfigured: true, + encrypt: true, + usePathObfuscation: true, + P2P_Enabled: true, + P2P_AppID: P2P_CHECK_APP_ID, + P2P_roomID: generated.groupId, + P2P_AutoStart: true, + P2P_AutoBroadcast: false, + }) + ); + expect(decoded.P2P_DevicePeerName).toBeUndefined(); + expect(decoded.P2P_useDiagRTC).toBeUndefined(); + expect(effective.passphrase).toHaveLength(32); + expect(effective.P2P_passphrase).toHaveLength(32); + expect(effective.passphrase).not.toBe(effective.P2P_passphrase); + expect(effective.P2P_AutoAccepting).toBe(DEFAULT_SETTINGS.P2P_AutoAccepting); + expect(effective.P2P_AutoSyncPeers).toBe(""); + expect(effective.P2P_AutoWatchPeers).toBe(""); + expect(effective.P2P_SyncOnReplication).toBe(""); + + const remoteConfigurations = Object.values(decoded.remoteConfigurations ?? {}); + expect(remoteConfigurations).toHaveLength(1); + expect(decoded.activeConfigurationId).toBe(remoteConfigurations[0].id); + expect(decoded.P2P_ActiveRemoteConfigurationId).toBe(remoteConfigurations[0].id); + const deviceRemote = ConnectionStringParser.parse(remoteConfigurations[0].uri); + expect(deviceRemote).toEqual( + expect.objectContaining({ + type: "p2p", + settings: expect.objectContaining({ + P2P_AutoStart: true, + P2P_AutoBroadcast: false, + }), + }) + ); + expect("P2P_useDiagRTC" in deviceRemote.settings).toBe(false); + + expect(generated.browserSettings).toEqual( + expect.objectContaining({ + remoteType: REMOTE_P2P, + P2P_Enabled: true, + P2P_AppID: P2P_CHECK_APP_ID, + P2P_roomID: effective.P2P_roomID, + P2P_passphrase: effective.P2P_passphrase, + passphrase: effective.passphrase, + P2P_AutoStart: false, + P2P_AutoBroadcast: false, + P2P_useDiagRTC: true, + }) + ); + expect(generated.browserDeviceName).toMatch(/^p2p-check-browser-(?:desktop|mobile)-/); + const browserConfigurations = Object.values(generated.browserSettings.remoteConfigurations ?? {}); + expect(browserConfigurations).toHaveLength(1); + const browserRemote = ConnectionStringParser.parse(browserConfigurations[0].uri); + expect(browserRemote).toEqual( + expect.objectContaining({ + type: "p2p", + settings: expect.objectContaining({ + P2P_AutoStart: false, + P2P_AutoBroadcast: false, + }), + }) + ); + expect("P2P_useDiagRTC" in browserRemote.settings).toBe(false); + } + ); + + it("creates independent rooms and secrets for separate checks", async () => { + const first = await generateP2PCheckSetup("desktop"); + const second = await generateP2PCheckSetup("desktop"); + + expect(second.groupId).not.toBe(first.groupId); + expect(second.setupPassphrase).not.toBe(first.setupPassphrase); + expect(second.browserSettings.P2P_passphrase).not.toBe(first.browserSettings.P2P_passphrase); + expect(second.browserSettings.passphrase).not.toBe(first.browserSettings.passphrase); + }); + + it("uses the same explicitly selected relay for the Setup URI and browser peer", async () => { + const relay = "ws://127.0.0.1:4010/"; + const generated = await generateP2PCheckSetup("desktop", { relay }); + const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase); + + expect(decoded).not.toBe(false); + if (decoded === false) { + throw new Error("The generated Setup URI could not be decoded"); + } + expect(generated.relay).toBe(relay); + expect(decoded.P2P_relays).toBe(relay); + expect(generated.browserSettings.P2P_relays).toBe(relay); + }); + + it("accepts a relay query override only from a loopback-served check page", () => { + const search = "?relay=ws%3A%2F%2F127.0.0.1%3A4010%2F"; + + expect(resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search })).toBe("ws://127.0.0.1:4010/"); + expect(resolveLocalP2PCheckRelayOverride({ hostname: "localhost", search })).toBe("ws://127.0.0.1:4010/"); + expect(resolveLocalP2PCheckRelayOverride({ hostname: "example.com", search })).toBeUndefined(); + expect( + resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search: "?relay=https%3A%2F%2Fexample.com" }) + ).toBeUndefined(); + }); +}); diff --git a/test/apps/webpeer/P2PCheckState.unit.spec.ts b/test/apps/webpeer/P2PCheckState.unit.spec.ts new file mode 100644 index 00000000..d22bf296 --- /dev/null +++ b/test/apps/webpeer/P2PCheckState.unit.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { + EMPTY_P2P_CHECK_DIAGNOSTICS, + P2P_CHECK_OBSERVATION_MILLISECONDS, + captureP2PAdditionalCheckBaseline, + deriveP2PAdditionalCheckProgress, + deriveP2PCheckOutcome, +} from "@/apps/webpeer/src/P2PCheckState"; + +describe("P2P connection-check outcome", () => { + it("waits for a target after the browser monitor joins the room", () => { + expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, true, 5_000)).toBe("waiting"); + }); + + it("reports an active negotiation before any connection succeeds", () => { + expect( + deriveP2PCheckOutcome( + { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 1, + details: { + "rtc-1": { + connectionState: "connecting", + iceConnectionState: "checking", + }, + }, + }, + true, + 10_000 + ) + ).toBe("connecting"); + }); + + it("keeps a failed attempt retryable until the observation period expires", () => { + const diagnostics = { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 1, + totalFailedConnections: 1, + }; + + expect(deriveP2PCheckOutcome(diagnostics, true, 15_000)).toBe("retrying"); + expect(deriveP2PCheckOutcome(diagnostics, true, P2P_CHECK_OBSERVATION_MILLISECONDS)).toBe("inconclusive"); + }); + + it("lets a later success take precedence over failures and closure", () => { + expect( + deriveP2PCheckOutcome( + { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 2, + totalFailedConnections: 1, + totalSuccessfulConnections: 1, + totalClosedConnections: 1, + }, + true, + P2P_CHECK_OBSERVATION_MILLISECONDS * 2 + ) + ).toBe("connected"); + }); + + it("distinguishes an idle page and a monitor-start error", () => { + expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0)).toBe("idle"); + expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0, true)).toBe("error"); + }); +}); + +describe("same-room additional-device progress", () => { + const firstDeviceDiagnostics = { + ...EMPTY_P2P_CHECK_DIAGNOSTICS, + totalNewConnections: 1, + totalSuccessfulConnections: 1, + details: { + "rtc-a": { + connectionState: "connected" as const, + iceConnectionState: "connected" as const, + }, + }, + }; + + it("captures the counter and currently active connection baseline", () => { + expect(captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics)).toEqual({ + activeConnectionIds: ["rtc-a"], + totalClosedConnections: 0, + totalFailedConnections: 0, + totalNewConnections: 1, + totalSuccessfulConnections: 1, + }); + }); + + it("does not mistake a reconnect from the first peer for an additional device", () => { + const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics); + const progress = deriveP2PAdditionalCheckProgress( + { + ...firstDeviceDiagnostics, + totalNewConnections: 2, + totalSuccessfulConnections: 2, + details: { + "rtc-a": { + connectionState: "closed", + iceConnectionState: "closed", + }, + "rtc-b": { + connectionState: "connected", + iceConnectionState: "connected", + }, + }, + }, + baseline, + 10_000 + ); + + expect(progress).toEqual({ + activeConnections: 0, + closedConnections: 0, + failedConnections: 0, + newConnections: 1, + newActiveConnectionIds: ["rtc-b"], + outcome: "negotiating", + successfulConnections: 1, + }); + }); + + it("requires both a new successful state and another simultaneous active connection", () => { + const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics); + + expect( + deriveP2PAdditionalCheckProgress( + { + ...firstDeviceDiagnostics, + details: { + ...firstDeviceDiagnostics.details, + "rtc-b": { + connectionState: "connected", + iceConnectionState: "connected", + }, + }, + }, + baseline, + 10_000 + ).outcome + ).toBe("negotiating"); + + expect( + deriveP2PAdditionalCheckProgress( + { + ...firstDeviceDiagnostics, + totalNewConnections: 2, + totalSuccessfulConnections: 2, + details: { + ...firstDeviceDiagnostics.details, + "rtc-b": { + connectionState: "connected", + iceConnectionState: "connected", + }, + }, + }, + baseline, + 10_000 + ) + ).toEqual({ + activeConnections: 1, + closedConnections: 0, + failedConnections: 0, + newConnections: 1, + newActiveConnectionIds: ["rtc-b"], + outcome: "connected", + successfulConnections: 1, + }); + }); + + it("marks an additional-device attempt inconclusive after its own observation period", () => { + const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics); + expect( + deriveP2PAdditionalCheckProgress(firstDeviceDiagnostics, baseline, P2P_CHECK_OBSERVATION_MILLISECONDS) + .outcome + ).toBe("inconclusive"); + }); +}); diff --git a/test/apps/webpeer/WebPeerRuntime.unit.spec.ts b/test/apps/webpeer/WebPeerRuntime.unit.spec.ts index 98c5fb06..b92f816f 100644 --- a/test/apps/webpeer/WebPeerRuntime.unit.spec.ts +++ b/test/apps/webpeer/WebPeerRuntime.unit.spec.ts @@ -1,5 +1,5 @@ import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; -import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import { describe, expect, it, vi } from "vitest"; @@ -73,4 +73,28 @@ describe("WebPeer runtime composition", () => { await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened"); }); + + it("isolates a specialised runtime and applies its device name before opening the database", async () => { + const runtime = new WebPeerRuntime({ + store: createMemoryStore(), + deviceName: " p2p-check-browser-desktop-abc ", + systemVaultName: "p2p-check-vault", + }); + vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined); + vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ + ...DEFAULT_SETTINGS, + P2P_AutoStart: false, + P2P_Enabled: false, + }); + const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined); + const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => { + expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc"); + return true; + }); + + await runtime.start(); + + expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault"); + expect(openDatabase).toHaveBeenCalledOnce(); + }); }); diff --git a/test/browser-apps/interop.test.ts b/test/browser-apps/interop.test.ts index 385cd395..e7397016 100644 --- a/test/browser-apps/interop.test.ts +++ b/test/browser-apps/interop.test.ts @@ -392,15 +392,18 @@ Deno.test({ screenshotDirectory, "webpeer-initial.png", ); - await configureP2PPane(webPeerPage.locator(".control"), { - deviceName: webPeerDeviceName, - passphrase: p2pPassphrase, - relay, - room, - turnCredential, - turnServers, - turnUsername, - }); + await configureP2PPane( + webPeerPage.getByRole("region", { name: "WebPeer controls" }), + { + deviceName: webPeerDeviceName, + passphrase: p2pPassphrase, + relay, + room, + turnCredential, + turnServers, + turnUsername, + }, + ); await webPeerPage.getByRole("button", { name: "Connect", exact: true }) .click(); await webPeerPage.getByText("Connected to Signaling Server", { diff --git a/test/browser-apps/webpeer/browser-smoke.test.ts b/test/browser-apps/webpeer/browser-smoke.test.ts index 4b2a9fc3..79800a77 100644 --- a/test/browser-apps/webpeer/browser-smoke.test.ts +++ b/test/browser-apps/webpeer/browser-smoke.test.ts @@ -21,6 +21,12 @@ Deno.test({ timeout: 30_000, }); await page.getByText("No Connection", { exact: true }).waitFor(); + assertEquals( + await page + .getByRole("link", { name: "Try the P2P connection check", exact: true }) + .getAttribute("href"), + "./check.html" + ); await page.getByPlaceholder("anything-you-like").fill("browser-e2e-room"); await page.getByPlaceholder("iphone-16").fill("browser-e2e-peer"); @@ -44,10 +50,7 @@ Deno.test({ await page.getByText("Optional TURN server settings", { exact: true }).click(); assertEquals(await page.getByPlaceholder("turn:turn.example.com:3478").inputValue(), "turn:127.0.0.1:3478"); assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user"); - assertEquals( - await page.getByPlaceholder("Enter TURN credential").inputValue(), - "browser-turn-credential" - ); + assertEquals(await page.getByPlaceholder("Enter TURN credential").inputValue(), "browser-turn-credential"); assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true); assertNoPageFailures(); } finally { @@ -56,3 +59,82 @@ Deno.test({ } }, }); + +Deno.test({ + name: "WebPeer: P2P connection check prepares a local Setup URI and zeroed diagnostics", + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const server = await startStaticServer(webPeerDist); + const browser = await chromium.launch({ headless: true }); + try { + for (const target of ["desktop", "mobile"] as const) { + const page = await browser.newPage(); + const assertNoPageFailures = observePageFailures(page); + try { + await page.goto(`${server.baseUrl}check.html`); + await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({ + timeout: 30_000, + }); + if (target === "mobile") { + await page.locator('input[type="radio"][value="mobile"]').check(); + } + + await page.getByRole("button", { name: `Prepare ${target} check`, exact: true }).click(); + await page.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }).waitFor({ + timeout: 30_000, + }); + + const setupURI = await page.getByLabel("Setup URI", { exact: true }).inputValue(); + const passphrase = await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue(); + const qrSource = await page + .getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }) + .getAttribute("src"); + + assertEquals(setupURI.startsWith("obsidian://setuplivesync?settings="), true); + assertEquals(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/.test(passphrase), true); + assertEquals(qrSource?.startsWith("data:image/"), true); + for (const label of ["Setup URI", "Setup URI passphrase"] as const) { + const credentialField = page.getByLabel(label, { exact: true }); + assertEquals(await credentialField.getAttribute("autocomplete"), "off"); + assertEquals(await credentialField.getAttribute("spellcheck"), "false"); + } + assertEquals(await page.getByTestId("diag-new").textContent(), "0"); + assertEquals(await page.getByTestId("diag-successful").textContent(), "0"); + assertEquals(await page.getByTestId("diag-failed").textContent(), "0"); + assertEquals(await page.getByTestId("diag-closed").textContent(), "0"); + assertEquals( + await page.getByRole("button", { name: "Start connection monitor", exact: true }).isVisible(), + true + ); + assertEquals( + await page + .getByRole("button", { name: "Try another device without resetting", exact: true }) + .count(), + 0 + ); + + await page.locator(".results-card").scrollIntoViewIfNeeded(); + await page.getByRole("button", { name: "Show the Setup QR again", exact: true }).click(); + await waitFor( + async () => + await page + .getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }) + .evaluate((element) => { + const rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.bottom <= document.documentElement.clientHeight; + }), + `The ${target} Setup QR did not return to the viewport` + ); + assertEquals(await page.getByLabel("Setup URI", { exact: true }).inputValue(), setupURI); + assertNoPageFailures(); + } finally { + await page.close(); + } + } + } finally { + await browser.close(); + await server.close(); + } + }, +}); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index e8a749b8..864da52f 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -151,6 +151,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- `test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. +`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay. + `test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan. `test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets. @@ -223,6 +225,8 @@ Useful environment variables: - `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds. - `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds. - `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds. +- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds. +- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`. - `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`. - `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`. - `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port. diff --git a/test/e2e-obsidian/scripts/p2p-connection-check.ts b/test/e2e-obsidian/scripts/p2p-connection-check.ts new file mode 100644 index 00000000..b935e34a --- /dev/null +++ b/test/e2e-obsidian/scripts/p2p-connection-check.ts @@ -0,0 +1,406 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, stat } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import { connect } from "node:net"; +import { dirname, extname, relative, resolve } from "node:path"; +import { chromium, type Browser, type ConsoleMessage, type Page } from "playwright"; + +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + confirmRebuild, + enterSetupURI, + finishInitialisation, + resumeCompatibilityReviewIfShown, + type SetupArtifact, + type SetupCaptureNames, +} from "../runner/setupUri.ts"; +import { obsidianRemoteDebuggingPort } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +const captures: SetupCaptureNames = { scenario: "p2p-connection-check", guide: "p2p-setup" }; +const connectionTimeoutMs = Number(process.env.E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS ?? 60000); +const webPeerDist = resolve(process.cwd(), "src/apps/webpeer/dist"); + +type StaticServer = { + readonly baseUrl: string; + close(): Promise; +}; + +function contentType(path: string): string { + switch (extname(path)) { + case ".css": + return "text/css; charset=utf-8"; + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".json": + case ".map": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + default: + return "application/octet-stream"; + } +} + +function closeServer(server: Server): Promise { + return new Promise((resolveClose, reject) => { + server.close((error) => { + if (error) reject(error); + else resolveClose(); + }); + }); +} + +async function startStaticServer(): Promise { + const distribution = await stat(webPeerDist).catch(() => undefined); + if (!distribution?.isDirectory()) { + throw new Error( + `WebPeer production bundle was not found at ${webPeerDist}. Build the webpeer workspace first.` + ); + } + + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const requestedPath = decodeURIComponent(url.pathname).replace(/^\/+/, "") || "index.html"; + const candidate = resolve(webPeerDist, requestedPath); + const relativePath = relative(webPeerDist, candidate); + if (relativePath.startsWith("..") || relativePath.includes("\0")) { + response.writeHead(404).end("Not found"); + return; + } + + try { + const candidateStat = await stat(candidate); + const filePath = candidateStat.isDirectory() ? resolve(candidate, "index.html") : candidate; + const body = await readFile(filePath); + response.writeHead(200, { + "cache-control": "no-store", + "content-type": contentType(filePath), + }); + response.end(body); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + response.writeHead(404).end("Not found"); + return; + } + throw error; + } + })().catch((error: unknown) => { + response.writeHead(500).end("Internal server error"); + console.error(error instanceof Error ? error.stack : error); + }); + }); + + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolveListen(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await closeServer(server); + throw new Error("The WebPeer static server did not expose a TCP port."); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}/`, + close: async () => await closeServer(server), + }; +} + +async function waitForRelay(relay: string): Promise { + const endpoint = new URL(relay); + if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { + throw new Error(`P2P relay must use ws: or wss:, received ${endpoint.protocol}`); + } + const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80)); + const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname; + const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000); + let lastError: unknown; + let consecutiveConnections = 0; + + while (Date.now() < deadline) { + try { + await new Promise((resolveConnection, reject) => { + const socket = connect({ host, port }); + socket.setTimeout(1000); + socket.once("connect", () => { + socket.destroy(); + resolveConnection(); + }); + socket.once("timeout", () => { + socket.destroy(); + reject(new Error("connection timed out")); + }); + socket.once("error", reject); + }); + consecutiveConnections += 1; + if (consecutiveConnections >= 3) return; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 500)); + } catch (error) { + lastError = error; + consecutiveConnections = 0; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); + } + } + + throw new Error( + `P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : String(lastError)}` + ); +} + +async function readDiagnostics(page: Page): Promise> { + return { + new: (await page.getByTestId("diag-new").textContent())?.trim() ?? "", + successful: (await page.getByTestId("diag-successful").textContent())?.trim() ?? "", + failed: (await page.getByTestId("diag-failed").textContent())?.trim() ?? "", + closed: (await page.getByTestId("diag-closed").textContent())?.trim() ?? "", + }; +} + +async function waitForSuccessfulConnection(page: Page, consoleErrors: string[]): Promise> { + try { + await page.waitForFunction( + () => Number(document.querySelector('[data-testid="diag-successful"]')?.textContent ?? "0") > 0, + undefined, + { timeout: connectionTimeoutMs } + ); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n` + + `Browser diagnostics: ${JSON.stringify(await readDiagnostics(page))}\n` + + `Browser console errors: ${JSON.stringify(consoleErrors)}` + ); + } + await page.getByRole("heading", { name: "P2P connection observed", exact: true }).waitFor({ + timeout: 5000, + }); + return await readDiagnostics(page); +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function npmBinary(): string { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function runNpmScript(script: string, optional = false): Promise { + return new Promise((resolveRun, reject) => { + const child = spawn(npmBinary(), ["run", script], { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0 || optional) { + resolveRun(); + return; + } + reject(new Error(`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`)); + }); + }); +} + +async function runScenario(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + + const relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`; + await waitForRelay(relay); + + const server = await startStaticServer(); + let browser: Browser | undefined; + const vaults: TemporaryVault[] = []; + const sessions: ObsidianLiveSyncSession[] = []; + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + + try { + browser = await chromium.launch({ headless: true }); + const firstVault = await createTemporaryVault("obsidian-livesync-p2p-check-first-e2e-"); + vaults.push(firstVault); + const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message)); + page.on("console", (message: ConsoleMessage) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + const checkUrl = new URL("check.html", server.baseUrl); + checkUrl.searchParams.set("relay", relay); + await page.goto(checkUrl.href); + await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({ timeout: 30000 }); + await page.getByRole("button", { name: "Prepare desktop check", exact: true }).click(); + await page.getByAltText("Setup URI QR code for the desktop check", { exact: true }).waitFor({ + timeout: 30000, + }); + + const artifact: SetupArtifact = { + setupURI: await page.getByLabel("Setup URI", { exact: true }).inputValue(), + setupPassphrase: await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue(), + }; + if (!artifact.setupURI.startsWith("obsidian://setuplivesync?settings=")) { + throw new Error("The browser did not generate a Setup URI."); + } + await page.getByText(relay, { exact: true }).waitFor({ timeout: 5000 }); + await page.getByRole("button", { name: "Start connection monitor", exact: true }).click(); + await page.getByRole("button", { name: "Monitoring is active", exact: true }).waitFor({ timeout: 30000 }); + + const [firstPort, secondPort] = sessionPorts(); + const firstSession = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault: firstVault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(firstPort), + }); + sessions.push(firstSession); + + await enterSetupURI(firstPort, "new", artifact, captures); + await captureAndStartInitialisation(firstPort, "new", captures); + await confirmRebuild(firstPort, captures); + await acknowledgeDisabledOptionalFeatures(firstPort, captures); + const firstSetupState = await finishInitialisation(firstPort, cli.binary, firstSession.cliEnv); + await resumeCompatibilityReviewIfShown(firstPort); + if (!firstSetupState.p2pEnabled || firstSetupState.p2pRelays !== relay) { + throw new Error( + `The first Obsidian device did not apply the browser P2P setup: ${JSON.stringify(firstSetupState)}` + ); + } + + const firstDiagnostics = await waitForSuccessfulConnection(page, consoleErrors); + const tryAnotherDevice = page.getByRole("button", { + name: "Try another device without resetting", + exact: true, + }); + await tryAnotherDevice.click({ timeout: connectionTimeoutMs }); + await page + .getByRole("heading", { name: "Use this same one-off configuration on another device", exact: true }) + .waitFor({ timeout: 5000 }); + await page.getByAltText("Setup URI QR code for another device", { exact: true }).waitFor({ timeout: 5000 }); + if ((await page.getByLabel("Setup URI", { exact: true }).inputValue()) !== artifact.setupURI) { + throw new Error("The additional-device action regenerated or replaced the Setup URI."); + } + + const secondVault = await createTemporaryVault("obsidian-livesync-p2p-check-second-e2e-"); + vaults.push(secondVault); + const secondSession = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault: secondVault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(secondPort), + }); + sessions.push(secondSession); + + await enterSetupURI(secondPort, "new", artifact, captures); + await captureAndStartInitialisation(secondPort, "new", captures); + await confirmRebuild(secondPort, captures); + await acknowledgeDisabledOptionalFeatures(secondPort, captures); + const secondSetupState = await finishInitialisation(secondPort, cli.binary, secondSession.cliEnv); + await resumeCompatibilityReviewIfShown(secondPort); + if ( + !secondSetupState.p2pEnabled || + secondSetupState.p2pRelays !== relay || + secondSetupState.p2pRoomId !== firstSetupState.p2pRoomId + ) { + throw new Error( + `The second Obsidian device did not reuse the browser P2P setup: ${JSON.stringify(secondSetupState)}` + ); + } + + await page.getByRole("heading", { name: "An additional connection was observed", exact: true }).waitFor({ + timeout: connectionTimeoutMs, + }); + const diagnostics = await readDiagnostics(page); + if (pageErrors.length > 0) { + throw new Error(`The browser page reported runtime errors: ${JSON.stringify(pageErrors)}`); + } + + const screenshotPath = resolve( + process.env.E2E_P2P_CHECK_SCREENSHOT ?? + resolve( + process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", + "p2p-connection-check-browser-success.png" + ) + ); + await mkdir(dirname(screenshotPath), { recursive: true }); + await page.locator(".results-card").screenshot({ + animations: "disabled", + caret: "hide", + path: screenshotPath, + }); + + console.log( + `Browser-to-two-Obsidian P2P connection check succeeded through ${relay}. ` + + `First diagnostics: ${JSON.stringify(firstDiagnostics)}; final diagnostics: ${JSON.stringify(diagnostics)}` + ); + console.log(`Browser result screenshot: ${screenshotPath}`); + if (consoleErrors.length > 0) { + console.warn(`Browser console errors after successful connection: ${JSON.stringify(consoleErrors)}`); + } + } finally { + for (const session of sessions.reverse()) { + await session.app.stop().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + for (const vault of vaults.reverse()) { + await vault.dispose().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + await browser?.close().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await server.close().catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } +} + +async function main(): Promise { + const manageP2P = process.argv.includes("--manage-p2p"); + let shouldStopP2P = false; + try { + if (manageP2P) { + await runNpmScript("test:docker-p2p:stop", true); + await runNpmScript("test:docker-p2p:start"); + shouldStopP2P = true; + } + await runScenario(); + } finally { + if (shouldStopP2P) { + await runNpmScript("test:docker-p2p:stop", true); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/updates.md b/updates.md index ecc91fa3..d902e69d 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased +### P2P and experimental browser applications + +#### Improved + +- WebPeer now includes a separate P2P connection check which prepares a disposable encrypted Setup URI and QR code locally, tests desktop and mobile devices in separate random rooms, can reuse the unchanged QR and a recorded baseline for an optional second device in the same room, and displays browser-side WebRTC diagnostic totals without presenting them as note-synchronisation results. + ### Interface #### Fixed