Add browser P2P connection check

This commit is contained in:
vorotamoroz
2026-07-31 14:13:34 +00:00
parent e32e3f545e
commit 48c398948a
20 changed files with 2710 additions and 8 deletions
+31 -1
View File
@@ -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
+18
View File
@@ -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>
+601
View File
@@ -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 &gt; 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>
+70
View File
@@ -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();
}
}
+166
View File
@@ -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,
};
}
+129
View File
@@ -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,
};
}
+19
View File
@@ -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; */
+8 -2
View File
@@ -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,
+713
View File
@@ -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;
}
}
+11
View File
@@ -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;
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: "index.html",
check: "check.html",
// uitest: "uitest.html",
},
},