mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-29 14:57:05 +00:00
Add browser P2P connection check
This commit is contained in:
@@ -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,19 @@ 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.
|
||||
`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.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Prepare a disposable Setup URI and check a Self-hosted LiveSync P2P connection from the browser."
|
||||
/>
|
||||
<title>P2P connection check · Self-hosted LiveSync</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/check.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,601 @@
|
||||
<script lang="ts">
|
||||
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import qrcode from "qrcode-generator";
|
||||
import { onDestroy, tick } from "svelte";
|
||||
|
||||
import {
|
||||
generateP2PCheckSetup,
|
||||
resolveLocalP2PCheckRelayOverride,
|
||||
type GeneratedP2PCheckSetup,
|
||||
type P2PCheckTarget,
|
||||
} from "./P2PCheckSetup";
|
||||
import { P2PCheckSession } from "./P2PCheckSession";
|
||||
import {
|
||||
EMPTY_P2P_CHECK_DIAGNOSTICS,
|
||||
P2P_CHECK_OBSERVATION_MILLISECONDS,
|
||||
captureP2PAdditionalCheckBaseline,
|
||||
countActiveP2PConnections,
|
||||
deriveP2PAdditionalCheckProgress,
|
||||
deriveP2PCheckOutcome,
|
||||
type P2PAdditionalCheckBaseline,
|
||||
type P2PAdditionalCheckOutcome,
|
||||
type P2PCheckOutcome,
|
||||
} from "./P2PCheckState";
|
||||
|
||||
const OUTCOME_COPY: Record<
|
||||
P2PCheckOutcome,
|
||||
{ readonly title: string; readonly body: string; readonly tone: string }
|
||||
> = {
|
||||
idle: {
|
||||
title: "Not monitoring yet",
|
||||
body: "Prepare the reference peer, then start monitoring before opening the Setup URI on the target device.",
|
||||
tone: "neutral",
|
||||
},
|
||||
waiting: {
|
||||
title: "Waiting for the device",
|
||||
body: "The browser reference peer is ready. Keep this page open and complete setup in the empty test Vault.",
|
||||
tone: "neutral",
|
||||
},
|
||||
connecting: {
|
||||
title: "Negotiating a connection",
|
||||
body: "A WebRTC connection attempt is in progress. The counters can increase more than once during negotiation.",
|
||||
tone: "progress",
|
||||
},
|
||||
retrying: {
|
||||
title: "An attempt failed; waiting for a retry",
|
||||
body: "A failed attempt is not final. Leave both peers open because a later retry can still succeed.",
|
||||
tone: "warning",
|
||||
},
|
||||
connected: {
|
||||
title: "P2P connection observed",
|
||||
body: "This browser and the target device established at least one WebRTC connection. This checks connectivity, not note synchronisation.",
|
||||
tone: "success",
|
||||
},
|
||||
inconclusive: {
|
||||
title: "No P2P connection observed",
|
||||
body: "No successful connection was seen during the observation period. Repeat the check on the networks you intend to use; if it remains unsuccessful, CouchDB is the more predictable choice.",
|
||||
tone: "warning",
|
||||
},
|
||||
error: {
|
||||
title: "The browser monitor could not start",
|
||||
body: "Check browser support, the secure-context requirement, and access to the signalling relay, then start a fresh check.",
|
||||
tone: "error",
|
||||
},
|
||||
};
|
||||
|
||||
const ADDITIONAL_OUTCOME_COPY: Record<
|
||||
P2PAdditionalCheckOutcome,
|
||||
{ readonly title: string; readonly body: string; readonly tone: string }
|
||||
> = {
|
||||
waiting: {
|
||||
title: "Waiting for another device",
|
||||
body: "The same Setup URI is ready. Keep the browser and first device open while another empty Vault joins.",
|
||||
tone: "neutral",
|
||||
},
|
||||
negotiating: {
|
||||
title: "New connection activity observed",
|
||||
body: "The counters or active connections changed. Waiting for both a new successful state and another simultaneous active connection.",
|
||||
tone: "progress",
|
||||
},
|
||||
connected: {
|
||||
title: "An additional connection was observed",
|
||||
body: "The browser gained another simultaneous active WebRTC connection after this attempt began.",
|
||||
tone: "success",
|
||||
},
|
||||
inconclusive: {
|
||||
title: "Another device was not identified",
|
||||
body: "The same-room baseline did not produce both signals during this observation period. Use a fresh check for a clean per-device result.",
|
||||
tone: "warning",
|
||||
},
|
||||
};
|
||||
|
||||
interface AdditionalDeviceAttempt {
|
||||
readonly baseline: P2PAdditionalCheckBaseline;
|
||||
readonly startedAtElapsedMilliseconds: number;
|
||||
}
|
||||
|
||||
let target = $state<P2PCheckTarget>("desktop");
|
||||
let setup = $state<GeneratedP2PCheckSetup>();
|
||||
let qrDataURL = $state("");
|
||||
let preparing = $state(false);
|
||||
let preparationError = $state("");
|
||||
let monitorStarting = $state(false);
|
||||
let monitorActive = $state(false);
|
||||
let monitorError = $state("");
|
||||
let status = $state<P2PServerInfo>();
|
||||
let elapsedMilliseconds = $state(0);
|
||||
let copied = $state<"uri" | "passphrase">();
|
||||
let copyError = $state("");
|
||||
let freshCheckStarting = $state(false);
|
||||
let additionalDeviceAttempt = $state<AdditionalDeviceAttempt>();
|
||||
|
||||
let session: P2PCheckSession | undefined;
|
||||
let setupCard = $state<HTMLElement>();
|
||||
let setupHeading = $state<HTMLHeadingElement>();
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let monitorStartedAt = 0;
|
||||
|
||||
let diagnostics = $derived(status?.diag ?? EMPTY_P2P_CHECK_DIAGNOSTICS);
|
||||
let outcome = $derived(
|
||||
deriveP2PCheckOutcome(
|
||||
diagnostics,
|
||||
monitorActive,
|
||||
elapsedMilliseconds,
|
||||
monitorError !== ""
|
||||
)
|
||||
);
|
||||
let outcomeCopy = $derived(OUTCOME_COPY[outcome]);
|
||||
let activeConnections = $derived(countActiveP2PConnections(diagnostics));
|
||||
let observationSeconds = $derived(
|
||||
Math.floor(P2P_CHECK_OBSERVATION_MILLISECONDS / 1_000)
|
||||
);
|
||||
let elapsedSeconds = $derived(Math.floor(elapsedMilliseconds / 1_000));
|
||||
let targetLabel = $derived(target === "desktop" ? "desktop" : "mobile");
|
||||
let additionalElapsedMilliseconds = $derived(
|
||||
additionalDeviceAttempt
|
||||
? Math.max(
|
||||
0,
|
||||
elapsedMilliseconds -
|
||||
additionalDeviceAttempt.startedAtElapsedMilliseconds
|
||||
)
|
||||
: 0
|
||||
);
|
||||
let additionalElapsedSeconds = $derived(
|
||||
Math.floor(additionalElapsedMilliseconds / 1_000)
|
||||
);
|
||||
let additionalProgress = $derived(
|
||||
additionalDeviceAttempt
|
||||
? deriveP2PAdditionalCheckProgress(
|
||||
diagnostics,
|
||||
additionalDeviceAttempt.baseline,
|
||||
additionalElapsedMilliseconds
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
let additionalOutcomeCopy = $derived(
|
||||
additionalProgress
|
||||
? ADDITIONAL_OUTCOME_COPY[additionalProgress.outcome]
|
||||
: undefined
|
||||
);
|
||||
|
||||
function createQRCodeDataURL(setupURI: string): string {
|
||||
const code = qrcode(0, "L");
|
||||
code.addData(setupURI);
|
||||
code.make();
|
||||
return code.createDataURL(4, 4);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function prepareCheck(): Promise<void> {
|
||||
preparing = true;
|
||||
preparationError = "";
|
||||
try {
|
||||
const relay = resolveLocalP2PCheckRelayOverride(window.location);
|
||||
const generated = await generateP2PCheckSetup(target, { relay });
|
||||
qrDataURL = createQRCodeDataURL(generated.setupURI);
|
||||
setup = generated;
|
||||
} catch (error) {
|
||||
preparationError = formatError(error);
|
||||
} finally {
|
||||
preparing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateElapsedTime(): void {
|
||||
elapsedMilliseconds = Date.now() - monitorStartedAt;
|
||||
}
|
||||
|
||||
async function startMonitor(): Promise<void> {
|
||||
if (!setup || monitorStarting || monitorActive) {
|
||||
return;
|
||||
}
|
||||
monitorStarting = true;
|
||||
monitorError = "";
|
||||
const newSession = new P2PCheckSession();
|
||||
session = newSession;
|
||||
try {
|
||||
await newSession.start(setup.browserSettings, setup.browserDeviceName, (nextStatus) => {
|
||||
status = nextStatus;
|
||||
});
|
||||
monitorActive = true;
|
||||
monitorStartedAt = Date.now();
|
||||
elapsedMilliseconds = 0;
|
||||
elapsedTimer = setInterval(updateElapsedTime, 250);
|
||||
} catch (error) {
|
||||
monitorError = formatError(error);
|
||||
monitorActive = false;
|
||||
} finally {
|
||||
monitorStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(value: string, kind: "uri" | "passphrase"): Promise<void> {
|
||||
copyError = "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = kind;
|
||||
if (copyTimer !== undefined) {
|
||||
clearTimeout(copyTimer);
|
||||
}
|
||||
copyTimer = setTimeout(() => {
|
||||
copied = undefined;
|
||||
}, 2_000);
|
||||
} catch (error) {
|
||||
copyError = `Copying failed: ${formatError(error)}. Select the text and copy it manually.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function startFreshCheck(): Promise<void> {
|
||||
freshCheckStarting = true;
|
||||
if (elapsedTimer !== undefined) {
|
||||
clearInterval(elapsedTimer);
|
||||
}
|
||||
await session?.stop();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function scrollToSetupQRCode(): void {
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setupCard?.scrollIntoView({
|
||||
behavior: reducedMotion ? "auto" : "smooth",
|
||||
block: "start",
|
||||
});
|
||||
setupHeading?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
async function showSetupQRCode(): Promise<void> {
|
||||
await tick();
|
||||
scrollToSetupQRCode();
|
||||
}
|
||||
|
||||
async function startAdditionalDeviceAttempt(): Promise<void> {
|
||||
if (
|
||||
!monitorActive ||
|
||||
outcome !== "connected" ||
|
||||
activeConnections === 0 ||
|
||||
additionalDeviceAttempt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
additionalDeviceAttempt = {
|
||||
baseline: captureP2PAdditionalCheckBaseline(diagnostics),
|
||||
startedAtElapsedMilliseconds: elapsedMilliseconds,
|
||||
};
|
||||
await showSetupQRCode();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (elapsedTimer !== undefined) {
|
||||
clearInterval(elapsedTimer);
|
||||
}
|
||||
if (copyTimer !== undefined) {
|
||||
clearTimeout(copyTimer);
|
||||
}
|
||||
void session?.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="theme-color" content="#12233f" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="check-shell">
|
||||
<header class="hero">
|
||||
<a class="eyebrow" href="./index.html">Self-hosted LiveSync · WebPeer</a>
|
||||
<h1>P2P connection check</h1>
|
||||
<p class="hero-copy">
|
||||
See whether one LiveSync device can establish a WebRTC connection to this browser on
|
||||
the current network.
|
||||
</p>
|
||||
<div class="scope-note" role="note">
|
||||
<span aria-hidden="true">◇</span>
|
||||
<strong>Empty test Vaults only.</strong>
|
||||
This is a disposable connectivity check, not a synchronisation or backup test.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="step-card" aria-labelledby="choose-target-heading">
|
||||
<div class="step-number" aria-hidden="true">1</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Choose one target</p>
|
||||
<h2 id="choose-target-heading">Which device will connect to this browser?</h2>
|
||||
<p class="section-copy">
|
||||
Check desktop and mobile separately. A fresh test gives each device its own random
|
||||
room and clear diagnostic counters.
|
||||
</p>
|
||||
|
||||
<fieldset class="target-picker" disabled={setup !== undefined}>
|
||||
<legend class="visually-hidden">Target device</legend>
|
||||
<label class:chosen={target === "desktop"} class="target-option">
|
||||
<input type="radio" bind:group={target} value="desktop" />
|
||||
<span class="target-icon" aria-hidden="true">▰</span>
|
||||
<span>
|
||||
<strong>Desktop LiveSync</strong>
|
||||
<small>Browser ↔ desktop plug-in</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class:chosen={target === "mobile"} class="target-option">
|
||||
<input type="radio" bind:group={target} value="mobile" />
|
||||
<span class="target-icon mobile" aria-hidden="true">▯</span>
|
||||
<span>
|
||||
<strong>Mobile LiveSync</strong>
|
||||
<small>Browser ↔ mobile plug-in</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button class="primary-action" type="button" onclick={prepareCheck} disabled={preparing || setup !== undefined}>
|
||||
{preparing ? "Preparing locally…" : `Prepare ${targetLabel} check`}
|
||||
</button>
|
||||
<p class="privacy-line">
|
||||
Preparing generates and encrypts everything in this browser. It does not contact
|
||||
the signalling relay.
|
||||
</p>
|
||||
{#if preparationError}
|
||||
<p class="inline-error" role="alert">{preparationError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if setup}
|
||||
<section bind:this={setupCard} class="step-card setup-card" aria-labelledby="setup-heading">
|
||||
<div class="step-number" aria-hidden="true">2</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Set up the empty Vault</p>
|
||||
<h2 bind:this={setupHeading} id="setup-heading" tabindex="-1">
|
||||
{additionalDeviceAttempt
|
||||
? "Use this same one-off configuration on another device"
|
||||
: `Open this one-off configuration on ${targetLabel}`}
|
||||
</h2>
|
||||
{#if additionalDeviceAttempt}
|
||||
<p class="section-copy">
|
||||
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.
|
||||
</p>
|
||||
<p class="reuse-note" role="note">
|
||||
The room, credentials, connection, and existing counters have not been
|
||||
reset. The additional-device result uses the values recorded when you
|
||||
selected the button.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="section-copy">
|
||||
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.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="setup-grid">
|
||||
<div class="qr-panel">
|
||||
<img
|
||||
src={qrDataURL}
|
||||
alt={additionalDeviceAttempt
|
||||
? "Setup URI QR code for another device"
|
||||
: `Setup URI QR code for the ${targetLabel} check`}
|
||||
/>
|
||||
<p>
|
||||
{additionalDeviceAttempt
|
||||
? "This is the original encrypted Setup URI; it was not regenerated."
|
||||
: "QR contains the encrypted Setup URI only."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="credential-panel">
|
||||
<label for="setup-passphrase">Setup URI passphrase</label>
|
||||
<div class="copy-row compact">
|
||||
<input
|
||||
id="setup-passphrase"
|
||||
value={setup.setupPassphrase}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" onclick={() => copyText(setup!.setupPassphrase, "passphrase")}>
|
||||
{copied === "passphrase" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-help">Type this when LiveSync asks to decrypt the Setup URI.</p>
|
||||
|
||||
<label for="setup-uri">Setup URI</label>
|
||||
<textarea
|
||||
id="setup-uri"
|
||||
rows="4"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
readonly
|
||||
value={setup.setupURI}
|
||||
></textarea>
|
||||
<div class="button-row">
|
||||
<button type="button" onclick={() => copyText(setup!.setupURI, "uri")}>
|
||||
{copied === "uri" ? "Copied URI" : "Copy Setup URI"}
|
||||
</button>
|
||||
<a class="button-link" href={setup.setupURI}>Open in Obsidian</a>
|
||||
</div>
|
||||
{#if copyError}
|
||||
<p class="inline-error" role="alert">{copyError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="session-details">
|
||||
<div>
|
||||
<dt>Target</dt>
|
||||
<dd>{targetLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Test Group ID</dt>
|
||||
<dd>{setup.groupId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Signalling relay</dt>
|
||||
<dd>{setup.relay}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step-card results-card" aria-labelledby="monitor-heading">
|
||||
<div class="step-number" aria-hidden="true">3</div>
|
||||
<div class="step-content">
|
||||
<p class="section-kicker">Watch the browser diagnostics</p>
|
||||
<h2 id="monitor-heading">Start monitoring, then complete setup on {targetLabel}</h2>
|
||||
<p class="section-copy">
|
||||
Starting monitoring joins the configured signalling relay. Keep this page and
|
||||
the target Vault open for at least {observationSeconds} seconds.
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="primary-action monitor-action"
|
||||
type="button"
|
||||
onclick={startMonitor}
|
||||
disabled={monitorStarting || monitorActive}
|
||||
>
|
||||
{monitorStarting ? "Starting browser peer…" : monitorActive ? "Monitoring is active" : "Start connection monitor"}
|
||||
</button>
|
||||
|
||||
<div class="outcome" class:success={outcomeCopy.tone === "success"} class:warning={outcomeCopy.tone === "warning"} class:error={outcomeCopy.tone === "error"} class:progress={outcomeCopy.tone === "progress"} aria-live="polite">
|
||||
<div class="outcome-mark" aria-hidden="true">
|
||||
{outcome === "connected" ? "✓" : outcome === "error" ? "!" : outcome === "inconclusive" ? "?" : "•"}
|
||||
</div>
|
||||
<div>
|
||||
<h3>{outcomeCopy.title}</h3>
|
||||
<p>{outcomeCopy.body}</p>
|
||||
{#if monitorActive}
|
||||
<small>{elapsedSeconds}s observed · {activeConnections} currently connected</small>
|
||||
{/if}
|
||||
{#if monitorError}
|
||||
<code>{monitorError}</code>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics" aria-label="WebRTC diagnostic totals since this page opened">
|
||||
<article>
|
||||
<span>New</span>
|
||||
<strong data-testid="diag-new">{diagnostics.totalNewConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article class="successful">
|
||||
<span>Successful</span>
|
||||
<strong data-testid="diag-successful">{diagnostics.totalSuccessfulConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article class="failed">
|
||||
<span>Failed</span>
|
||||
<strong data-testid="diag-failed">{diagnostics.totalFailedConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Closed</span>
|
||||
<strong data-testid="diag-closed">{diagnostics.totalClosedConnections}</strong>
|
||||
<small>connection states</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p class="counter-note">
|
||||
These are negotiation events, not device counts. <strong>Successful > 0</strong>
|
||||
is the connection signal; later failures or closures do not erase it.
|
||||
</p>
|
||||
|
||||
{#if additionalProgress && additionalOutcomeCopy}
|
||||
<div
|
||||
class="outcome additional-outcome"
|
||||
class:success={additionalOutcomeCopy.tone === "success"}
|
||||
class:warning={additionalOutcomeCopy.tone === "warning"}
|
||||
class:progress={additionalOutcomeCopy.tone === "progress"}
|
||||
aria-live="polite"
|
||||
data-testid="additional-device-outcome"
|
||||
>
|
||||
<div class="outcome-mark" aria-hidden="true">
|
||||
{additionalProgress.outcome === "connected"
|
||||
? "✓"
|
||||
: additionalProgress.outcome === "inconclusive"
|
||||
? "?"
|
||||
: "•"}
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-kicker">Another device in this room</p>
|
||||
<h3>{additionalOutcomeCopy.title}</h3>
|
||||
<p>{additionalOutcomeCopy.body}</p>
|
||||
<small>
|
||||
{additionalElapsedSeconds}s observed ·
|
||||
<span data-testid="additional-successful">
|
||||
+{additionalProgress.successfulConnections} successful
|
||||
</span>
|
||||
·
|
||||
<span data-testid="additional-active-connections">
|
||||
+{additionalProgress.activeConnections} active
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="counter-note additional-caveat">
|
||||
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.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="session-actions">
|
||||
<button class="secondary-action" type="button" onclick={showSetupQRCode}>
|
||||
Show the Setup QR again
|
||||
</button>
|
||||
{#if outcome === "connected" && !additionalDeviceAttempt}
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
onclick={startAdditionalDeviceAttempt}
|
||||
disabled={activeConnections === 0}
|
||||
>
|
||||
{activeConnections === 0
|
||||
? "Waiting for the first device to reconnect…"
|
||||
: "Try another device without resetting"}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="secondary-action"
|
||||
type="button"
|
||||
onclick={startFreshCheck}
|
||||
disabled={freshCheckStarting}
|
||||
>
|
||||
{freshCheckStarting ? "Closing this check…" : "Start a fresh check for the other device"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="next-step" aria-labelledby="next-step-heading">
|
||||
<p class="section-kicker">What this tells you</p>
|
||||
<h2 id="next-step-heading">Use the result as a P2P preflight</h2>
|
||||
<div class="next-step-grid">
|
||||
<div>
|
||||
<h3>If both devices are observed</h3>
|
||||
<p>
|
||||
P2P looks plausible on these networks. Separate fresh checks give the
|
||||
clearest device comparison. For a stronger final check, use two disposable
|
||||
empty Vaults and verify a note round trip directly between your devices.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>If checks repeatedly do not connect</h3>
|
||||
<p>
|
||||
Network policy or NAT may make P2P unreliable. Use CouchDB when you need a
|
||||
predictable synchronisation path across these networks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
The generated credentials are disposable. Delete the empty test Vault afterwards and
|
||||
generate new credentials for any real setup.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
@@ -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<unknown> {
|
||||
const values = new Map<string, unknown>([[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<void> {
|
||||
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<void> {
|
||||
this.removeStatusListener?.();
|
||||
this.removeStatusListener = undefined;
|
||||
const runtime = this.runtime;
|
||||
this.runtime = undefined;
|
||||
await runtime?.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -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<GeneratedP2PCheckSetup> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,9 @@
|
||||
|
||||
<main>
|
||||
<div class="control">
|
||||
<div class="connection-check-link">
|
||||
<a href="./check.html">Try the P2P connection check</a>
|
||||
</div>
|
||||
{#await synchronised then activeRuntime}
|
||||
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
|
||||
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
|
||||
@@ -88,6 +91,22 @@
|
||||
overflow-y: scroll;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.connection-check-link {
|
||||
margin-bottom: 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
.connection-check-link a {
|
||||
display: inline-block;
|
||||
padding: 0.45em 0.75em;
|
||||
border: 1px solid var(--interactive-accent);
|
||||
border-radius: 0.5em;
|
||||
color: var(--interactive-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.connection-check-link a:hover {
|
||||
color: var(--interactive-accent-hover);
|
||||
border-color: var(--interactive-accent-hover);
|
||||
}
|
||||
.status {
|
||||
flex-grow: 0;
|
||||
/* max-height: 40px; */
|
||||
|
||||
@@ -22,6 +22,8 @@ import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersisten
|
||||
export interface WebPeerRuntimeOptions {
|
||||
context?: ServiceContext;
|
||||
store?: SimpleStore<unknown>;
|
||||
deviceName?: string;
|
||||
systemVaultName?: string;
|
||||
}
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
@@ -55,12 +57,12 @@ export class WebPeerRuntime {
|
||||
private startPromise?: Promise<this>;
|
||||
private shutdownPromise?: Promise<void>;
|
||||
|
||||
constructor(options: WebPeerRuntimeOptions = {}) {
|
||||
constructor(private readonly options: WebPeerRuntimeOptions = {}) {
|
||||
const persistence = createWebPeerPersistence(options.store);
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
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<this> {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
color: #17253b;
|
||||
background: #f3f6fa;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--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;
|
||||
--shadow: 0 18px 55px rgba(24, 42, 72, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
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,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.button-link {
|
||||
min-height: 2.75rem;
|
||||
border-radius: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
.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: ui-monospace, "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: ui-monospace, "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%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { mount } from "svelte";
|
||||
|
||||
import P2PCheck from "./P2PCheck.svelte";
|
||||
import "./check.css";
|
||||
|
||||
const app = mount(P2PCheck, {
|
||||
target: _activeDocument.getElementById("app")!,
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: "index.html",
|
||||
check: "check.html",
|
||||
// uitest: "uitest.html",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user