diff --git a/DESIGN.md b/DESIGN.md index 1c36441ad..d6184200f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3718,7 +3718,9 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. About links the repository. - In the hosted relay shell (app.ovis.sh, ``, lib/shellMode.ts) the page opens with a **This app** group of two rows the native build never shows: **Change PC** (a neutral button to the - shell's front page, which lists every paired PC, the same row the iOS app gets for its own list) + shell's front page: the paired-PC list, or, signed out where the account service runs, the sign-in + screen with that list behind **Continue without an account**; the same row the iOS app gets for + its own list) and **Forget this PC** (a neutral button that asks once in a `ConfirmDialog`, **Forget**, then deletes this phone's key for the PC and leaves for the front page). Neither is destructive on the PC, which keeps listing the phone until it is revoked there, so neither is a `.btn-danger`. @@ -4376,22 +4378,47 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. for the flow; the states and their words are this app's, in the token gate's own frame, so a phone that pairs through the relay and one that pairs with a code see the same room. Neither the Codex nor the Claude app has a hosted relay shell to follow. -- Account part (components/AccountPcs.tsx, lib/account/), only on the front page and only when +- Account part (components/AccountPcs.tsx, RelayDashboard.tsx, RelayMap.tsx, AddPcSheet.tsx, + lib/account/, lib/relay/dashboard.ts), only on the front page and only when the page names an account service (``, account.ovis.sh unless the build names another) and, in a browser, it is app.ovis.sh, the one page a sign-in comes back to; otherwise the front page is the saved-PC list above, unchanged. Signed out: **Continue with GitHub** and **Continue with Apple**, two equal neutral `.btn`s (App Review 4.8 wants Apple wherever another provider is, and neither is the one to pick), a `--fs-xs` dim line on what the account service learns, - then the QR route under a hairline (an `h2`, the page keeps one `h1`). With saved PCs the list - comes first and the sign-in follows it. Signed in: **Your PCs** with "Signed in with GitHub" - under it, one row per PC of the account merged with the PCs this device is paired with: the - name, and under it a `--fs-sm` dim line in words (Paired; This device is not approved on it - yet; Offline, last seen 3 h ago; Its Ovis needs an update to join the account), no badge. Only - a paired PC is a link with a chevron; the others are plain rows. The `
` adds - `data-account-state` (`signed-out`, `signing-in`, `signed-in`) for scripts; each row - `data-pc-state`. Precedent: Tailscale's admin machine list (every machine of the account, its - state in words) for the list, and Signal's linked devices for keeping trust on devices already - linked rather than on the sign-in. + all under one **Sign in to Ovis** title (the page's one `h1`) and a line on what the relay is for. + Under them, quiet links: **Pair with a QR or code**, and only when this device holds pairings, + **Continue without an account (N PCs)**, which opens the dashboard below in local mode (this + device's own pairings, `data-dashboard-mode="local"`, the sidebar's foot offering the sign-in). +- Dashboard (signed in, or local mode): the shell's one wide screen, not the card. A sidebar of + places (Overview, PCs, Devices, Relay; Settings a row with no page yet) with the provider, Sign + out and Remove this device at its foot; a top bar with the title, a count line (the PCs the + account's directory lists: a PC paired by QR alone is a row, not one of them) and **Add a PC**, + the one primary action. Under it, a notice for the first PC waiting on this device, then a + summary strip of three cells (PCs as Ready / Needs you / Offline counts; Relays, each host with + its PC count and regional or global; Needs attention, each PC and its state in words), the + connection map (RelayMap.tsx: this device, each relay, the PCs behind it, wires that flow only + for a PC seen lately; a route list instead on a phone), and the PCs table: name with a dim line + (new in your account, in your account, paired by QR), state in words, relay host, last seen, + and the one action its state allows (Open, Add for a PC new in the account, Join for another + one waiting on this device, Update, Details), filtered by All / Ready / Needs you. One account + read on load, again when the page comes into view and every 60 s while it stays in view. The + `
` adds `data-account-state` (`signed-out`, `signing-in`, `signed-in`) for scripts; each + row `data-pc-state`. +- Add-a-PC sheet (AddPcSheet.tsx): a modal sheet with four segmented bars and a step list, in the + mode the row picked: **Add** types the six digits the new PC shows, then approves on that PC; + **Join** asks through the account, shows this device's digits, and an account member approves + on another device. **Add a PC** opens it with no PC, saying how one is added and leading to the + QR and code screen. It drives the shell's existing join, Pair, match and Cancel; the join goes + on in the sheet over the PC's own page, failure included (its words and Try again are the + shell's own screen's), except a new identity, which keeps the shell's screen with its ids and + Forget. +- Phone (760 px and under): the sidebar becomes a bottom tab bar (Overview, PCs, Devices, + Settings) over the safe area, its foot the page's last section, rendered after the page so that + Tab follows the eye (Add a PC first, Sign out after the table); and the map its route list. +- Precedent: GOAT's Fleet console (a telemetry strip over the machines) for the dashboard, and + its onboarding bars and connection steps for the sheet; Tailscale's admin machine list (every + machine of the account, its state in words) for the table; Signal's linked devices for keeping + trust on devices already linked rather than on the sign-in. No code or pixels are borrowed. - Relay files keep the existing file viewer and its Paseo-style file bar, rather than adding a separate remote-files screen. A client without a resource-capable service worker shows the usual file note as an alert when the 64 MiB limit is exceeded. Copy link and new-tab actions diff --git a/changelog.d/relay-dashboard.md b/changelog.d/relay-dashboard.md new file mode 100644 index 000000000..294a24b87 --- /dev/null +++ b/changelog.d/relay-dashboard.md @@ -0,0 +1,8 @@ +### Changed +- app.ovis.sh now opens on a sign-in screen, with pairing by QR code or code and PCs paired + without an account one link away. + +### Added +- The dashboard brings your PCs together with an at-a-glance summary, a connection map of relays + and PCs, and a step-by-step sheet for adding or joining a PC. The map animates only PCs your + account has seen lately. diff --git a/docs/account.md b/docs/account.md index ef7dd6823..869f9f9f6 100644 --- a/docs/account.md +++ b/docs/account.md @@ -339,15 +339,16 @@ messages): a pairing window with `POST /api/relay/link` and the header `x-ovis-local-only: 1`. What that window takes is decided on this PC only: it is never listed to account members (`remote-pairings`) or approved by one, even once the PC has members. The screen keeps the window open while it waits. -2. On any device already in the account, the PC shows as a card, **Add {name} to your account?**, - for 10 minutes after the directory took it. Its key comes from the directory and is checked - against its id, as for any PC there; the person then **types the six digits the new PC shows** - (not a tap: the directory's name is anyone's), and approves on the PC. +2. On any device already in the account, the dashboard lists the PC as new in the account, with + **Add** on its row, for 10 minutes after the directory took it. **Add** opens the Add-a-PC sheet. + The PC's key comes from the directory and is checked against its id, as for any PC there; the + person then **types the six digits the new PC shows** (not a tap: the directory's name is + anyone's), and approves on the PC. 3. The device is then the PC's first member: it links, and enrolls the PC ([Enrolling a PC](#enrolling-a-pc)), so the account's other devices are let in and introduced. -A wrong number ends that comparison; the person starts again from the card. QR codes and pairing -codes stay under **Other ways** for a device without an account. +A wrong number ends that comparison; the person starts again from **Add** on the dashboard. A device without an account +pairs with a QR code or pairing code behind **Pair with a QR or code** on the sign-in screen. ### Introductions diff --git a/docs/relay.md b/docs/relay.md index fbd94e920..454657eb0 100644 --- a/docs/relay.md +++ b/docs/relay.md @@ -190,8 +190,9 @@ then waits for the approval. A PC that declines, or does not approve in time, le **Pairing with a typed code.** For a device with no camera pointed at the PC, **Show a code to type** under the QR code shows ten characters, `XXXXX-XXXXX` (Crockford base32: no I, L, O or U; -O, I and L typed are read as 0, 1 and 1), and **Pair with a code** on app.ovis.sh's front page takes -them. The code finds the PC and hands the device the same offer the QR code carries, then the +O, I and L typed are read as 0, 1 and 1), and **Pair with a code** on app.ovis.sh takes them +(on its front page, or behind **Pair with a QR or code** on the sign-in screen when the account +service runs). The code finds the PC and hands the device the same offer the QR code carries, then the device pairs with the six-digit code in it as a scanned QR code does: nothing else admits it ([`shared/relay-pair-code.ts`](../shared/relay-pair-code.ts), [`server/relay/pair-code.ts`](../server/relay/pair-code.ts), diff --git a/scripts/relay-shell-browser-qa.ts b/scripts/relay-shell-browser-qa.ts index 01423152c..ff062d374 100644 --- a/scripts/relay-shell-browser-qa.ts +++ b/scripts/relay-shell-browser-qa.ts @@ -15,7 +15,12 @@ * * UI_EVIDENCE_DIR= bun scripts/relay-shell-browser-qa.ts * - * writes the screenshots and logs there (task-16-*, and task-18-* for push alerts); without it the checks still run. + * writes the screenshots and logs there (task-16-*, task-18-* for push alerts, task-9-* for the sign-in screen and + * dashboard); without it the checks still run. + * + * The sign-in screen and the dashboard need the account service, which the shell offers on app.ovis.sh alone + * (lib/account/service.ts shellAccountServiceOf): that part routes https://app.ovis.sh to the same built shell and + * https://account.ovis.sh to the in-memory account service (shared/account-service-fake.ts), which its PCs use too. * * Headless Chrome has no push service: each PC's server pushes to a fake one (server/push.fake.ts, * which decrypts what it gets as a browser would), and the page's PushManager hands over that @@ -27,7 +32,7 @@ import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { chromium } from "playwright-core"; import type { Browser, BrowserContext, Page } from "playwright-core"; import { startBunRelay } from "../relay/bun.ts"; @@ -414,6 +419,456 @@ export async function relayShellQa(tree: RelayServerTree): Promise { await state(page, "connecting").waitFor(); }; + /** Plan relay-homepage todo 9: the sign-in screen and the dashboard in a real browser, against real PCs and the Bun relay. */ + async function dashboardQa(): Promise { + const APP = "https://app.ovis.sh"; + const ACCOUNT_SERVICE = "https://account.ovis.sh"; + /** the account a GitHub sign-in at the fake service turns out to be, on every device of this run */ + const ACCT = "acct_github"; + // loaded here, for this checkout's tree only (relayShellQa runs against others too) + const { OFFLINE_AFTER_MS } = await import("../src/lib/account/myPcs.ts"); + const { FakeAccountService } = await import("../shared/account-service-fake.ts"); + const fake = new FakeAccountService(); + const lines: string[] = []; + const log9 = (line: string): void => { lines.push(line); note(`dashboard: ${line}`); }; + const shot9 = async (page: Page, name: string): Promise => { if (evidence) await page.screenshot({ path: join(evidence, `task-9-${name}.png`) }); }; + /** Until `check` holds, read again every 50 ms; past `ms` the run fails naming `what`. */ + const until = async (check: () => boolean | Promise, what: string, ms = 20_000): Promise => { + for (const deadline = Date.now() + ms; !(await check());) { + if (Date.now() > deadline) throw new Error(`${what}: not within ${ms} ms`); + await Bun.sleep(50); + } + }; + /** Each request the browsers made of the account service, once answered: `METHOD /path`. */ + const accountSeen = new EventTarget(); + const accountRequest = (line: string, what: string): Promise => bounded(new Promise((resolve) => { + const hear = (event: Event): void => { + if ((event as CustomEvent).detail !== line) return; + accountSeen.removeEventListener("request", hear); + resolve(); + }; + accountSeen.addEventListener("request", hear); + }), what); + /** No horizontal overflow: neither a scrolling page nor a layout viewport Chrome widened past the screen. */ + const capture = async (page: Page, name: string): Promise => { + const size = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth, inner: window.innerWidth })); + assert.ok(size.scroll <= size.client && size.inner === size.client, `${name}: no horizontal overflow (${JSON.stringify(size)})`); + await shot9(page, name); + log9(`capture ${name}: scrollWidth ${size.scroll} <= clientWidth ${size.client}, innerWidth ${size.inner}`); + }; + /** A sheet slides up on a phone: measured and captured once its finite animations ended and it sits inside the viewport. */ + const settledSheet = async (page: Page): Promise => { + await page.waitForFunction(() => { + const sheet = document.querySelector("[role=dialog]"); + const finite = document.getAnimations().filter((animation) => Number.isFinite(animation.effect?.getTiming().iterations ?? 1)); + return sheet !== null && finite.every((animation) => animation.playState === "finished") && sheet.getBoundingClientRect().bottom <= window.innerHeight + 0.5; + }); + }; + + // ---- PCs that use the account service, each with its own state ---- + + const pcs9: { stop: () => Promise }[] = []; + const accountPc = async (name: string) => { + const dir = mkdtempSync(join(tmpdir(), "ovis-relay-dash-qa-")); + let ready = deferred(); + const pcRelay = new RelayService({ stateDir: dir, relay: RELAY, connect: (url) => { + const socket = new WebSocket(relayUrl(url)); + socket.addEventListener("message", (event) => { if (typeof event.data === "string" && event.data.includes('"sync"')) ready.resolve(); }); + return socket; + } }); + const pcHerdrConfig = join(dir, "config.toml"); + writeFileSync(pcHerdrConfig, "[ui]\ncopy_on_select = true\n"); + const pc = createServer({ port: 0, hostname: "127.0.0.1", stateDir: dir, token: "", tailscaleOwner: null, sidecar: false, relay: pcRelay, herdrConfigPath: pcHerdrConfig, + accountService: { url: ACCOUNT_SERVICE, fetch: (request) => fake.fetch(request) } }); + const stop = async (): Promise => { await pcRelay.shutdown(); pc.stop(); rmSync(dir, { recursive: true, force: true }); }; + pcs9.push({ stop }); + const at = `http://127.0.0.1:${pc.port}`; + /** What the PC's own window asks of it. */ + const local = (path: string, options: { body?: unknown; headers?: Record } = {}) => fetch(`${at}${path}`, { + method: "POST", headers: { origin: at, "x-herdr-update": "1", "content-type": "application/json", ...options.headers }, + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + }); + assert.equal((await local("/api/relay/start")).status, 200); + await bounded(ready.promise, `${name} synced with the relay`); + const response = await local("/api/relay/offer"); + const body = await response.text(); + assert.equal(response.status, 200, `${name} offer: ${body}`); + const offer: RelayOffer = { ...decodeOffer((JSON.parse(body) as { url: string }).url)!, name }; + const services = pc.context.services; + return { + offer, id: offer.server_id, local, mailbox: services.accountMailbox, + /** in the account, and no member's enrollment still under way */ + joined: (): boolean => services.devices.account() === ACCT && !services.accountHolds.enrolling(), + /** the comparisons the PC's own windows show, with their digits */ + pairings: async () => (await (await fetch(`${at}/api/relay/pairings`, { headers: { origin: at } })).json() as { pairings: { id: string; digits: string }[] }).pairings, + /** the next sync with the relay, armed before the relay comes back */ + resync: (): Promise => { ready = deferred(); return ready.promise; }, + lastSeen: (): string | null => fake.pcs(ACCT).find((entry) => entry.server_id === offer.server_id)?.last_seen_at ?? null, + }; + }; + + // ---- browsers on app.ovis.sh ---- + + const contexts: BrowserContext[] = []; + /** what the pages warned of, kept with the report: the shell says there why a step it took did not finish */ + const consoleLines: string[] = []; + const hosted = async (options: { width: number; height: number; theme: "dark" | "light"; clock?: boolean }) => { + if (!browser) throw new Error("no browser"); + const mobile = options.width < 600; + // the worker would fetch past these routes; the shell runs without one + const context = await browser.newContext({ viewport: { width: options.width, height: options.height }, colorScheme: options.theme, deviceScaleFactor: mobile ? 3 : 1, isMobile: mobile, hasTouch: mobile, serviceWorkers: "block" }); + contexts.push(context); + const bridge = new Bridge(); + await bridgeRelay(context, bridge); + await context.route(/^https:\/\/app\.ovis\.sh\//, async (route) => { + const url = new URL(route.request().url()); + await route.fulfill({ response: await route.fetch({ url: `${origin}${url.pathname}${url.search}` }) }); + }); + await context.route(/^https:\/\/account\.ovis\.sh\//, async (route) => { + const request = route.request(); + const cors = { "access-control-allow-origin": APP, "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "GET, POST, PUT, DELETE" }; + if (request.method() === "OPTIONS") { await route.fulfill({ status: 204, headers: cors }); return; } + const body = request.postDataBuffer(); + const answer = await fake.fetch(new Request(request.url(), { method: request.method(), headers: request.headers(), ...(body === null ? {} : { body }) })); + // a redirect fulfilled here is followed on the network, past these routes (to the real app.ovis.sh): the sign-in's + // way back is a new navigation instead, which the app.ovis.sh route serves + const location = answer.headers.get("location"); + if (answer.status === 302 && location !== null && location.startsWith(`${APP}/`)) { + await route.fulfill({ status: 200, contentType: "text/html", body: `` }); + accountSeen.dispatchEvent(new CustomEvent("request", { detail: `${request.method()} ${new URL(request.url()).pathname}` })); + return; + } + await route.fulfill({ status: answer.status, headers: { ...Object.fromEntries(answer.headers), ...cors }, body: Buffer.from(await answer.arrayBuffer()) }); + accountSeen.dispatchEvent(new CustomEvent("request", { detail: `${request.method()} ${new URL(request.url()).pathname}` })); + }); + const page = await context.newPage(); + page.setDefaultTimeout(20_000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { if (message.type() === "warning" || message.type() === "error") consoleLines.push(`${options.width}: ${message.text()}`); }); + // the browser's own clock, moved on by fastForward where the map's offline state is checked + if (options.clock) await page.clock.install(); + await page.addInitScript((theme) => { + if (localStorage.getItem("ovis:settings") === null) localStorage.setItem("ovis:settings", JSON.stringify({ language: "en", theme })); + }, options.theme); + csp.push(await watchCsp(page)); + return { context, page, bridge, errors }; + }; + const setTheme = async (page: Page, theme: "dark" | "light"): Promise => { + await page.evaluate((theme) => { + localStorage.setItem("ovis:settings", JSON.stringify({ ...JSON.parse(localStorage.getItem("ovis:settings") ?? "{}") as object, theme })); + }, theme); + await page.emulateMedia({ colorScheme: theme }); + await page.reload(); + await page.waitForFunction((theme) => document.documentElement.dataset["theme"] === theme, theme); + }; + const signIn = async (page: Page): Promise => { + await page.getByRole("button", { name: "Continue with GitHub" }).click(); + await page.locator('.relay-dash[data-dashboard-mode="account"]').waitFor(); + }; + const row = (page: Page, name: string) => page.locator(".relay-dash-table tbody tr", { hasText: name }); + const wire = (page: Page, serverId: string) => page.locator(`path.relay-map-edge[data-pc="${serverId}"]`); + const signInScreen = async (page: Page): Promise => { + await page.getByRole("heading", { name: "Sign in to Ovis" }).waitFor(); + assert.equal(await page.getByRole("button", { name: "Continue with GitHub" }).count(), 1); + assert.equal(await page.getByRole("button", { name: "Pair with a QR or code" }).count(), 1); + assert.equal(await page.locator(".relay-shell-pcs, .relay-dash, input").count(), 0, "no PC list, dashboard or code field on the sign-in screen"); + }; + + try { + // the sign-in screen alone, at both widths and in both themes + for (const [width, height] of [[390, 844], [1440, 900]] as const) { + for (const theme of ["dark", "light"] as const) { + const { context, page } = await hosted({ width, height, theme }); + await page.goto(`${APP}/`); + await signInScreen(page); + assert.equal(await page.getByRole("button", { name: /Continue without an account/ }).count(), 0, "no way past the sign-in before any pairing"); + await capture(page, `signin-${width}-${theme}`); + await context.close(); + } + } + log9("sign-in: Sign in to Ovis, Continue with GitHub, Pair with a QR or code; no PC list, code field or Continue without an account before a pairing"); + + const pc1 = await accountPc("Desk PC"); + const A = await hosted({ width: 1440, height: 900, theme: "dark", clock: true }); + const a = A.page; + await a.goto(`${APP}/`); + await signInScreen(a); + await a.getByRole("button", { name: "Pair with a QR or code" }).click(); + await a.getByRole("heading", { name: "Scan the QR code on your PC" }).waitFor(); + assert.equal(await a.getByRole("button", { name: /Continue without an account/ }).count(), 0, "the pairing screen offers no saved PCs either"); + + // a real pairing through the Bun relay, signed out + await a.goto(encodeOffer(pc1.offer, APP)); + await state(a, "confirm").waitFor(); + await a.getByRole("button", { name: "Pair", exact: true }).click(); + await inApp(a); + await a.goto(`${APP}/`); + await signInScreen(a); + await a.getByRole("button", { name: "Continue without an account (1 PC)" }).click(); + await a.locator('.relay-dash[data-dashboard-mode="local"]').waitFor(); + assert.equal(await row(a, "Desk PC").getAttribute("data-pc-state"), "paired"); + assert.equal(await wire(a, pc1.id).getAttribute("data-wire"), "still"); + assert.equal(await a.locator('.relay-map-node[data-seen="unknown"] .relay-map-word').textContent(), "Paired · status unknown"); + assert.equal(await a.locator(".relay-map-flow").count(), 0, "nothing flows for a pairing without an account"); + await capture(a, "local-1440-dark"); + log9(`local: paired Desk PC ${pc1.id} by QR through the Bun relay, opened it, back on / -> Continue without an account (1 PC) -> local dashboard: row paired, wire still, "Paired · status unknown", 0 flows`); + + // signed in: opening the PC lists it in the account and links this device; its mailbox pull is the PC being seen + await a.locator(".relay-dash-foot").getByRole("button", { name: "Sign in" }).click(); + await signIn(a); + await row(a, "Desk PC").getByText("paired by QR · not in the account yet").waitFor(); + // the header counts the account's PCs, and the directory lists none yet: the QR pairing is a row, not one of them + assert.equal(await a.locator(".relay-dash-sub").innerText(), "0 PCs in your account", "the header counts only the PCs the account lists"); + assert.deepEqual(fake.pcs(ACCT), [], "the directory is empty before the PC is opened"); + await capture(a, "account-unlisted-1440-dark"); + log9(`account, before Open: directory [] -> header "0 PCs in your account", row "paired by QR · not in the account yet"`); + assert.equal(await wire(a, pc1.id).getAttribute("data-wire"), "still"); + const listed = accountRequest(`PUT /v1/pcs/${pc1.id}`, "the shell listed Desk PC in the account"); + await row(a, "Desk PC").getByRole("link", { name: "Open" }).click(); + await inApp(a); + await listed; + await until(pc1.joined, "Desk PC joined the account"); + // dated five minutes back (still seen lately), so the pull with the relay down below changes the row's Last seen to "just now" + await pc1.mailbox.pull(); + const seenDesk = fake.pcs(ACCT).find((entry) => entry.server_id === pc1.id); + assert.ok(seenDesk, "Desk PC is listed in the account"); + seenDesk.last_seen_at = new Date(Date.now() - 5 * 60_000).toISOString(); + const seenFirst = pc1.lastSeen(); + assert.equal(seenFirst, seenDesk.last_seen_at, "Desk PC's last_seen_at is five minutes ago"); + await a.goto(`${APP}/`); + await wire(a, pc1.id).and(a.locator('[data-wire="live"]')).waitFor({ state: "attached" }); + await row(a, "Desk PC").getByText("in your account", { exact: true }).waitFor(); + await row(a, "Desk PC").locator(".relay-dash-seen", { hasText: /^\d+ min ago$/ }).waitFor(); + assert.equal(await row(a, "Desk PC").getAttribute("data-pc-state"), "paired"); + assert.equal(await a.locator(".relay-map-flow").count(), 1); + assert.ok(await a.locator(".relay-map-dot").count() >= 1, "the live wire carries dots"); + assert.equal(await a.locator('.relay-map-node[data-seen="lately"] .relay-map-word').textContent(), "Seen lately"); + await capture(a, "seen-lately-1440-dark"); + log9(`account: GitHub sign-in at the fake service, Open -> PUT /v1/pcs/${pc1.id}, PC joined ${ACCT}, mailbox pull -> last_seen_at ${seenFirst} -> wire live, 1 flow, "Seen lately"`); + + // the relay stops while the PC keeps coming by the account service: the map reports the mailbox, not the relay + await relay!.stop(); + relay = null; + await pc1.mailbox.pull(); + const seenDown = pc1.lastSeen(); + assert.ok(seenDown !== null && Date.parse(seenDown) > Date.parse(seenFirst), `last_seen_at moved on with the relay down (${seenFirst} -> ${seenDown})`); + const reread = accountRequest("GET /v1/pcs", "the dashboard read the directory again"); + await a.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); + await reread; + // the answer is applied once the row's Last seen moves from the earlier pull (min ago) to this one + await row(a, "Desk PC").locator(".relay-dash-seen", { hasText: /^just now$/ }).waitFor(); + assert.equal(await wire(a, pc1.id).getAttribute("data-wire"), "live", "the wire still says seen lately"); + assert.equal(await row(a, "Desk PC").getAttribute("data-pc-state"), "paired"); + assert.deepEqual(A.errors, [], "no page error with the relay gone"); + await capture(a, "relay-stopped-1440-dark"); + await row(a, "Desk PC").getByRole("link", { name: "Open" }).click(); + await state(a, "relay-unreachable").waitFor(); + await a.getByRole("heading", { name: "The relay could not be reached" }).waitFor(); + await capture(a, "relay-stopped-open-1440-dark"); + const back = pc1.resync(); + await restartRelay(); + await bounded(back, "Desk PC reconnected to the restarted relay", 45_000); + await a.getByRole("button", { name: "Try again" }).click(); + await inApp(a); + log9(`relay stopped: PC pulled again (last_seen_at ${seenDown}); refresh applied (row Last seen min ago -> just now) -> wire still live, row paired, no page error; Open -> relay-unreachable "The relay could not be reached"; relay back -> Try again -> app`); + + // (a) a PC new in the account: its own screen listed it and opened a window it decides alone; this device types its number + const pc2 = await accountPc("Mac mini"); + const own = fake.issueToken(ACCT); + const put = await fake.fetch(new Request(`${ACCOUNT_SERVICE}/v1/pcs/${pc2.id}`, { + method: "PUT", headers: { authorization: `Bearer ${own}`, "content-type": "application/json" }, + body: JSON.stringify({ label: "Mac mini", public_key: Buffer.from(pc2.offer.server_public_key, "base64").toString("hex"), relay: RELAY }), + })); + assert.equal(put.status, 200, `Mac mini listed itself: ${await put.text()}`); + assert.equal((await pc2.local("/api/relay/link", { headers: { "x-ovis-local-only": "1" } })).status, 200); + await a.goto(`${APP}/`); + await row(a, "Mac mini").getByText("new in your account").waitFor(); + await row(a, "Mac mini").getByRole("button", { name: "Add", exact: true }).click(); + const sheetNew = a.locator('[role=dialog][data-add-pc-mode="new"]'); + await sheetNew.waitFor(); + await settledSheet(a); + await capture(a, "sheet-new-1440-dark"); + await sheetNew.getByRole("button", { name: "Add this PC" }).click(); + await a.waitForURL(`${APP}/s/${pc2.id}/`); + await a.locator('[role=dialog][data-add-pc-mode="new"]').getByRole("button", { name: "Pair", exact: true }).click(); + const typed = a.getByRole("textbox", { name: "Number on Mac mini" }); + await typed.waitFor(); + await until(async () => (await pc2.pairings()).length === 1, "Mac mini's own screen shows the comparison"); + const [asked] = await pc2.pairings(); + await settledSheet(a); + await capture(a, "sheet-typed-1440-dark"); + await typed.fill(asked!.digits); + await a.locator("[role=dialog]").getByRole("button", { name: "Add this PC" }).click(); + await a.getByText("Approve this phone on Mac mini to finish.").waitFor(); + assert.equal((await pc2.local("/api/relay/pairing", { body: { id: asked!.id, decision: "approve" } })).status, 200); + await inApp(a); + await until(pc2.joined, "Mac mini joined the account through this device"); + await pc2.mailbox.pull(); + await a.goto(`${APP}/`); + await row(a, "Mac mini").and(a.locator('[data-pc-state="paired"]')).waitFor(); + log9(`new PC: Mac mini ${pc2.id} listed itself and opened its local-only window -> row Add -> sheet (new) -> Add this PC -> Pair -> typed the ${asked!.digits.length} digits its own screen listed -> approved on the PC -> app -> row Ready`); + + // (b) an older account PC, its own window closed: this device shows its digits, another member picks them + await a.goto(`${APP}/s/${pc1.id}/`); + await inApp(a); + const desk = fake.pcs(ACCT).find((entry) => entry.server_id === pc1.id)!; + desk.added_at = new Date(Date.now() - 11 * 60_000).toISOString(); + const B = await hosted({ width: 390, height: 844, theme: "dark" }); + const b = B.page; + await b.goto(`${APP}/`); + await signInScreen(b); + await signIn(b); + await row(b, "Desk PC").getByRole("button", { name: "Join" }).click(); + const sheetJoin = b.locator('[role=dialog][data-add-pc-mode="join"]'); + await sheetJoin.waitFor(); + await settledSheet(b); + await capture(b, "sheet-join-390-dark"); + await sheetJoin.getByRole("button", { name: "Ask to join" }).click(); + await b.waitForURL(`${APP}/s/${pc1.id}/`); + await b.locator('[role=dialog][data-add-pc-mode="join"]').getByRole("button", { name: "Pair", exact: true }).click(); + const digits = b.locator("[role=dialog] .relay-shell-digits"); + await digits.waitFor(); + const shown = (await digits.getAttribute("aria-label"))!.replace(/ /g, ""); + const prompt = a.getByTestId("account-approval"); + await prompt.waitFor(); + assert.deepEqual(await pc1.pairings(), [], "the PC's own windows are shown no prompt for an account join"); + await settledSheet(b); + await capture(b, "sheet-digits-390-dark"); + await capture(a, "member-approval-1440-dark"); + await b.locator("[role=dialog]").getByRole("button", { name: "The numbers match" }).click(); + await prompt.getByRole("radio", { name: shown.split("").join(" ") }).click(); + await prompt.getByRole("button", { name: "Approve" }).click(); + await inApp(b); + await until(async () => (await pc1.pairings()).length === 0 && pc1.joined(), "Desk PC settled after the member's approval"); + await pc1.mailbox.pull(); + await pc2.mailbox.pull(); + await b.goto(`${APP}/`); + await row(b, "Desk PC").and(b.locator('[data-pc-state="paired"]')).waitFor(); + log9(`account join: Desk PC added 11 min ago, its window closed -> second device (390) Join -> Ask to join -> Pair -> showed ${shown.length} digits; the first device (another member, in Desk PC's app) got the prompt with 3 choices and picked them; Desk PC's own GET /api/relay/pairings = []; -> app -> row Ready`); + + // the visual matrix: dashboard and sheet at 1440 (the first device) and 390 at DPR 3 (the second), dark and light + for (const [page, width, sheetOpen] of [ + [a, 1440, () => a.getByRole("button", { name: "Add a PC" }).first().click()], + [b, 390, () => row(b, "Mac mini").getByRole("button", { name: "Add", exact: true }).click()], + ] as const) { + for (const theme of ["dark", "light"] as const) { + await page.goto(`${APP}/`); + await setTheme(page, theme); + await wire(page, pc1.id).or(page.locator(`.relay-map-hop[data-seen="lately"]`).first()).first().waitFor({ state: "attached" }); + await row(page, "Desk PC").and(page.locator('[data-pc-state="paired"]')).waitFor(); + await capture(page, `dashboard-${width}-${theme}`); + await sheetOpen(); + await settledSheet(page); + await capture(page, `sheet-${width}-${theme}`); + await page.keyboard.press("Escape"); + await page.locator("[role=dialog]").waitFor({ state: "detached" }); + await page.waitForFunction(() => !Object.keys(history.state ?? {}).includes("ovis:sheet")); + } + } + + // a phone's Tab order follows the page top to bottom: Add a PC first, the sidebar's foot (Sign out, Remove this device) after the table, the tab bar last + await setTheme(b, "dark"); + await row(b, "Desk PC").and(b.locator('[data-pc-state="paired"]')).waitFor(); + /** where the focus is after a Tab: the control's words and its place on the page; null once it reached the tab bar or left the page */ + const focused = () => b.evaluate(() => { + const active = document.activeElement; + if (!(active instanceof HTMLElement) || active === document.body || active.closest(".relay-dash-tabs") !== null) return null; + return { name: active.textContent?.trim() ?? "", top: Math.round(active.getBoundingClientRect().top + window.scrollY), scrollY: Math.round(window.scrollY) }; + }); + await b.keyboard.press("Tab"); + const first = await focused(); + assert.deepEqual({ name: first?.name, scrollY: first?.scrollY }, { name: "Add a PC", scrollY: 0 }, `the first Tab stop at 390 is Add a PC at the top, with no scrolling (${JSON.stringify(first)})`); + await capture(b, "first-tab-390-dark"); + const stops = [first!]; + for (let presses = 1; presses < 40; presses += 1) { + await b.keyboard.press("Tab"); + const stop = await focused(); + if (stop === null) break; + stops.push(stop); + } + assert.ok(stops.length < 40, `Tab reached the tab bar (${stops.length} stops)`); + const names = stops.map((stop) => stop.name); + assert.deepEqual(names.slice(-2), ["Sign out", "Remove this device from your account"], `the sidebar's foot comes last before the tab bar (${names.join(" > ")})`); + assert.ok(names.lastIndexOf("Open") < names.indexOf("Sign out"), "the table's actions come before the foot"); + const climbs = stops.filter((stop, index) => index > 0 && stop.top < stops[index - 1]!.top - 1).map((stop) => stop.name); + assert.deepEqual(climbs, [], `no Tab stop sits above the one before it (${stops.map((stop) => `${stop.name}@${stop.top}`).join(" > ")})`); + log9(`phone Tab order (390): ${stops.map((stop) => `${stop.name}@${stop.top}`).join(" > ")} > tab bar`); + + // reduced motion: still wires, no running animation, no dot + await a.emulateMedia({ reducedMotion: "reduce" }); + await setTheme(a, "dark"); + await wire(a, pc1.id).and(a.locator('[data-wire="live"]')).waitFor({ state: "attached" }); + const reduced = await a.evaluate(() => ({ + running: document.getAnimations().filter((animation) => animation.playState === "running").length, + dots: [...document.querySelectorAll(".relay-map-dot, .relay-map-flow")].filter((element) => { + const style = getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) > 0 && element.getBoundingClientRect().width > 0; + }).length, + })); + assert.deepEqual(reduced, { running: 0, dots: 0 }, "reduced motion: nothing runs, no dot shows"); + await capture(a, "reduced-1440-dark"); + log9(`reduced motion: running animations ${reduced.running}, visible dots and flows ${reduced.dots}`); + await a.emulateMedia({ reducedMotion: "no-preference" }); + + // the map's motion, seeked: frames differ by where the dots are + await a.reload(); + await wire(a, pc1.id).and(a.locator('[data-wire="live"]')).waitFor({ state: "attached" }); + const positions: string[] = []; + for (const [at, name] of [[0, "rest"], [1.2, "mid"], [2.55, "end"]] as const) { + const dot = await a.evaluate((at) => { + const svg = document.querySelector("svg.relay-map-svg") as SVGSVGElement; + svg.pauseAnimations(); + svg.setCurrentTime(at); + for (const animation of document.getAnimations()) { animation.pause(); animation.currentTime = at * 1000; } + const box = document.querySelector(".relay-map-dot")!.getBoundingClientRect(); + return `${Math.round(box.x)},${Math.round(box.y)}`; + }, at); + positions.push(dot); + if (evidence) await a.locator("svg.relay-map-svg").screenshot({ path: join(evidence, `task-9-map-${name}.png`) }); + } + assert.equal(new Set(positions).size, 3, `the first dot moves between t=0, 1.2 and 2.55 s (${positions.join(" ")})`); + log9(`map frames t=0/1.2/2.55 s: first dot at ${positions.join(" -> ")}`); + + // the PC stops coming by: past OFFLINE_AFTER_MS on this browser's clock, its wire goes dotted and its row Offline + pc1.mailbox.stop(); + pc2.mailbox.stop(); + await a.reload(); + await wire(a, pc1.id).and(a.locator('[data-wire="live"]')).waitFor({ state: "attached" }); + const later = accountRequest("GET /v1/pcs", "the dashboard read the directory past the offline mark"); + await a.clock.fastForward(OFFLINE_AFTER_MS + 60_000); + await a.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); + await later; + await wire(a, pc1.id).and(a.locator('[data-wire="off"]')).waitFor({ state: "attached" }); + await row(a, "Desk PC").and(a.locator('[data-pc-state="offline"]')).waitFor(); + // the map, the needs-attention list and the row's visible state cell all say Offline with the last-seen time + const mapWords = await a.locator('.relay-map-node[data-pc-state="offline"] .relay-map-word').allTextContents(); + assert.ok(mapWords.length > 0 && mapWords.every((word) => word === "Offline"), `the map's offline nodes say Offline (${JSON.stringify(mapWords)})`); + const offlineCell = await row(a, "Desk PC").locator(".relay-dash-state-cell").innerText(); + const [offlineState, ...offlineNote] = offlineCell.split("\n").map((line) => line.trim()).filter(Boolean); + assert.match(offlineState ?? "", /^Offline · \d+ min ago$/, `the offline row's state cell says Offline with its last-seen time ("${offlineCell}")`); + // this device's own check may add that the PC still answered it, never the check's "Answers" in place of Offline + assert.ok(offlineNote.every((line) => line === "Answered this device's latest check"), `nothing in the offline row's state cell contradicts Offline ("${offlineCell}")`); + const attentionText = await a.locator(".relay-dash-attention li", { hasText: "Desk PC" }).locator("small").innerText(); + assert.equal(offlineState, attentionText, "the row and the needs-attention list say the same"); + await capture(a, "offline-1440-dark"); + log9(`offline: mailbox pulls stopped (last_seen_at ${pc1.lastSeen()}), browser clock +${(OFFLINE_AFTER_MS + 60_000) / 60_000} min, visibilitychange -> GET /v1/pcs -> wire dotted (data-wire=off), row data-pc-state=offline, visible state cell "${offlineCell.replace(/\s+/g, " ")}" (= needs attention "${attentionText}"), map words ${JSON.stringify(mapWords)}`); + + // signing out goes back to the sign-in screen, the pairings one link away + await a.locator(".relay-dash-foot").getByRole("button", { name: "Sign out" }).click(); + await signInScreen(a); + await a.getByRole("button", { name: "Continue without an account (2 PCs)" }).waitFor(); + await capture(a, "signed-out-1440-dark"); + assert.deepEqual([...A.errors, ...B.errors], [], "no page error on either device"); + log9("sign-out: the sign-in screen with Continue without an account (2 PCs); page errors on both devices: none"); + keep("task-9-dashboard.txt", `# relay-shell-browser-qa (n): the sign-in screen and dashboard on ${APP} (routed to ${origin}), account service ${ACCOUNT_SERVICE} (shared/account-service-fake.ts), PCs on the Bun relay as ${RELAY}\n${lines.join("\n")}\n`); + } finally { + keep("task-9-console.txt", `${consoleLines.join("\n")}\n`); + for (const context of contexts) await context.close().catch(() => undefined); + for (const pc of pcs9) await pc.stop(); + } + } + try { await startPcRelay(); browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); @@ -1212,6 +1667,12 @@ export async function relayShellQa(tree: RelayServerTree): Promise { keep("task-19-prev.txt", `${lines.join("\n")}\n`); } + // (n) the hosted front page as app.ovis.sh serves it: the sign-in screen, pairing without an account, the dashboard + // against the account service, both ways a PC joins, the map's states, and the visual matrix. Another server tree + // (relay-e2e-regression.ts --skew) may predate the account service options its PCs are given here. + if (resolve(tree.dir) === resolve(repo)) await dashboardQa(); + else note("dashboard: skipped against another server tree"); + assertCspClean(csp, "the hosted shell under the enforced shell policy"); note(`csp: ${csp.length} watched pages, every one served the policy, no violation`); keep("task-16-states.txt", `# relay-shell-browser-qa: dist-shell at ${origin} (scripts/pages-static.ts), PC ${base} (createServer, relay ${RELAY} bridged to the Bun relay)\n${report.join("\n")}\n`); diff --git a/src/components/AccountPcs.css b/src/components/AccountPcs.css index b65ac96a8..d42d7dcee 100644 --- a/src/components/AccountPcs.css +++ b/src/components/AccountPcs.css @@ -133,13 +133,6 @@ color: var(--text-strong); } -/* the sign-in under the saved PCs: a hairline from the list before it */ -.account-or { - margin-top: var(--space-5); - padding-top: var(--space-4); - border-top: var(--hairline) solid var(--border); -} - /* sign out and leave: quiet links under the list; leaving asks once more, in the card */ .account-actions { display: flex; diff --git a/src/components/AccountPcs.tsx b/src/components/AccountPcs.tsx index e977204d8..106508da0 100644 --- a/src/components/AccountPcs.tsx +++ b/src/components/AccountPcs.tsx @@ -2,15 +2,13 @@ import "./RelayShell.css"; import "./AccountPcs.css"; -import { useState } from "react"; import { ChevronRight } from "lucide-react"; -import { newPcs, type MyPc } from "../lib/account/myPcs.ts"; +import type { MyPc, MyPcState } from "../lib/account/myPcs.ts"; import type { AccountProvider } from "../lib/account/store.ts"; import { useT } from "../lib/i18n.ts"; import { lastSeen } from "../lib/lastSeen.ts"; import type { PcStatus } from "../lib/relay/pcStatus.ts"; -import type { AccountView } from "../lib/relay/shellState.ts"; import { serverScopePath } from "../lib/shellMode.ts"; import { ConnectionLine } from "./ConnectionLine.tsx"; @@ -68,13 +66,22 @@ export function AccountFinePrint() { return

{t("The account only lists your PCs. Sessions stay end-to-end encrypted.")}

; } +/** A PC's state in a word or two, as the dashboard's rows and counts say it beside its dot. */ +export function pcStateWord(state: MyPcState, t: ReturnType): string { + switch (state) { + case "paired": return t("Ready"); + case "needs-approval": return t("Needs approval"); + case "offline": return t("Offline"); + case "needs-update": return t("Update Ovis on it"); + } +} + export function accountPcStateText(pc: MyPc, t: ReturnType): string { switch (pc.state) { // a pairing from before the account: removing this device through the account does not reach it yet - case "paired": return pc.linked ? t("Ready") : t("Ready · not in the account yet"); - case "needs-approval": return t("Needs approval"); + case "paired": return pc.linked ? pcStateWord("paired", t) : t("Ready · not in the account yet"); case "offline": return t("Offline · {when}", { when: lastSeen(pc.last_seen_at) }); - case "needs-update": return t("Update Ovis on it"); + default: return pcStateWord(pc.state, t); } } @@ -121,55 +128,3 @@ export function AccountPcList({ pcs, onJoin, status }: { pcs: readonly MyPc[]; o ); } - -/** The front page once signed in: the account's PCs, then how another one joins. */ -export function AccountPcs({ view, onJoin, onSignOut, onLeave, status, onCheck }: { - view: Extract; onJoin?: (pc: MyPc, options?: { typed?: boolean }) => void; - /** how each saved PC answered the latest check, and Check now */ - status?: ReadonlyMap; - onCheck?: () => void; - /** ends the sign-in; every pairing stays */ - onSignOut?: () => void; - /** removes this device from every PC of the account, then signs out */ - onLeave?: () => void; -}) { - const t = useT(); - const [leaving, setLeaving] = useState(false); - return ( - <> -

{t("Your PCs")}

-

{t("Signed in with {provider}", { provider: PROVIDER_NAMES[view.provider] })}

- {view.pcs !== null && onJoin && newPcs(view.pcs, Date.now()).map((pc) => ( -
-

{t("Add {name} to your account?", { name: pc.name })}

-

{t("You type the number {name} shows on its own screen, then approve there. Every device of your account can then use it.", { name: pc.name })}

- -
- ))} - {view.pcs === null - ?
- : <> - {onCheck && view.pcs.some((pc) => pc.paired) && } - - } - {view.error !== null &&

{view.error}

} -

{t("To add a PC, sign in on it or scan its QR code.")}

- {(onSignOut || onLeave) && ( -
- {leaving && onLeave ? ( - <> -

{t("Every PC of the account removes this device. You can pair it again later.")}

- - - - ) : ( - <> - {onSignOut && } - {onLeave && } - - )} -
- )} - - ); -} diff --git a/src/components/AddPcSheet.css b/src/components/AddPcSheet.css new file mode 100644 index 000000000..af8027f12 --- /dev/null +++ b/src/components/AddPcSheet.css @@ -0,0 +1,206 @@ +/* + * Add a PC (AddPcSheet.tsx): the system's modal (styles.css .modal*) with four segmented bars at its + * head and the join's steps below the screen the shell shows for that step. Tokens only. The digit + * field, the digits to compare and the waits are the relay shell's own rules (RelayShell.css), so the + * sheet and the shell's page draw them alike; the rules at the end only fit them into the sheet. + */ + +.add-pc-sheet { + width: min(100%, 520px); +} + +.add-pc-sheet:focus { + outline: none; +} + +/* the head holds the bars, the title and its line; the body's own top padding parts them from what follows */ +.add-pc-head { + display: block; + padding-bottom: 0; + border-bottom: 0; +} + +.add-pc-bars { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.add-pc-bars span { + height: var(--rail-w); + border-radius: var(--radius-pill); + background: var(--border); +} + +.add-pc-bars span[data-bar="done"] { + background: var(--primary); + opacity: 0.45; +} + +.add-pc-bars span[data-bar="running"] { + background: var(--primary); +} + +.add-pc-bars span[data-bar="failed"] { + background: var(--status-blocked); +} + +.add-pc-sub { + margin: var(--space-1) 0 0; + font-size: var(--fs-md); + line-height: var(--lh-base); + color: var(--text-dim); +} + +.add-pc-body { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +/* the steps: a mark, the step and what it proves, a hairline between them */ +.add-pc-steps { + margin: 0; + padding: 0; + list-style: none; +} + +.add-pc-steps li { + display: grid; + grid-template-columns: var(--icon-size) minmax(0, 1fr); + gap: var(--space-3); + align-items: start; + padding: var(--space-3) 0; + border-top: var(--hairline) solid var(--border); + font-size: var(--fs-md); + line-height: var(--lh-base); +} + +.add-pc-steps li:first-child { + border-top: 0; +} + +.add-pc-steps li:last-child { + padding-bottom: 0; +} + +.add-pc-step-label { + display: block; + color: var(--text-strong); + font-weight: var(--fw-medium); + overflow-wrap: anywhere; +} + +.add-pc-step-detail { + display: block; + margin-top: var(--space-1); + font-size: var(--fs-xs); + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.add-pc-steps li[data-step="todo"] .add-pc-step-label { + color: var(--text-dim); + font-weight: var(--fw-regular); +} + +/* the mark sits on the label's first line: a ring to do, the working colour running, a tick done, a cross failed */ +.add-pc-mark { + display: grid; + place-items: center; + width: var(--icon-size); + height: var(--icon-size); + margin-top: calc((var(--fs-md) * var(--lh-base) - var(--icon-size)) / 2); + border-radius: var(--radius-pill); + box-shadow: inset 0 0 0 var(--hairline) var(--border-strong); + color: var(--text-dim); +} + +.add-pc-mark svg { + width: var(--space-3); + height: var(--space-3); + stroke-width: 2.5; +} + +.add-pc-steps li[data-step="done"] .add-pc-mark { + background: var(--status-done-tint); + box-shadow: none; + color: var(--status-done); +} + +.add-pc-steps li[data-step="running"] .add-pc-mark { + box-shadow: inset 0 0 0 calc(2 * var(--hairline)) var(--status-working); +} + +.add-pc-steps li[data-step="running"] .add-pc-mark::after { + content: ""; + width: var(--dot-size); + height: var(--dot-size); + border-radius: var(--radius-pill); + background: var(--status-working); + animation: pulse var(--dur-pulse) var(--ease-pulse) infinite; +} + +.add-pc-steps li[data-step="failed"] .add-pc-mark { + background: var(--status-blocked-tint); + box-shadow: none; + color: var(--status-blocked); +} + +/* what went wrong, in one line above the steps: the wrong number, or why the join ended */ +.add-pc-problem { + margin: 0; + color: var(--status-blocked); + font-size: var(--fs-md); + font-weight: var(--fw-medium); + overflow-wrap: anywhere; +} + +/* the shell's screens inside the sheet: the typed number's heading repeats the running step and its copy + the sheet's own line, so only those say it; their buttons sit in a row at the foot's side, the first in + the DOM (the primary) on the right */ +.add-pc-sheet .relay-shell-title, +.add-pc-typed > .relay-shell-copy { + display: none; +} + +.add-pc-typed > .relay-shell-detail { + margin-top: 0; +} + +.add-pc-sheet .relay-shell-actions { + flex-direction: row-reverse; + justify-content: flex-start; + margin-top: var(--space-3); +} + +.add-pc-sheet .relay-shell-actions .btn { + width: auto; +} + +.add-pc-sheet .relay-shell-digits { + margin: var(--space-2) 0; +} + +.add-pc-sheet .relay-shell-wait { + margin: 0; +} + +.add-pc-sheet .relay-shell-wait .relay-shell-detail { + margin-top: var(--space-2); + text-align: center; +} + +/* fingers: the number's field is a control a finger reaches, so it takes the touch target's height */ +@media (pointer: coarse) { + .add-pc-sheet .relay-shell-code-input { + min-height: var(--touch-target); + } +} + +@media (prefers-reduced-motion: reduce) { + .add-pc-steps li[data-step="running"] .add-pc-mark::after { + animation: none; + } +} diff --git a/src/components/AddPcSheet.test.tsx b/src/components/AddPcSheet.test.tsx new file mode 100644 index 000000000..14fed022e --- /dev/null +++ b/src/components/AddPcSheet.test.tsx @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { act, createElement } from "react"; +import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; + +import type { MyPc } from "../lib/account/myPcs.ts"; +import type { RelayShellState } from "../lib/relay/shellState.ts"; +import { SettingsProvider } from "../lib/settings.ts"; +import { AddPcSheet, addPcSteps, type AddPcSheetProps } from "./AddPcSheet.tsx"; + +const SERVER_ID = "b".repeat(32); +const pcOf = (over: Partial = {}): MyPc => ({ server_id: SERVER_ID, name: "desk2", state: "needs-approval", last_seen_at: null, added_at: new Date().toISOString(), listed: true, paired: false, linked: false, join: { server_public_key: "k", relay: "wss://relay.example" }, ...over }); +const LIST: RelayShellState = { kind: "list", devices: [], account: { kind: "signed-in", provider: "github", error: null, pcs: [] } }; + +// the test renderer draws no DOM: a node stands in for every element, so the focus trap finds nothing to hold +const node = () => ({ isConnected: true, focus() {}, contains: () => false, querySelectorAll: () => [], getClientRects: () => [], closest: () => null, addEventListener() {}, removeEventListener() {} }); + +describe("the Add-a-PC sheet", () => { + const descriptors = new Map(); + let renderer: ReactTestRenderer | undefined; + let events: EventTarget; + /** what had the focus when the sheet opened, and how often it got it back */ + let opener: { isConnected: boolean; focus: () => void; focused: number }; + let calls: { joins: unknown[][]; matches: unknown[][]; cancels: number; closes: number; pairs: number; qr: number; retries: number }; + + beforeEach(() => { + calls = { joins: [], matches: [], cancels: 0, closes: 0, pairs: 0, qr: 0, retries: 0 }; + for (const key of ["window", "document", "navigator", "localStorage", "getComputedStyle", "matchMedia", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"]) descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + events = new EventTarget(); + const store = new Map(); + const storage = { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { store.set(key, value); }, removeItem: (key: string) => { store.delete(key); }, key: () => null, length: 0, clear: () => store.clear() }; + const matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + class HTMLElement { isConnected = true; focused = 0; focus(): void { this.focused += 1; } } + opener = new HTMLElement(); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + Object.defineProperty(globalThis, "HTMLElement", { configurable: true, value: HTMLElement }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["en"], userAgent: "Test browser", maxTouchPoints: 0 } }); + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage }); + Object.defineProperty(globalThis, "matchMedia", { configurable: true, value: matchMedia }); + Object.defineProperty(globalThis, "getComputedStyle", { configurable: true, value: () => ({ getPropertyValue: () => "" }) }); + Object.defineProperty(globalThis, "window", { configurable: true, value: { + location: { href: "https://app.ovis.example/", search: "", origin: "https://app.ovis.example", pathname: "/" }, + history: { state: null, replaceState() {} }, + localStorage: storage, + matchMedia, + innerWidth: 1280, + setTimeout, clearTimeout, + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + dispatchEvent: events.dispatchEvent.bind(events), + } }); + Object.defineProperty(globalThis, "document", { configurable: true, value: { + documentElement: { dataset: {}, style: { setProperty() {}, removeProperty() {} } }, + activeElement: opener, + // the sheet portals into the body: the renderer needs a container with a node mock for refs + body: { nodeType: 1, children: [], createNodeMock: node }, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + } }); + }); + + afterEach(async () => { + await act(async () => { renderer?.unmount(); }); + renderer = undefined; + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + descriptors.clear(); + }); + + const props = (mode: AddPcSheetProps["mode"], pc: MyPc | null, state: RelayShellState): AddPcSheetProps => ({ + mode, pc, state, + onJoin: (...args) => { calls.joins.push(args); }, + onPair: () => { calls.pairs += 1; }, + onMatch: (...args) => { calls.matches.push(args); }, + onRetry: () => { calls.retries += 1; }, + onCancel: () => { calls.cancels += 1; }, + onClose: () => { calls.closes += 1; }, + onPairByQr: () => { calls.qr += 1; }, + }); + const view = (shown: AddPcSheetProps) => createElement(SettingsProvider, { children: createElement(AddPcSheet, shown) }); + const mount = async (shown: AddPcSheetProps) => { await act(async () => { renderer = create(view(shown), { createNodeMock: node }); }); }; + const update = async (shown: AddPcSheetProps) => { await act(async () => { renderer!.update(view(shown)); }); }; + const text = (instance: ReactTestInstance): string => instance.children.map((child) => typeof child === "string" ? child : text(child)).join(""); + const all = () => text(renderer!.root); + const click = async (label: string) => { + const found = renderer!.root.findAll((instance) => instance.type === "button" && text(instance) === label); + expect(found.map(text)).toEqual([label]); + await act(async () => { found[0]!.props.onClick(); }); + }; + const steps = () => renderer!.root.findAll((instance) => instance.type === "li").map((instance) => instance.props["data-step"] as string); + const digitField = () => renderer!.root.findAll((instance) => instance.type === "input" && instance.props.name === "digits"); + const escape = async () => { + const event = Object.assign(new Event("keydown", { cancelable: true }), { key: "Escape" }); + await act(async () => { window.dispatchEvent(event); }); + return event.defaultPrevented; + }; + + it("marks the steps done in order, and a refusal after the digits on the approval", () => { + expect(addPcSteps("new", "start", null)).toEqual(["done", "running", "todo", "todo"]); + expect(addPcSteps("new", "compare", null)).toEqual(["done", "running", "todo", "todo"]); + expect(addPcSteps("new", "approving", null)).toEqual(["done", "done", "running", "todo"]); + expect(addPcSteps("join", "start", null)).toEqual(["running", "todo", "todo", "todo"]); + expect(addPcSteps("join", "connecting", null)).toEqual(["running", "todo", "todo", "todo"]); + expect(addPcSteps("join", "compare", null)).toEqual(["done", "running", "todo", "todo"]); + expect(addPcSteps("join", "failed", "pc-offline")).toEqual(["failed", "todo", "todo", "todo"]); + expect(addPcSteps("join", "failed", "pair-declined")).toEqual(["done", "done", "failed", "todo"]); + }); + + it("walks a new PC through the typed join: the card's join, the number typed from its screen, then its approval", async () => { + const pc = pcOf(); + await mount(props("new", pc, LIST)); + expect(renderer!.root.findByProps({ role: "dialog" }).props["aria-modal"]).toBe("true"); + expect(all()).toContain("Add desk2 to your account"); + expect(steps()).toEqual(["done", "running", "todo", "todo"]); + await click("Add this PC"); + expect(calls.joins).toEqual([[pc, { typed: true }]]); + // the shell asks for the number the PC shows: the field, never digits of this device's + await update(props("new", pc, { kind: "compare", name: "desk2", digits: "123456", confirmed: false, typed: true })); + expect(digitField()).toHaveLength(1); + expect(all()).not.toContain("123 456"); + expect(steps()).toEqual(["done", "running", "todo", "todo"]); + const form = renderer!.root.findByType("form"); + await act(async () => { form.props.onSubmit({ preventDefault() {}, currentTarget: { elements: { namedItem: () => ({ value: "123 456" }) } } }); }); + expect(calls.matches).toEqual([["123456"]]); + await update(props("new", pc, { kind: "compare", name: "desk2", digits: "123456", confirmed: true })); + expect(steps()).toEqual(["done", "done", "running", "todo"]); + expect(all()).toContain("Approve this phone on desk2 to finish."); + }); + + it("walks an account join: the plain join, this device's own digits, then an account member's approval", async () => { + const pc = pcOf({ added_at: new Date(Date.now() - 3_600_000).toISOString() }); + await mount(props("join", pc, LIST)); + expect(all()).toContain("Add this device to desk2"); + expect(steps()).toEqual(["running", "todo", "todo", "todo"]); + await click("Ask to join"); + expect(calls.joins).toHaveLength(1); + expect(calls.joins[0]).toEqual([pc]); + // the comparison this device shows, as the shell's own screen shows it + await update(props("join", pc, { kind: "compare", name: "desk2", digits: "123456", confirmed: false })); + const digits = renderer!.root.findByProps({ className: "relay-shell-digits" }); + expect(text(digits)).toBe("123 456"); + expect(digits.props["aria-label"]).toBe("1 2 3 4 5 6"); + expect(digitField()).toHaveLength(0); + expect(steps()).toEqual(["done", "running", "todo", "todo"]); + await click("The numbers match"); + expect(calls.matches).toEqual([[]]); + await update(props("join", pc, { kind: "compare", name: "desk2", digits: "123456", confirmed: true })); + expect(steps()).toEqual(["done", "done", "running", "todo"]); + expect(all()).toContain("Waiting for an account member to approve this device"); + await update(props("join", pc, { kind: "failed", failure: "pair-declined", name: "desk2", detail: null })); + expect(steps()).toEqual(["done", "done", "failed", "todo"]); + expect(all()).toContain("desk2 did not approve this phone"); + // a refusal is not one Try again helps with: Close alone, and closing ends nothing more + expect(renderer!.root.findAll((instance) => instance.type === "button").map(text)).toEqual(["Close"]); + await click("Close"); + expect(calls).toMatchObject({ closes: 1, cancels: 0, retries: 0 }); + }); + + it("says why a join failed in the shell's own words, with its Try again where that can help", async () => { + await mount(props("new", pcOf(), { kind: "failed", failure: "pc-offline", name: "desk2", detail: "relay closed 4404", server_id: SERVER_ID })); + expect(renderer!.root.findByProps({ role: "dialog" }).props["data-add-pc-phase"]).toBe("failed"); + expect(all()).toContain("desk2 is offline"); + expect(all()).toContain("Start Ovis on the PC"); + expect(all()).toContain("relay closed 4404"); + expect(steps()).toEqual(["done", "failed", "todo", "todo"]); + await click("Try again"); + expect(calls.retries).toBe(1); + }); + + it("shows the shell's own failure for a wrong typed number, with the typing step still running", async () => { + await mount(props("new", pcOf(), { kind: "compare", name: "desk2", digits: "", confirmed: false, typed: true, wrong: true })); + expect(all()).toContain("That is not the number desk2 shows"); + expect(digitField()).toHaveLength(0); + expect(steps()).toEqual(["done", "running", "todo", "todo"]); + }); + + it("closes on Escape, ending a comparison in progress, and gives the focus back to what opened it", async () => { + await mount(props("new", pcOf(), LIST)); + expect(await escape()).toBe(true); + expect(calls).toMatchObject({ closes: 1, cancels: 0 }); + await update(props("new", pcOf(), { kind: "compare", name: "desk2", digits: "123456", confirmed: false, typed: true })); + await escape(); + expect(calls).toMatchObject({ closes: 2, cancels: 1 }); + expect(opener.focused).toBe(0); + await act(async () => { renderer!.unmount(); }); + expect(opener.focused).toBe(1); + }); + + it("with no PC, says how one is added and leads to the QR and code screen", async () => { + await mount(props("add", null, LIST)); + expect(all()).toContain("Add a PC"); + expect(steps()).toEqual([]); + expect(renderer!.root.findAll((instance) => instance.props.className === "add-pc-bars")).toHaveLength(0); + await click("Pair with a QR or code"); + expect(calls.qr).toBe(1); + await click("Close"); + expect(calls).toMatchObject({ closes: 1, cancels: 0 }); + }); +}); diff --git a/src/components/AddPcSheet.tsx b/src/components/AddPcSheet.tsx new file mode 100644 index 000000000..173a4b660 --- /dev/null +++ b/src/components/AddPcSheet.tsx @@ -0,0 +1,254 @@ +/** + * Adding a PC, step by step: a sheet with four segmented bars and the steps of the join it drives, + * in the mode the dashboard's row picked (Add or Join, RelayDashboard.tsx). A PC new in the + * account (newPcs) has this device type the number that PC shows, then approve on the PC; any other + * PC that waits for this device asks through the account, this device shows its own number, and an + * account member approves on another device, never on the PC's own screen (docs/account.md, + * "Joining through the account"). Both call the shell's existing join, Pair, match and Cancel, and + * the steps advance from the shell's state: nothing here talks to the network, forces the typed + * mode or skips a comparison; the comparison on screen follows the state's `typed`, as the shell's + * own screen does. Without a PC the sheet says how one is added. The step list follows GOAT's + * connection phases (its onboarding bars and SSH runtime steps); no code is borrowed. + */ +// the digit field and the comparison's digits are the relay shell's own rules, so both screens draw them alike +import "./RelayShell.css"; +import "./AddPcSheet.css"; + +import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { Check, X } from "lucide-react"; + +import type { MyPc } from "../lib/account/myPcs.ts"; +import { useT } from "../lib/i18n.ts"; +import type { RelayFailure, RelayShellState } from "../lib/relay/shellState.ts"; +import { usePhoneSheetBack } from "../lib/sheetHistory.ts"; +import { useSheetSwipe } from "../lib/sheets.ts"; +import { nativeModalOver, useFocusTrap } from "../lib/useFocusTrap.ts"; +import { relayFailureText } from "./relayFailureText.ts"; +import { TypedDigits } from "./RelayShell.tsx"; + +/** `new`: the PC's card (typed number); `join`: the row of a PC waiting for approval (account join); `add`: no PC, how one is added. */ +export type AddPcSheetMode = "new" | "join" | "add"; + +/** Where the join is, read off the shell's state: the step list and the sheet's controls follow it. */ +export type AddPcPhase = "start" | "confirm" | "connecting" | "compare" | "wrong" | "approving" | "failed"; + +export function addPcPhase(pc: MyPc, state: RelayShellState): AddPcPhase { + switch (state.kind) { + case "confirm": return state.offer.server_id === pc.server_id ? "confirm" : "start"; + case "connecting": return "connecting"; + case "compare": return state.wrong ? "wrong" : state.confirmed ? "approving" : "compare"; + case "failed": return "failed"; + default: return "start"; + } +} + +export type AddPcStepStatus = "done" | "running" | "todo" | "failed"; + +/** + * The four steps' marks for a phase: the join's steps are done up to the one the phase is at, which + * runs, or failed there. A new PC has signed in before its card shows; an account join's ask is + * done once the relay produced digits; a refusal after digits is the approval's, any other failure + * the step that was connecting. + */ +export function addPcSteps(mode: "new" | "join", phase: AddPcPhase, failure: RelayFailure | null): AddPcStepStatus[] { + const at = phase === "approving" || (phase === "failed" && failure === "pair-declined") ? 2 + : mode === "new" ? 1 + : phase === "compare" || phase === "wrong" ? 1 + : 0; + return [0, 1, 2, 3].map((step) => step < at ? "done" : step > at ? "todo" : phase === "failed" ? "failed" : "running"); +} + +export interface AddPcSheetProps { + /** which flow the sheet walks: the dashboard row's Add (`new`) or Join (`join`), or Add a PC (`add`) */ + mode: AddPcSheetMode; + /** the PC joined; null in the `add` mode, which only says how a PC is added */ + pc: MyPc | null; + /** the shell's state of this PC's join (RelayShellProps.state): the steps advance from it */ + state: RelayShellState; + /** the shell's join (RelayShellProps.onJoin): typed from a PC's card, plain for an account join */ + onJoin: (pc: MyPc, options?: { typed?: boolean }) => void; + /** the shell's Pair, answering the question an offer link asks (RelayShellProps.onPair) */ + onPair: () => void; + /** the shell's match (RelayShellProps.onMatch): the digits typed from the PC's screen, or nothing when this device shows them */ + onMatch: (typed?: string) => void; + /** the shell's Try again (RelayShellProps.onRetry), offered on a failure it can help with */ + onRetry?: () => void; + /** the shell's Cancel (RelayShellProps.onCancel): ends a connection or comparison in progress */ + onCancel: () => void; + /** takes the sheet down; the focus goes back to what opened it */ + onClose: () => void; + /** the `add` mode's way to the existing QR and code pairing screen */ + onPairByQr: () => void; +} + +export function AddPcSheet({ mode, pc, state, onJoin, onPair, onMatch, onRetry, onCancel, onClose, onPairByQr }: AddPcSheetProps) { + const t = useT(); + const id = useId(); + const name = pc?.name ?? ""; + const phase = pc === null || mode === "add" ? null : addPcPhase(pc, state); + // the phase's own control: the one the focus starts on, and lands on again when a phase change took the last one away + const primary = useRef(null); + const surface = useFocusTrap(true, { initialFocus: primary }); + useLayoutEffect(() => { + const node = surface.current; + if (node === null || node.contains(document.activeElement)) return; + (primary.current ?? node).focus({ preventScroll: true }); + }, [phase]); + // while the join runs, Cancel, Escape and the scrim end it in the shell too, as today's Cancel and Not now do; a failed join has nothing left to end + const close = (): void => { + if (phase !== null && phase !== "start" && phase !== "failed") onCancel(); + onClose(); + }; + const latestClose = useRef(close); + latestClose.current = close; + useSheetSwipe(surface, close); + usePhoneSheetBack(() => { latestClose.current(); }); + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + // a native modal over it takes its own Escape + if (event.key !== "Escape" || nativeModalOver(surface.current)) return; + event.stopPropagation(); + event.preventDefault(); + latestClose.current(); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, []); + + const cancel = ; + const done = ; + let title: string; + let sub: string; + let body: ReactNode = null; + let actions: ReactNode; + if (phase === null || pc === null) { + title = t("Add a PC"); + sub = t("A PC joins through your account, or pairs with this device alone."); + body = ( + <> +

{t("On the PC, open Settings → Devices and PCs, sign in and choose Add this PC to my account. It then appears here to add.")}

+

{t("Without an account on the PC, pair this device with the QR code or pairing code it shows.")}

+ + ); + actions = ( + <> + + + + ); + } else { + title = mode === "new" ? t("Add {name} to your account", { name }) : t("Add this device to {name}", { name }); + sub = mode === "new" ? t("Type the number {name} shows on its own screen, then approve there.", { name }) : t("The number to check appears on another of your devices, not on {name}.", { name }); + switch (phase) { + case "start": + actions = mode === "new" + ? <>{cancel} + : <>{cancel}; + break; + case "confirm": + body =

{t("Pair with {name}?", { name })}

; + actions = <>{cancel}; + break; + case "connecting": + body =
; + actions = cancel; + break; + case "compare": { + // the digits are typed only when the shell asks for them, whatever the mode: a comparison is never made stricter or looser here + const typed = state.kind === "compare" && state.typed === true; + const digits = state.kind === "compare" ? state.digits : ""; + if (typed) { + body =
; + actions = null; + } else { + body = ( + <> +

{`${digits.slice(0, 3)} ${digits.slice(3)}`}

+

{t("Another of your devices shows three numbers: pick this one there.")}

+ + ); + actions = <>{cancel}; + } + break; + } + case "wrong": + body = ( +
+

{t("That is not the number {name} shows", { name })}

+

{t("Nothing was added. Tap the PC again to compare a new number, and type the one its own screen shows.")}

+
+ ); + actions = done; + break; + case "approving": + body = ( +
+ + ); + actions = cancel; + break; + case "failed": { + // the shell's own words for the failure (relayFailureText), and its Try again where that can help + const failure = state.kind === "failed" ? relayFailureText(t, state.failure, name) : null; + body = failure && ( +
+

{failure.title}

+

{failure.copy}

+ {state.kind === "failed" && state.detail !== null &&

{state.detail}

} +
+ ); + actions = failure?.retry && onRetry + ? <> + : done; + break; + } + } + } + + const steps = phase === null || pc === null ? null : (mode === "new" ? [ + [t("{name} signed in", { name }), t("It asked to join your account")], + [t("Type the number {name} shows", { name }), t("Proves you are looking at that PC")], + [t("Approve on {name}", { name }), t("The PC decides who gets in, not the account")], + [t("Ready"), t("Every device of your account can open it")], + ] : [ + [t("Ask to join"), t("Through your account, with this device's key")], + [t("Check the number on another of your devices"), t("It shows three; pick the one this device shows")], + [t("Approved by an account member"), t("On that device, never on {name} itself", { name })], + [t("Ready"), t("This device can open {name}", { name })], + ]).map(([label, detail], step) => ({ label, detail, status: addPcSteps(mode === "new" ? "new" : "join", phase, state.kind === "failed" ? state.failure : null)[step]! })); + + return createPortal( +
{ if (event.target === event.currentTarget) close(); }}> +
+
+ {steps && } +

{title}

+

{sub}

+
+
+ {body} + {steps && ( +
    + {steps.map(({ label, detail, status }, step) => ( +
  1. + + + {label} + {detail} + + {status === "done" && {t("Done")}} + {status === "failed" && {t("Failed")}} +
  2. + ))} +
+ )} +
+ {actions !== null &&
{actions}
} +
+
, + document.body, + ); +} diff --git a/src/components/RelayDashboard.css b/src/components/RelayDashboard.css new file mode 100644 index 000000000..302278edd --- /dev/null +++ b/src/components/RelayDashboard.css @@ -0,0 +1,711 @@ +/* + * The relay dashboard (RelayDashboard.tsx): the app's sidebar grid (DESIGN.md, sidebar roster row: + * one grid, neutral hover fill, the selected row's fill and no stroke) beside one column of panels + * on --bg. Tokens only; every count in tabular numerals; every control a finger can reach grows to + * --touch-target through the .btn, .segmented and .account-link rules it reuses. A phone (760px and + * under, the block at the end) gets one column: a top bar, a tab bar at the bottom, the table as rows. + */ + +/* the
the shell gives the dashboard: the whole viewport, no card */ +.relay-dash-screen { + display: grid; + min-height: 100dvh; + min-height: var(--app-height, 100dvh); + background: var(--bg); +} + +.relay-dash { + /* the sidebar: narrower than the app's roster, which has rows to fill; aliases of existing tokens only */ + --dash-side-w: calc(var(--sidebar-w) - 2 * var(--space-8) - var(--space-4)); + --dash-hover: color-mix(in srgb, var(--bg-hover) 50%, var(--bg-panel)); + --dash-selected: var(--bg-hover); + display: grid; + grid-template-columns: var(--dash-side-w) minmax(0, 1fr); + min-width: 0; +} + +/* ---- sidebar ---- */ + +/* the column stretches to the grid's row, so its fill and hairline reach the end of a page taller than the viewport */ +.relay-dash-side { + background: var(--bg-panel); + border-right: var(--hairline) solid var(--divider); +} + +/* the box inside sticks to the viewport, one screen tall, its foot at the bottom */ +.relay-dash-side-box { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); + height: 100dvh; + height: var(--app-height, 100dvh); + padding: calc(var(--space-3) + env(safe-area-inset-top, 0px)) var(--space-2) calc(var(--space-3) + env(safe-area-inset-bottom, 0px)) calc(var(--space-2) + env(safe-area-inset-left, 0px)); +} + +.relay-dash-brand { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-h); + margin: 0 0 var(--space-3); + padding: 0 var(--space-2); + color: var(--text-strong); + font-size: var(--fs-md); + font-weight: var(--fw-bold); + letter-spacing: var(--tracking-tight); +} + +.relay-dash-brand-mark { + width: calc(var(--mark-size) + var(--space-1)); + height: calc(var(--mark-size) + var(--space-1)); +} + +.relay-dash-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +/* one grid for every row: a --control-h leading cell for the glyph, the words, a trailing count */ +.relay-dash-nav-item { + display: grid; + grid-template-columns: var(--control-h) minmax(0, 1fr) auto; + align-items: center; + min-height: var(--control-h); + padding-right: var(--space-3); + border-radius: var(--radius-md); + color: var(--text); + font-size: var(--fs-sm); + font-weight: var(--fw-medium); + text-decoration: none; + transition: background-color var(--dur-fast) var(--ease-out); +} + +.relay-dash-nav-item svg { + width: var(--icon-size); + height: var(--icon-size); + justify-self: center; + color: var(--text-dim); +} + +.relay-dash-nav-item:hover { + background: var(--dash-hover); +} + +.relay-dash-nav-item[aria-current="page"] { + background: var(--dash-selected); + color: var(--text-strong); +} + +.relay-dash-nav-item[aria-current="page"] svg { + color: var(--accent); +} + +/* keyboard focus draws the ring inside the row, so it is not clipped by the panel's edge */ +.relay-dash-nav-item:focus-visible { + outline: var(--ring); + outline-offset: calc(-1 * var(--ring-offset)); +} + +.relay-dash-nav-item[aria-disabled="true"] { + color: var(--text-dim); + cursor: default; +} + +.relay-dash-nav-item[aria-disabled="true"]:hover { + background: transparent; +} + +.relay-dash-nav-count { + font-size: var(--fs-xs); + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +/* forced colors repaint the fill as Canvas: the current row keeps a Highlight leading bar, as the app's sidebar does */ +@media (forced-colors: active) { + .relay-dash-nav-item[aria-current="page"] { + border-inline-start: calc(2 * var(--hairline)) solid Highlight; + } +} + +.relay-dash-foot { + display: flex; + flex-direction: column; + gap: var(--space-1); + margin-top: auto; + padding: var(--space-3) var(--space-2) 0; + border-top: var(--hairline) solid var(--border); +} + +.relay-dash-who { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-h); + margin: 0; + font-size: var(--fs-sm); + color: var(--text); + overflow-wrap: anywhere; +} + +.relay-dash-who svg { + flex: none; + width: var(--icon-size); + height: var(--icon-size); + color: var(--text-dim); +} + +.relay-dash-foot .account-link { + margin-top: 0; + text-align: start; +} + +.relay-dash-foot .account-fineprint { + margin-top: 0; +} + +/* ---- the page ---- */ + +.relay-dash-main { + min-width: 0; + padding: var(--space-5) calc(var(--space-6) + env(safe-area-inset-right, 0px)) calc(var(--space-8) + env(safe-area-inset-bottom, 0px)) var(--space-6); +} + +.relay-dash-top { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.relay-dash-top-text { + flex: 1; + min-width: 0; +} + +.relay-dash-title { + margin: 0; + font-size: var(--fs-xl); + font-weight: var(--fw-semibold); + letter-spacing: var(--tracking-tight); + line-height: var(--lh-tight); + color: var(--text-strong); +} + +.relay-dash-sub { + margin: var(--space-1) 0 0; + font-size: var(--fs-sm); + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.relay-dash-top .btn svg { + width: var(--icon-size); + height: var(--icon-size); +} + +/* a PC waiting on this device: one line in the waiting state's tint, with the row's own action */ +.relay-dash-notice { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); + padding: var(--space-3) var(--space-4); + background: var(--status-working-tint); + border-radius: var(--radius-lg); + font-size: var(--fs-md); + color: var(--text); +} + +.relay-dash-notice p { + flex: 1; + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +/* the summary strip: counts, relays, what needs attention, three cells parted by hairlines */ +.relay-dash-summary { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.4fr) minmax(0, 1fr); + margin-bottom: var(--space-4); + background: var(--bg-panel); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.relay-dash-cell { + min-width: 0; + padding: var(--space-3) var(--space-4); +} + +.relay-dash-cell + .relay-dash-cell { + border-left: var(--hairline) solid var(--border); +} + +.relay-dash-h { + margin: 0 0 var(--space-2); + font-size: var(--fs-2xs); + font-weight: var(--fw-medium); + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.relay-dash-counts, +.relay-dash-relays, +.relay-dash-attention { + display: flex; + flex-direction: column; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; + font-size: var(--fs-sm); + color: var(--text); +} + +.relay-dash-counts li { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--space-6); +} + +.relay-dash-counts b { + margin-left: auto; + font-size: var(--fs-xl); + font-weight: var(--fw-semibold); + font-variant-numeric: tabular-nums; + color: var(--text-strong); +} + +.relay-dash-relays li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: var(--space-3); + align-items: center; + min-height: var(--space-6); + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.relay-dash-host { + overflow: hidden; + font-family: var(--font-mono); + font-size: var(--fs-xs); + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.relay-dash-attention li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-2); + align-items: start; +} + +.relay-dash-attention li .relay-dash-dot { + margin-top: calc(var(--space-2) - var(--hairline)); +} + +.relay-dash-attention-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.relay-dash-attention small { + display: block; + font-size: var(--fs-xs); + color: var(--text-dim); +} + +.relay-dash-quiet { + margin: 0; + font-size: var(--fs-sm); + color: var(--text-dim); +} + +/* the state's dot, the same four as AccountPcs.css: offline is a hollow ring, so it never reads as a colour alone */ +.relay-dash-dot { + display: inline-block; + flex: none; + box-sizing: border-box; + width: var(--dot-size); + height: var(--dot-size); + border-radius: 50%; + background: var(--status-idle); +} + +.relay-dash-dot[data-pc-state="paired"] { background: var(--status-done); } +.relay-dash-dot[data-pc-state="needs-approval"] { background: var(--status-working); } +.relay-dash-dot[data-pc-state="needs-update"] { background: var(--status-blocked); } +.relay-dash-dot[data-pc-state="offline"] { background: transparent; border: var(--hairline) solid var(--status-idle); } + +/* the connection map (RelayMap.tsx) in the strip's frame: the heading, then the map's own legend and drawing, or its route list on a phone */ +.relay-dash-map { + margin-bottom: var(--space-4); + padding: var(--space-3) var(--space-4) var(--space-4); + background: var(--bg-panel); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-lg); +} + +/* ---- the PC table ---- */ + +.relay-dash-pcs-head { + display: flex; + align-items: center; + gap: var(--space-3); + margin: var(--space-2) 0; +} + +.relay-dash-pcs-head h2 { + flex: 1; + min-width: 0; + margin: 0; + font-size: var(--fs-lg); + font-weight: var(--fw-semibold); + color: var(--text-strong); +} + +.relay-dash-pcs-head .account-link { + width: auto; + margin-top: 0; +} + +.relay-dash-table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + font-size: var(--fs-md); +} + +.relay-dash-col-name { width: 34%; } +.relay-dash-col-state { width: 22%; } +.relay-dash-col-relay { width: 20%; } +.relay-dash-col-seen { width: 12%; } +.relay-dash-col-act { width: 12%; } + +.relay-dash-table th { + padding: var(--space-2) var(--space-3); + border-bottom: var(--hairline) solid var(--border); + font-size: var(--fs-xs); + font-weight: var(--fw-medium); + color: var(--text-dim); + text-align: start; +} + +.relay-dash-table td { + padding: var(--space-3); + border-bottom: var(--hairline) solid var(--border); + vertical-align: middle; +} + +.relay-dash-name { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.relay-dash-name-icon { + display: grid; + flex: none; + place-items: center; + width: var(--avatar-size); + height: var(--avatar-size); + background: var(--bg-elevated); + border-radius: var(--radius-md); + color: var(--text); +} + +.relay-dash-name-icon svg { + width: var(--icon-size); + height: var(--icon-size); +} + +.relay-dash-name-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +/* a long name truncates: the row keeps its columns, and the PC's own page shows the name whole */ +.relay-dash-name-text b, +.relay-dash-name-text small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.relay-dash-name-text b { + font-weight: var(--fw-semibold); + color: var(--text-strong); +} + +.relay-dash-name-text small { + font-size: var(--fs-xs); + color: var(--text-dim); +} + +.relay-dash-state { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +/* under an offline row's state: this device's own check still got an answer */ +.relay-dash-state-note { + display: block; + font-size: var(--fs-xs); + color: var(--text-dim); +} + +.relay-dash-relay, +.relay-dash-seen { + overflow: hidden; + color: var(--text-dim); + text-overflow: ellipsis; + white-space: nowrap; +} + +.relay-dash-relay { + font-family: var(--font-mono); + font-size: var(--fs-sm); +} + +.relay-dash-seen { + font-variant-numeric: tabular-nums; +} + +.relay-dash-act { + text-align: end; + white-space: nowrap; +} + +.relay-dash-none { + margin-top: var(--space-3); +} + +/* no PC at all: one sentence and the one thing to do */ +.relay-dash-empty { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-6); + background: var(--bg-panel); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-lg); +} + +.relay-dash-empty p { + margin: 0; + font-size: var(--fs-md); + color: var(--text-dim); +} + +@media (pointer: coarse) { + .relay-dash-nav-item { + min-height: var(--touch-target); + } +} + +/* a narrower window: the attention list takes a row of its own under the counts and relays */ +@media (max-width: 1100px) { + .relay-dash-summary { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .relay-dash-cell-attention { + grid-column: 1 / -1; + border-top: var(--hairline) solid var(--border); + } + + .relay-dash-cell + .relay-dash-cell-attention { + border-left: 0; + } +} + +/* ---- a phone (760px and under, where RelayMap.css shows the route list in place of the drawing) ---- */ + +/* the tab bar is a phone's alone */ +.relay-dash-tabs { + display: none; +} + +@media (max-width: 760px) { + /* + * One column. The sidebar's box goes (display: contents): its brand is the top bar, stuck to the + * top; its foot, where the Devices tab leads, is the last section, and RelayDashboard.tsx renders it + * after the page here so that Tab reaches the page's controls first; its nav gives way to the tab bar + * fixed at the bottom, over the safe area. The page ends above the tab bar, and what a tab leads to + * stops under the top bar, not behind it. + */ + .relay-dash { + --dash-top-h: calc(var(--header-h) + env(safe-area-inset-top, 0px)); + --dash-tabs-h: calc(var(--touch-target) + 2 * var(--space-1) + var(--hairline) + env(safe-area-inset-bottom, 0px)); + grid-template-columns: minmax(0, 1fr); + padding-bottom: var(--dash-tabs-h); + } + + .relay-dash-side, + .relay-dash-side-box { + display: contents; + } + + .relay-dash-brand { + position: sticky; + top: 0; + z-index: var(--z-header); + order: -1; + min-height: var(--dash-top-h); + margin: 0; + padding: env(safe-area-inset-top, 0px) calc(var(--space-4) + env(safe-area-inset-right, 0px)) 0 calc(var(--space-4) + env(safe-area-inset-left, 0px)); + background: var(--bg-panel); + border-bottom: var(--hairline) solid var(--divider); + } + + .relay-dash-nav { + display: none; + } + + .relay-dash-main { + padding: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right, 0px)) var(--space-4) calc(var(--space-4) + env(safe-area-inset-left, 0px)); + } + + .relay-dash-foot { + margin-top: 0; + padding: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right, 0px)) 0 calc(var(--space-4) + env(safe-area-inset-left, 0px)); + } + + .relay-dash-main, + .relay-dash-pcs, + .relay-dash-foot { + scroll-margin-top: var(--dash-top-h); + } + + .relay-dash-tabs { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: var(--z-header); + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + padding: var(--space-1) env(safe-area-inset-right, 0px) calc(var(--space-1) + env(safe-area-inset-bottom, 0px)) env(safe-area-inset-left, 0px); + background: var(--bg-panel); + border-top: var(--hairline) solid var(--divider); + } + + /* a tab: the glyph over its word, the whole cell the target; the current one strong with the accent's glyph, as the sidebar's row; the row with no page dimmed as a disabled button is */ + .relay-dash-tab { + display: grid; + gap: calc(var(--space-1) / 2); + align-content: center; + justify-items: center; + min-width: 0; + min-height: var(--touch-target); + border-radius: var(--radius-md); + color: var(--text-dim); + font-size: var(--fs-2xs); + font-weight: var(--fw-medium); + text-decoration: none; + } + + .relay-dash-tab svg { + width: calc(var(--icon-size) + var(--space-1)); + height: calc(var(--icon-size) + var(--space-1)); + } + + .relay-dash-tab > span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .relay-dash-tab[aria-current="page"] { + color: var(--text-strong); + } + + .relay-dash-tab[aria-current="page"] svg { + color: var(--accent); + } + + .relay-dash-tab[aria-disabled="true"] { + opacity: 0.55; + } + + .relay-dash-tab:focus-visible { + outline: var(--ring); + outline-offset: calc(-1 * var(--ring-offset)); + } + + /* the strip stacks; the notice already names what waits, so the attention list goes */ + .relay-dash-summary { + grid-template-columns: minmax(0, 1fr); + } + + .relay-dash-cell + .relay-dash-cell { + border-top: var(--hairline) solid var(--border); + border-left: 0; + } + + .relay-dash-cell-attention { + display: none; + } + + .relay-dash-pcs-head { + flex-wrap: wrap; + } + + /* the table as rows: the name and the action on one line, the state under the name past its icon; relay and last seen go (the strip and the route list name the relays) */ + .relay-dash-table, + .relay-dash-table tbody, + .relay-dash-table td { + display: block; + } + + .relay-dash-table colgroup, + .relay-dash-table thead, + .relay-dash-table .relay-dash-relay, + .relay-dash-table .relay-dash-seen { + display: none; + } + + .relay-dash-table tr { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-1) var(--space-3); + align-items: center; + padding: var(--space-3) 0; + border-bottom: var(--hairline) solid var(--border); + } + + .relay-dash-table td { + padding: 0; + border: 0; + } + + .relay-dash-table .relay-dash-state-cell { + grid-column: 1; + padding-left: calc(var(--avatar-size) + var(--space-3)); + } + + .relay-dash-table .relay-dash-act { + grid-row: 1; + grid-column: 2; + } +} diff --git a/src/components/RelayDashboard.tsx b/src/components/RelayDashboard.tsx new file mode 100644 index 000000000..f1692d43d --- /dev/null +++ b/src/components/RelayDashboard.tsx @@ -0,0 +1,287 @@ +import "./RelayDashboard.css"; + +import { useState } from "react"; +import { LayoutGrid, Monitor, Plus, Settings, Smartphone, Waypoints, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { newPcs, type MyPc } from "../lib/account/myPcs.ts"; +import type { AccountProvider } from "../lib/account/store.ts"; +import { useT } from "../lib/i18n.ts"; +import { lastSeen } from "../lib/lastSeen.ts"; +import { dashboardModel, relayHost, type DashboardPc } from "../lib/relay/dashboard.ts"; +import type { PcStatus } from "../lib/relay/pcStatus.ts"; +import { serverScopePath } from "../lib/shellMode.ts"; +import { useMediaQuery } from "../lib/useMediaQuery.ts"; +import { accountPcStateText, CheckPcsButton, pcStateWord, PcStatusLine, PROVIDER_NAMES, ProviderMark } from "./AccountPcs.tsx"; +import { RelayMap } from "./RelayMap.tsx"; + +type Filter = "all" | "ready" | "needs"; + +/** A phone, as RelayDashboard.css lays the page out in one column: the sidebar's foot is drawn last there, so it is rendered last too, and Tab follows the eye. */ +const PHONE_QUERY = "(max-width: 760px)"; + +export interface RelayDashboardProps { + /** every PC with the relay it goes through (dashboardPcs); null while the account's directory loads */ + pcs: readonly DashboardPc[] | null; + /** signed in: the provider, named in the sidebar's foot with Sign out and Remove this device; null in local mode (this device's pairings alone), whose foot offers the sign-in */ + account: { provider: AccountProvider } | null; + /** the shell's default relay origin, where a PC with no known relay is placed */ + defaultRelay: string; + /** this device, as the map names it */ + meLabel: string; + /** what the account service said went wrong, under the table; the pairings work regardless */ + error?: string | null; + /** how each saved PC answered the latest check (pcStatus.ts), and Check now */ + pcStatus?: ReadonlyMap; + onCheckPcs?: () => void; + /** Add a PC: the top bar's one primary action, and the empty state's */ + onAddPc: () => void; + /** Add (a PC new in the account) or Join (another PC of the account that has not approved this device): the step sheet in that mode */ + onOpenSheet: (pc: MyPc, mode: "new" | "join") => void; + /** local mode: back to the sign-in */ + onSignIn?: () => void; + /** ends the sign-in; every pairing stays */ + onSignOut?: () => void; + /** removes this device from every PC of the account, then signs out */ + onLeave?: () => void; +} + +/** A state's dot: the colour helps the eye, the words beside it carry the state (AccountPcs.css draws the same four). */ +function StateDot({ state }: { state: MyPc["state"] }) { + return
diff --git a/src/components/relayFailureText.ts b/src/components/relayFailureText.ts new file mode 100644 index 000000000..6c1fe071c --- /dev/null +++ b/src/components/relayFailureText.ts @@ -0,0 +1,22 @@ +import type { Translate } from "../lib/i18n.ts"; +import type { RelayFailure } from "../lib/relay/shellState.ts"; + +/** + * What a failed connection says (shellState.ts failureOf), on the shell's own screen and in the + * Add-a-PC sheet alike: its title, the sentence under it, and whether Try again can help. + */ +export function relayFailureText(t: Translate, failure: RelayFailure, name: string): { title: string; copy: string; retry: boolean } { + switch (failure) { + case "pair-declined": return { title: t("{name} did not approve this phone", { name }), copy: t("It was declined on the PC, or not approved in time. Open the link again to compare new numbers."), retry: false }; + case "pair-unavailable": return { title: t("{name} is not taking new phones right now", { name }), copy: t("Its link may have run out (it works for 10 minutes), or it may be paused after too many tries or busy with other phones. Copy a new link on the PC, or wait a few minutes."), retry: false }; + case "not-added-yet": return { title: t("{name} has not added this device yet", { name }), copy: t("It adds the devices of your account a few minutes after they join. Try again in a moment."), retry: true }; + case "pc-offline": return { title: t("{name} is offline", { name }), copy: t("Its Ovis is not connected to the relay right now. Start Ovis on the PC, or check its connection, then try again."), retry: true }; + case "pc-unresponsive": return { title: t("{name} did not finish connecting", { name }), copy: t("The PC answered through the relay but did not complete the connection. Try again; if it keeps happening, restart Ovis on the PC."), retry: true }; + case "relay-unreachable": return { title: t("The relay could not be reached"), copy: t("Check this phone's connection, then try again."), retry: true }; + case "needs-update": return { title: t("Update needed"), copy: t("{name} and this app speak different versions of the relay protocol. Update Ovis on the PC, then try again.", { name }), retry: true }; + case "revoked": return { title: t("This phone was removed from {name}", { name }), copy: t("Its pairing was revoked on the PC, so this phone's key for it is gone. To pair again, scan a new QR code on the PC."), retry: false }; + case "identity-changed": return { title: t("{name} has a new identity", { name }), copy: t("The PC's relay key is not the one this phone paired with, so the saved pairing cannot be trusted. Forget it, then pair again from a new QR code on the PC."), retry: false }; + case "code-expired": return { title: t("That pairing code no longer works"), copy: t("The code was wrong, used up or expired. Scan a new QR code on the PC."), retry: false }; + case "bad-offer": return { title: t("This pairing link is damaged"), copy: t("Part of the link is missing or changed. Scan the QR code on the PC again, or copy its link whole."), retry: false }; + } +} diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index e024f4ce1..e16e6d36f 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -17,6 +17,7 @@ import { GUIDE_JA } from "./i18n/guide.ja.ts"; import { UPDATES_JA } from "./i18n/updates.ja.ts"; import { PALETTE_JA } from "./i18n/palette.ja.ts"; import { RELAY_JA } from "./i18n/relay.ja.ts"; +import { DASHBOARD_JA } from "./i18n/dashboard.ja.ts"; import { RUNTIME_MOVE_JA } from "./i18n/runtime-move.ja.ts"; /** @@ -479,6 +480,26 @@ const BASE: Record = { "Conversation": "会話", "Off": "オフ", "Not now": "今はしない", + "Pair with a QR or code": "QR またはコードでペアリング", + "Add this PC": "このPCを追加", + "Pair with {name}?": "{name}とペアリングしますか?", + "Pair": "ペアリング", + "Connecting to {name}…": "{name}に接続中…", + "The numbers match": "数字が一致しています", + "That is not the number {name} shows": "{name} に表示されている番号ではありません", + "Nothing was added. Tap the PC again to compare a new number, and type the one its own screen shows.": "何も追加されていません。もう一度PCをタップして新しい番号を比べ、そのPC自身の画面に出ている番号を入力してください。", + "Approve this phone on {name} to finish.": "{name} でこのスマートフォンを承認すると完了します。", + "Type the number {name} shows": "{name} に表示された番号を入力してください", + "Ready": "準備完了", + "Needs approval": "承認が必要", + "Offline": "オフライン", + "Update Ovis on it": "Ovis の更新が必要", + "PCs": "PC", + "Open": "開く", + "Relay": "リレー", + "Add": "追加", + "Update": "更新", + "All": "すべて", "Remove PC": "PC を削除", "Remove PC {name}?": "PC {name} を削除しますか?", "Removes this registration. Remote sessions keep running.": "この登録のみを削除します。リモートのセッションは実行されたままです。", @@ -505,6 +526,7 @@ export const JA_PARTS: Record> = { updates: UPDATES_JA, palette: PALETTE_JA, relay: RELAY_JA, + dashboard: DASHBOARD_JA, "runtime-move": RUNTIME_MOVE_JA, }; diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index f2f700d7b..15b5628c7 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -17,6 +17,7 @@ import { GUIDE_KO } from "./i18n/guide.ko.ts"; import { UPDATES_KO } from "./i18n/updates.ko.ts"; import { PALETTE_KO } from "./i18n/palette.ko.ts"; import { RELAY_KO } from "./i18n/relay.ko.ts"; +import { DASHBOARD_KO } from "./i18n/dashboard.ko.ts"; import { RUNTIME_MOVE_KO } from "./i18n/runtime-move.ko.ts"; /** @@ -477,6 +478,26 @@ const BASE: Record = { "Conversation": "대화", "Off": "끄기", "Not now": "나중에", + "Pair with a QR or code": "QR이나 코드로 페어링", + "Add this PC": "이 PC 추가", + "Pair with {name}?": "{name}와(과) 페어링할까요?", + "Pair": "페어링", + "Connecting to {name}…": "{name}에 연결하는 중…", + "The numbers match": "숫자가 같습니다", + "That is not the number {name} shows": "{name}에 나온 숫자가 아닙니다", + "Nothing was added. Tap the PC again to compare a new number, and type the one its own screen shows.": "아무것도 추가되지 않았습니다. PC를 다시 눌러 새 숫자를 비교하고, 그 PC 화면에 나온 숫자를 입력하세요.", + "Approve this phone on {name} to finish.": "{name}에서 이 휴대폰을 승인하면 끝납니다.", + "Type the number {name} shows": "{name}에 나온 숫자를 입력하세요", + "Ready": "준비됨", + "Needs approval": "승인 필요", + "Offline": "오프라인", + "Update Ovis on it": "Ovis 업데이트 필요", + "PCs": "PC", + "Open": "열기", + "Relay": "릴레이", + "Add": "추가", + "Update": "업데이트", + "All": "전체", "Remove PC": "PC 제거", "Remove PC {name}?": "{name} PC를 제거할까요?", "Removes this registration. Remote sessions keep running.": "이 등록만 지웁니다. 원격 세션은 계속 돕니다.", @@ -503,6 +524,7 @@ export const KO_PARTS: Record> = { updates: UPDATES_KO, palette: PALETTE_KO, relay: RELAY_KO, + dashboard: DASHBOARD_KO, "runtime-move": RUNTIME_MOVE_KO, }; diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts index 6d09983a2..b44cc7815 100644 --- a/src/lib/i18n.test.ts +++ b/src/lib/i18n.test.ts @@ -84,6 +84,14 @@ function labelMapUses(): [key: string, file: string][] { const DICTIONARIES = { Korean: KO, Japanese: JA, Chinese: ZH }; +// a Ready PC is one the directory lists, not one anything is linked to: its state word claims no connection +describe.each([["Korean", KO, /연결|접속/], ["Japanese", JA, /接続/], ["Chinese", ZH, /连接/]] as const)("%s Ready wording", (_name, dictionary, connected) => { + it.each(["Ready", "Ready · not in the account yet"])("does not say connected in %p", (key) => { + expect(dictionary[key]).toBeString(); + expect(dictionary[key]).not.toMatch(connected); + }); +}); + describe.each(Object.entries(DICTIONARIES))("%s dictionary", (_name, dictionary) => { const inCode = keysInCode(); const keys = new Set([...inCode.keys(), ...labelMapUses().map(([key]) => key)]); @@ -134,7 +142,8 @@ const PART_FILES: Record = { palette: ["components/CommandPalette.tsx"], setup: ["components/AgentSetupCard.tsx", "components/AddAgentPanel.tsx", "components/AgentTile.tsx", "components/AgentsSettings.tsx"], guide: ["components/SetupGuide.tsx"], - relay: ["components/RelayShell.tsx", "components/AccountPcs.tsx", "lib/relay/shell.tsx"], + relay: ["components/RelayShell.tsx", "components/AccountPcs.tsx", "lib/relay/shell.tsx", "components/relayFailureText.ts"], + dashboard: ["components/AddPcSheet.tsx", "components/RelayDashboard.tsx", "components/RelayMap.tsx"], "runtime-move": ["components/RuntimeMove.tsx", "components/RuntimeMoveViews.tsx", "lib/runtimeMove.ts"], }; diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 18ba49c6b..e7c6e9971 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -17,6 +17,7 @@ import { GUIDE_ZH } from "./i18n/guide.zh.ts"; import { UPDATES_ZH } from "./i18n/updates.zh.ts"; import { PALETTE_ZH } from "./i18n/palette.zh.ts"; import { RELAY_ZH } from "./i18n/relay.zh.ts"; +import { DASHBOARD_ZH } from "./i18n/dashboard.zh.ts"; import { RUNTIME_MOVE_ZH } from "./i18n/runtime-move.zh.ts"; /** @@ -481,6 +482,26 @@ const BASE: Record = { "Conversation": "对话", "Off": "关闭", "Not now": "暂不", + "Pair with a QR or code": "使用二维码或配对码配对", + "Add this PC": "添加此电脑", + "Pair with {name}?": "与 {name} 配对?", + "Pair": "配对", + "Connecting to {name}…": "正在连接 {name}…", + "The numbers match": "数字一致", + "That is not the number {name} shows": "这不是 {name} 显示的数字", + "Nothing was added. Tap the PC again to compare a new number, and type the one its own screen shows.": "未添加任何内容。请再次点按该电脑以比较新的数字,并输入它自己屏幕上显示的数字。", + "Approve this phone on {name} to finish.": "在 {name} 上批准这部手机即可完成。", + "Type the number {name} shows": "输入 {name} 显示的数字", + "Ready": "就绪", + "Needs approval": "需要批准", + "Offline": "离线", + "Update Ovis on it": "需要更新 Ovis", + "PCs": "PC", + "Open": "打开", + "Relay": "中继", + "Add": "添加", + "Update": "更新", + "All": "全部", "Remove PC": "移除 PC", "Remove PC {name}?": "移除 PC {name}?", "Removes this registration. Remote sessions keep running.": "仅移除此注册。远程会话会继续运行。", @@ -507,6 +528,7 @@ export const ZH_PARTS: Record> = { updates: UPDATES_ZH, palette: PALETTE_ZH, relay: RELAY_ZH, + dashboard: DASHBOARD_ZH, "runtime-move": RUNTIME_MOVE_ZH, }; diff --git a/src/lib/i18n/dashboard.ja.ts b/src/lib/i18n/dashboard.ja.ts new file mode 100644 index 000000000..ac942f0c2 --- /dev/null +++ b/src/lib/i18n/dashboard.ja.ts @@ -0,0 +1,64 @@ +/** Japanese for dashboard feature strings. */ +export const DASHBOARD_JA: Record = { + "Add a PC": "PC を追加", + "A PC joins through your account, or pairs with this device alone.": "PC はアカウント経由で参加するか、このデバイスとだけペアリングします。", + "On the PC, open Settings → Devices and PCs, sign in and choose Add this PC to my account. It then appears here to add.": "PC で設定 → デバイスと PC を開いてサインインし、このPCをアカウントに追加を選びます。するとここに追加する PC として表示されます。", + "Without an account on the PC, pair this device with the QR code or pairing code it shows.": "PC にアカウントがない場合は、PC に表示される QR コードまたはペアリングコードでこのデバイスをペアリングします。", + "Add {name} to your account": "{name} をアカウントに追加", + "Add this device to {name}": "このデバイスを {name} に追加", + "Type the number {name} shows on its own screen, then approve there.": "{name} 自身の画面に表示された番号を入力し、その PC で承認します。", + "The number to check appears on another of your devices, not on {name}.": "確認する番号は {name} ではなく、別のデバイスに表示されます。", + "Ask to join": "参加を申請", + "Another of your devices shows three numbers: pick this one there.": "別のデバイスに 3 つの番号が表示されます。そこでこの番号を選んでください。", + "Waiting for an account member to approve this device…": "アカウントの別のデバイスがこのデバイスを承認するのを待っています…", + "{name} signed in": "{name} がサインイン済み", + "It asked to join your account": "アカウントへの参加を求めています", + "Proves you are looking at that PC": "その PC を見ていることの証明です", + "Approve on {name}": "{name} で承認", + "The PC decides who gets in, not the account": "誰を入れるかはアカウントではなく PC が決めます", + "Every device of your account can open it": "アカウントのすべてのデバイスから開けます", + "Through your account, with this device's key": "アカウント経由で、このデバイスのキーを使って", + "Check the number on another of your devices": "別のデバイスで番号を確認", + "It shows three; pick the one this device shows": "3 つのうち、このデバイスに表示された番号を選びます", + "Approved by an account member": "アカウントの別のデバイスが承認", + "On that device, never on {name} itself": "{name} ではなく、そのデバイスで承認します", + "This device can open {name}": "このデバイスから {name} を開けます", + "Seen lately": "最近確認済み", + "Paired · status unknown": "ペアリング済み · 状態不明", + "sees sizes, not content": "サイズは見えるが内容は見えない", + "Connection map": "接続マップ", + "Connection map: {me}. Relays: {relays}. PCs: {pcs}. Seen lately: {lately}. Ready: {ready}. Needs you: {needsYou}. Offline: {offline}.": "接続マップ: {me}。リレー: {relays}。PC: {pcs}。最近確認済み: {lately}。準備完了: {ready}。応答が必要: {needsYou}。オフライン: {offline}。", + "Overview": "概要", + "Devices": "デバイス", + "Join": "参加", + "Last seen": "最終確認", + "State": "状態", + "Summary": "まとめ", + "Filter": "絞り込み", + "Action": "操作", + "Relays": "リレー", + "regional": "地域", + "global": "グローバル", + "1 PC": "PC 1 台", + "{count} PCs": "PC {count} 台", + "1 PC in your account": "アカウントに PC 1 台", + "{count} PCs in your account": "アカウントに PC {count} 台", + "1 PC paired with this device": "このデバイスとペアリング済みの PC 1 台", + "{count} PCs paired with this device": "このデバイスとペアリング済みの PC {count} 台", + "Needs attention · {count}": "要確認 · {count}", + "Nothing needs you right now.": "今は確認が必要なものはありません。", + "No PCs yet. Sign in on a PC, or scan the QR code it shows, to see it here.": "まだ PC がありません。PC でサインインするか、その PC に表示される QR コードをスキャンすると、ここに表示されます。", + "No PC matches this filter.": "この絞り込みに合う PC はありません。", + "Without an account": "アカウントなし", + "in your account": "アカウントに登録済み", + "new in your account": "アカウントに新しく追加", + "paired by QR · not in the account yet": "QR でペアリング · まだアカウントにない", + "paired with this device": "このデバイスとペアリング済み", + "{name} has not approved this device yet.": "{name} はまだこのデバイスを承認していません。", + "{name} is new in your account. Type the number it shows on its own screen to add it.": "{name} がアカウントに新しく加わりました。その PC の画面に出ている番号を入力して追加してください。", + "Loading your PCs…": "PC を読み込み中…", + "Remove this device": "このデバイスを外す", + "Remove this device from your account": "アカウントからこのデバイスを外す", + "Every PC of the account removes this device. You can pair it again later.": "アカウントのすべての PC からこのデバイスが外れます。あとでまたペアリングできます。", + "Answered this device's latest check": "このデバイスの直近の確認には応答あり", +}; diff --git a/src/lib/i18n/dashboard.ko.ts b/src/lib/i18n/dashboard.ko.ts new file mode 100644 index 000000000..934cc56b2 --- /dev/null +++ b/src/lib/i18n/dashboard.ko.ts @@ -0,0 +1,64 @@ +/** Korean for dashboard feature strings. */ +export const DASHBOARD_KO: Record = { + "Add a PC": "PC 추가", + "A PC joins through your account, or pairs with this device alone.": "PC는 계정을 통해 들어오거나, 이 기기와만 페어링합니다.", + "On the PC, open Settings → Devices and PCs, sign in and choose Add this PC to my account. It then appears here to add.": "PC에서 설정 → 기기와 PC를 열고 로그인한 뒤 이 PC를 내 계정에 추가를 선택하세요. 그러면 여기에 추가할 PC로 나타납니다.", + "Without an account on the PC, pair this device with the QR code or pairing code it shows.": "PC에 계정이 없다면 PC에 나오는 QR 코드나 페어링 코드로 이 기기를 페어링하세요.", + "Add {name} to your account": "{name}을(를) 계정에 추가", + "Add this device to {name}": "{name}에 이 기기 추가", + "Type the number {name} shows on its own screen, then approve there.": "{name} 화면에 나온 숫자를 입력한 뒤 그 PC에서 승인하세요.", + "The number to check appears on another of your devices, not on {name}.": "확인할 숫자는 {name}이(가) 아니라 다른 기기에 나옵니다.", + "Ask to join": "참여 요청", + "Another of your devices shows three numbers: pick this one there.": "다른 기기에 숫자 세 개가 나옵니다. 거기서 이 숫자를 고르세요.", + "Waiting for an account member to approve this device…": "계정의 다른 기기가 이 기기를 승인하기를 기다리는 중…", + "{name} signed in": "{name} 로그인됨", + "It asked to join your account": "계정에 참여를 요청했습니다", + "Proves you are looking at that PC": "그 PC를 보고 있다는 증명입니다", + "Approve on {name}": "{name}에서 승인", + "The PC decides who gets in, not the account": "누가 들어올지는 계정이 아니라 PC가 정합니다", + "Every device of your account can open it": "계정의 모든 기기가 이 PC를 열 수 있습니다", + "Through your account, with this device's key": "계정을 통해, 이 기기의 키로", + "Check the number on another of your devices": "다른 기기에서 숫자 확인", + "It shows three; pick the one this device shows": "셋 중 이 기기에 나온 숫자를 고르세요", + "Approved by an account member": "계정의 다른 기기가 승인", + "On that device, never on {name} itself": "그 기기에서 승인하며, {name}에서는 아무것도 묻지 않습니다", + "This device can open {name}": "이 기기에서 {name}을(를) 열 수 있습니다", + "Seen lately": "최근 확인됨", + "Paired · status unknown": "페어링됨 · 상태 알 수 없음", + "sees sizes, not content": "크기만 보고 내용은 보지 못함", + "Connection map": "구성도", + "Connection map: {me}. Relays: {relays}. PCs: {pcs}. Seen lately: {lately}. Ready: {ready}. Needs you: {needsYou}. Offline: {offline}.": "구성도: {me}. 릴레이: {relays}. PC: {pcs}. 최근 확인됨: {lately}. 준비됨: {ready}. 내 응답 필요: {needsYou}. 오프라인: {offline}.", + "Overview": "개요", + "Devices": "기기", + "Join": "참여", + "Last seen": "마지막 확인", + "State": "상태", + "Summary": "요약", + "Filter": "필터", + "Action": "작업", + "Relays": "릴레이", + "regional": "지역", + "global": "전역", + "1 PC": "PC 1대", + "{count} PCs": "PC {count}대", + "1 PC in your account": "계정에 PC 1대", + "{count} PCs in your account": "계정에 PC {count}대", + "1 PC paired with this device": "이 기기와 페어링된 PC 1대", + "{count} PCs paired with this device": "이 기기와 페어링된 PC {count}대", + "Needs attention · {count}": "확인 필요 · {count}", + "Nothing needs you right now.": "지금은 확인할 것이 없습니다.", + "No PCs yet. Sign in on a PC, or scan the QR code it shows, to see it here.": "아직 PC가 없습니다. PC에서 로그인하거나 그 PC에 나오는 QR 코드를 스캔하면 여기에 나타납니다.", + "No PC matches this filter.": "이 필터에 맞는 PC가 없습니다.", + "Without an account": "계정 없이", + "in your account": "계정에 있음", + "new in your account": "계정에 새로 들어옴", + "paired by QR · not in the account yet": "QR로 페어링 · 아직 계정에 없음", + "paired with this device": "이 기기와 페어링됨", + "{name} has not approved this device yet.": "{name}에서 아직 이 기기를 승인하지 않았습니다.", + "{name} is new in your account. Type the number it shows on its own screen to add it.": "{name}이(가) 계정에 새로 들어왔습니다. 그 PC 화면에 나온 숫자를 입력해 추가하세요.", + "Loading your PCs…": "PC 목록을 불러오는 중…", + "Remove this device": "이 기기 빼기", + "Remove this device from your account": "계정에서 이 기기 빼기", + "Every PC of the account removes this device. You can pair it again later.": "계정의 모든 PC에서 이 기기가 빠집니다. 나중에 다시 페어링할 수 있습니다.", + "Answered this device's latest check": "이 기기의 최근 확인에는 응답함", +}; diff --git a/src/lib/i18n/dashboard.zh.ts b/src/lib/i18n/dashboard.zh.ts new file mode 100644 index 000000000..b8a156742 --- /dev/null +++ b/src/lib/i18n/dashboard.zh.ts @@ -0,0 +1,64 @@ +/** Simplified Chinese for dashboard feature strings. */ +export const DASHBOARD_ZH: Record = { + "Add a PC": "添加电脑", + "A PC joins through your account, or pairs with this device alone.": "电脑可以通过你的账户加入,或只与此设备配对。", + "On the PC, open Settings → Devices and PCs, sign in and choose Add this PC to my account. It then appears here to add.": "在电脑上打开设置 → 设备和电脑,登录后选择将此电脑添加到我的账号。之后它会出现在这里供你添加。", + "Without an account on the PC, pair this device with the QR code or pairing code it shows.": "如果电脑上没有账户,请用它显示的二维码或配对码配对此设备。", + "Add {name} to your account": "将 {name} 添加到你的账户", + "Add this device to {name}": "将此设备添加到 {name}", + "Type the number {name} shows on its own screen, then approve there.": "输入 {name} 自己屏幕上显示的数字,然后在那台电脑上批准。", + "The number to check appears on another of your devices, not on {name}.": "要核对的数字会显示在你的另一台设备上,而不是 {name} 上。", + "Ask to join": "请求加入", + "Another of your devices shows three numbers: pick this one there.": "你的另一台设备上会显示三个数字,请在那里选择这个数字。", + "Waiting for an account member to approve this device…": "正在等待账户中的另一台设备批准此设备…", + "{name} signed in": "{name} 已登录", + "It asked to join your account": "它请求加入你的账户", + "Proves you are looking at that PC": "证明你正看着那台电脑", + "Approve on {name}": "在 {name} 上批准", + "The PC decides who gets in, not the account": "由电脑决定谁能进入,而不是账户", + "Every device of your account can open it": "你账户中的每台设备都能打开它", + "Through your account, with this device's key": "通过你的账户,使用此设备的密钥", + "Check the number on another of your devices": "在你的另一台设备上核对数字", + "It shows three; pick the one this device shows": "那里显示三个数字,选择此设备显示的那个", + "Approved by an account member": "由账户中的另一台设备批准", + "On that device, never on {name} itself": "在那台设备上批准,{name} 本身不会询问", + "This device can open {name}": "此设备可以打开 {name}", + "Seen lately": "最近出现过", + "Paired · status unknown": "已配对 · 状态未知", + "sees sizes, not content": "只看到大小,看不到内容", + "Connection map": "连接图", + "Connection map: {me}. Relays: {relays}. PCs: {pcs}. Seen lately: {lately}. Ready: {ready}. Needs you: {needsYou}. Offline: {offline}.": "连接图:{me}。中继:{relays}。PC:{pcs}。最近出现过:{lately}。就绪:{ready}。需要你回应:{needsYou}。离线:{offline}。", + "Overview": "概览", + "Devices": "设备", + "Join": "加入", + "Last seen": "上次出现", + "State": "状态", + "Summary": "摘要", + "Filter": "筛选", + "Action": "操作", + "Relays": "中继", + "regional": "区域", + "global": "全球", + "1 PC": "1 台 PC", + "{count} PCs": "{count} 台 PC", + "1 PC in your account": "账户中有 1 台 PC", + "{count} PCs in your account": "账户中有 {count} 台 PC", + "1 PC paired with this device": "与此设备配对的 PC 1 台", + "{count} PCs paired with this device": "与此设备配对的 PC {count} 台", + "Needs attention · {count}": "需要关注 · {count}", + "Nothing needs you right now.": "目前没有需要你处理的事项。", + "No PCs yet. Sign in on a PC, or scan the QR code it shows, to see it here.": "还没有电脑。在电脑上登录,或扫描它显示的二维码,它就会出现在这里。", + "No PC matches this filter.": "没有符合此筛选的电脑。", + "Without an account": "未使用账户", + "in your account": "已在账户中", + "new in your account": "账户中的新电脑", + "paired by QR · not in the account yet": "通过二维码配对 · 尚未加入账户", + "paired with this device": "已与此设备配对", + "{name} has not approved this device yet.": "{name} 尚未批准此设备。", + "{name} is new in your account. Type the number it shows on its own screen to add it.": "{name} 是账户中的新电脑。输入它屏幕上显示的数字即可添加。", + "Loading your PCs…": "正在加载你的 PC…", + "Remove this device": "移除此设备", + "Remove this device from your account": "从账户中移除此设备", + "Every PC of the account removes this device. You can pair it again later.": "账户中的所有 PC 都会移除此设备。之后可以再次配对。", + "Answered this device's latest check": "对此设备最近一次检查有响应", +}; diff --git a/src/lib/i18n/devices.ja.ts b/src/lib/i18n/devices.ja.ts index 396d9b985..aa2953cfa 100644 --- a/src/lib/i18n/devices.ja.ts +++ b/src/lib/i18n/devices.ja.ts @@ -66,7 +66,6 @@ export const DEVICES_JA: Record = { "A public HTTPS address for this PC through a Portal relay. Anyone on the internet can open it, so it needs an access token.": "Portal リレーを経由する、この PC の公開 HTTPS アドレスです。インターネット上の誰でも開けるため、アクセストークンが必要です。", "Portal serves this PC": "Portal がこの PC を公開しています", "Open it on your phone and sign in with the access token, or pair the phone with a pairing code.": "スマートフォンで開いてアクセストークンでサインインするか、ペアリングコードでスマートフォンをペアリングしてください。", - "Relay": "リレー", "Stop": "停止", "Stopping…": "停止しています…", "Recovering…": "復旧しています…", @@ -77,7 +76,6 @@ export const DEVICES_JA: Record = { "A link name is 1 to 256 printable ASCII characters; spaces are fine.": "リンク名は表示可能な ASCII 文字で 1〜256 文字です。スペースも使えます。", "A verified email, @example.com for a whole domain, or sub: for one account.": "確認済みのメールアドレス、ドメイン全体なら @example.com、アカウント 1 つなら sub:。", "A wallet is an Ethereum address: 0x and 40 hexadecimal digits.": "ウォレットは Ethereum アドレスです。0x に続く 16 進数 40 桁です。", - "Add": "追加", "Add at least one email, @domain or sub:: Portal will not start Google or company login without one.": "メールアドレス、@ドメイン、sub: を 1 つ以上追加してください。なければ Portal は Google/会社ログインを開始しません。", "Add at least one wallet: with none, any wallet could sign in.": "ウォレットを 1 つ以上追加してください。なければどのウォレットでもサインインできてしまいます。", "Allowed": "許可リスト", diff --git a/src/lib/i18n/devices.ko.ts b/src/lib/i18n/devices.ko.ts index 503c83f62..97627c67f 100644 --- a/src/lib/i18n/devices.ko.ts +++ b/src/lib/i18n/devices.ko.ts @@ -66,7 +66,6 @@ export const DEVICES_KO: Record = { "A public HTTPS address for this PC through a Portal relay. Anyone on the internet can open it, so it needs an access token.": "Portal 릴레이를 거치는 이 PC의 공개 HTTPS 주소입니다. 인터넷의 누구나 열 수 있으므로 접근 토큰이 필요합니다.", "Portal serves this PC": "Portal이 이 PC를 서빙하고 있습니다", "Open it on your phone and sign in with the access token, or pair the phone with a pairing code.": "폰에서 열고 접근 토큰으로 로그인하거나, 페어링 코드로 폰을 페어링하세요.", - "Relay": "릴레이", "Stop": "중지", "Stopping…": "중지 중…", "Recovering…": "복구 중…", @@ -77,7 +76,6 @@ export const DEVICES_KO: Record = { "A link name is 1 to 256 printable ASCII characters; spaces are fine.": "링크 이름은 인쇄 가능한 ASCII 문자 1~256자입니다. 공백도 쓸 수 있습니다.", "A verified email, @example.com for a whole domain, or sub: for one account.": "인증된 이메일, 도메인 전체는 @example.com, 계정 하나는 sub:.", "A wallet is an Ethereum address: 0x and 40 hexadecimal digits.": "지갑은 이더리움 주소입니다. 0x 뒤에 16진수 40자리가 옵니다.", - "Add": "추가", "Add at least one email, @domain or sub:: Portal will not start Google or company login without one.": "이메일, @도메인 또는 sub:를 하나 이상 추가하세요. 없으면 Portal이 Google·회사 로그인을 시작하지 않습니다.", "Add at least one wallet: with none, any wallet could sign in.": "지갑을 하나 이상 추가하세요. 없으면 어떤 지갑으로든 로그인할 수 있게 됩니다.", "Allowed": "허용 목록", diff --git a/src/lib/i18n/devices.zh.ts b/src/lib/i18n/devices.zh.ts index 9ad9b9905..e00de79b7 100644 --- a/src/lib/i18n/devices.zh.ts +++ b/src/lib/i18n/devices.zh.ts @@ -66,7 +66,6 @@ export const DEVICES_ZH: Record = { "A public HTTPS address for this PC through a Portal relay. Anyone on the internet can open it, so it needs an access token.": "经由 Portal 中继为此 PC 提供的公开 HTTPS 地址。互联网上任何人都能打开它,因此需要访问令牌。", "Portal serves this PC": "Portal 正在为此 PC 提供服务", "Open it on your phone and sign in with the access token, or pair the phone with a pairing code.": "在手机上打开它并用访问令牌登录,或用配对码配对手机。", - "Relay": "中继", "Stop": "停止", "Stopping…": "正在停止…", "Recovering…": "正在恢复…", @@ -77,7 +76,6 @@ export const DEVICES_ZH: Record = { "A link name is 1 to 256 printable ASCII characters; spaces are fine.": "链接名称为 1 到 256 个可打印的 ASCII 字符,可包含空格。", "A verified email, @example.com for a whole domain, or sub: for one account.": "已验证的邮箱;整个域名用 @example.com;单个账号用 sub:。", "A wallet is an Ethereum address: 0x and 40 hexadecimal digits.": "钱包是以太坊地址:0x 加 40 位十六进制数字。", - "Add": "添加", "Add at least one email, @domain or sub:: Portal will not start Google or company login without one.": "请至少添加一个邮箱、@域名或 sub:;没有的话 Portal 不会启动 Google 或公司登录。", "Add at least one wallet: with none, any wallet could sign in.": "请至少添加一个钱包;没有的话任何钱包都能登录。", "Allowed": "允许列表", diff --git a/src/lib/i18n/machines.ja.ts b/src/lib/i18n/machines.ja.ts index 1936617c8..ffa47dc72 100644 --- a/src/lib/i18n/machines.ja.ts +++ b/src/lib/i18n/machines.ja.ts @@ -5,7 +5,6 @@ * Register and fixed terms follow ../i18n.ja.ts; a key another feature also uses goes there. */ export const MACHINES_JA: Record = { - "PCs": "PC", "1 agent running": "実行中のエージェント: 1 件", "{n} agents running": "実行中のエージェント: {n} 件", "Keep this dialog open until the PC connects.": "PC が接続されるまで、このダイアログを開いたままにしてください。", diff --git a/src/lib/i18n/machines.ko.ts b/src/lib/i18n/machines.ko.ts index e775649d0..908fcce5e 100644 --- a/src/lib/i18n/machines.ko.ts +++ b/src/lib/i18n/machines.ko.ts @@ -5,7 +5,6 @@ * Register and fixed terms follow ../i18n.ko.ts; a key another feature also uses goes there. */ export const MACHINES_KO: Record = { - "PCs": "PC", "1 agent running": "실행 중인 에이전트 1개", "{n} agents running": "실행 중인 에이전트 {n}개", "Keep this dialog open until the PC connects.": "PC가 연결될 때까지 이 대화 상자를 열어 두세요.", diff --git a/src/lib/i18n/machines.zh.ts b/src/lib/i18n/machines.zh.ts index 86dfdda37..7761e6495 100644 --- a/src/lib/i18n/machines.zh.ts +++ b/src/lib/i18n/machines.zh.ts @@ -5,7 +5,6 @@ * Register and fixed terms follow ../i18n.zh.ts; a key another feature also uses goes there. */ export const MACHINES_ZH: Record = { - "PCs": "PC", "1 agent running": "1 个代理运行中", "{n} agents running": "{n} 个代理运行中", "Keep this dialog open until the PC connects.": "请保持此对话框打开,直到 PC 连接成功。", diff --git a/src/lib/i18n/palette.ja.ts b/src/lib/i18n/palette.ja.ts index 05a87b5d7..eb5601aae 100644 --- a/src/lib/i18n/palette.ja.ts +++ b/src/lib/i18n/palette.ja.ts @@ -12,7 +12,6 @@ export const PALETTE_JA: Record = { "Search panes and actions…": "ペインと操作を検索…", "Close command palette": "コマンドパレットを閉じる", "Filter panes by status": "状態でペインを絞り込む", - "All": "すべて", "Workspace": "ワークスペース", "Unknown workspace": "不明なワークスペース", "Actions": "操作", diff --git a/src/lib/i18n/palette.ko.ts b/src/lib/i18n/palette.ko.ts index b59a340ec..b5f10cfb7 100644 --- a/src/lib/i18n/palette.ko.ts +++ b/src/lib/i18n/palette.ko.ts @@ -12,7 +12,6 @@ export const PALETTE_KO: Record = { "Search panes and actions…": "패널과 동작 검색…", "Close command palette": "명령 팔레트 닫기", "Filter panes by status": "상태별 패널 필터", - "All": "전체", "Workspace": "워크스페이스", "Unknown workspace": "알 수 없는 워크스페이스", "Actions": "동작", diff --git a/src/lib/i18n/palette.zh.ts b/src/lib/i18n/palette.zh.ts index 1581644f8..c6b5e0d4e 100644 --- a/src/lib/i18n/palette.zh.ts +++ b/src/lib/i18n/palette.zh.ts @@ -12,7 +12,6 @@ export const PALETTE_ZH: Record = { "Search panes and actions…": "搜索窗格和操作…", "Close command palette": "关闭命令面板", "Filter panes by status": "按状态筛选窗格", - "All": "全部", "Workspace": "工作区", "Unknown workspace": "未知工作区", "Actions": "操作", diff --git a/src/lib/i18n/relay.ja.ts b/src/lib/i18n/relay.ja.ts index a0d49c8f0..13ce09048 100644 --- a/src/lib/i18n/relay.ja.ts +++ b/src/lib/i18n/relay.ja.ts @@ -8,29 +8,18 @@ export const RELAY_JA: Record = { "To add another PC, scan the QR code it shows under Devices and PCs (Phone & devices on an older Ovis).": "別のPCを追加するには、そのPCの「スマホとデバイス」に表示されるQRコードをスキャンしてください。", "Scan the QR code on your PC": "PCのQRコードをスキャンしてください", "On the PC, open Settings → Devices and PCs (Phone & devices on an older Ovis) and turn on remote access through the Ovis relay. The QR code there opens this app paired with that PC.": "PCで設定 → デバイスと PC(古い Ovis では スマートフォンとデバイス)を開き、Ovisリレー経由のリモートアクセスをオンにしてください。そこに表示されるQRコードで、このアプリがそのPCとペアリングされた状態で開きます。", - "Pair with {name}?": "{name}とペアリングしますか?", "This phone gets a key of its own for {name}. The PC lists it under Devices and PCs (Phone & devices on an older Ovis), where it can be removed.": "このスマホは{name}専用のキーを受け取ります。PCの「スマホとデバイス」に表示され、そこから削除できます。", - "Pair": "ペアリング", - "Connecting to {name}…": "{name}に接続中…", - "{name} is offline": "{name}はオフラインです", "Its Ovis is not connected to the relay right now. Start Ovis on the PC, or check its connection, then try again.": "そのPCのOvisは今リレーに接続していません。PCでOvisを起動するか接続を確認してから、もう一度お試しください。", - "{name} did not finish connecting": "{name}は接続を完了できませんでした", "The PC answered through the relay but did not complete the connection. Try again; if it keeps happening, restart Ovis on the PC.": "PCはリレー経由で応答しましたが、接続を完了できませんでした。もう一度お試しください。繰り返す場合はPCでOvisを再起動してください。", - "The relay could not be reached": "リレーに接続できません", "Check this phone's connection, then try again.": "このスマホのネットワーク接続を確認してから、もう一度お試しください。", - "Update needed": "アップデートが必要です", "{name} and this app speak different versions of the relay protocol. Update Ovis on the PC, then try again.": "{name}とこのアプリのリレープロトコルのバージョンが異なります。PCのOvisをアップデートしてから、もう一度お試しください。", - "This phone was removed from {name}": "このスマホは{name}から削除されました", "Its pairing was revoked on the PC, so this phone's key for it is gone. To pair again, scan a new QR code on the PC.": "PC側でペアリングが取り消されたため、このスマホのキーも削除されました。再度ペアリングするには、PCで新しいQRコードをスキャンしてください。", - "{name} has a new identity": "{name}の識別情報が変わりました", "This phone forgot {name}": "このスマホは{name}を削除しました", "This link is not {name}'s": "このリンクは{name}のものではありません", "It carries a key that is not this PC's, so this phone will not pair with it. To pair, scan a new QR code on the PC itself.": "このPCのものではないキーが含まれているため、このスマホはこのリンクでペアリングしません。ペアリングするには、PC本体で新しいQRコードをスキャンしてください。", "The link you opened carried a different key, so it is not used to pair again. To pair, scan a new QR code on the PC itself.": "開いたリンクには別のキーが含まれていたため、そのリンクでは再ペアリングしません。ペアリングするには、PC本体で新しいQRコードをスキャンしてください。", "The PC's relay key is not the one this phone paired with, so the saved pairing cannot be trusted. Forget it, then pair again from a new QR code on the PC.": "PCのリレーキーがこのスマホがペアリングしたものと異なるため、保存されたペアリングは信頼できません。このPCを削除してから、PCの新しいQRコードで再度ペアリングしてください。", - "That pairing code no longer works": "このペアリングコードはもう使えません", "The code was wrong, used up or expired. Scan a new QR code on the PC.": "コードが間違っているか、使用済みか、期限切れです。PCで新しいQRコードをスキャンしてください。", - "This pairing link is damaged": "このペアリングリンクは壊れています", "Part of the link is missing or changed. Scan the QR code on the PC again, or copy its link whole.": "リンクの一部が欠けているか変わっています。PCのQRコードをもう一度スキャンするか、リンク全体をコピーしてください。", "Add to Home Screen to stay paired: Safari may clear this page's storage after a week without a visit.": "ペアリングを保つにはホーム画面に追加してください。Safariは1週間アクセスのないページの保存データを消すことがあります。", "Update Ovis on {name}": "{name}のOvisをアップデートしてください", @@ -43,9 +32,6 @@ export const RELAY_JA: Record = { "Waiting for {name} to restart…": "{name}の再起動を待っています…", "The update did not finish.": "アップデートが完了しませんでした。", "Does {name} show this number?": "{name} にもこの数字が表示されていますか?", - "Approve this phone on {name} to finish.": "{name} でこのスマートフォンを承認すると完了します。", - "The numbers match": "数字が一致しています", - "{name} did not approve this phone": "{name} はこのスマートフォンを承認しませんでした", "It was declined on the PC, or not approved in time. Open the link again to compare new numbers.": "PC で拒否されたか、時間内に承認されませんでした。リンクをもう一度開いて新しい数字を比べてください。", "Not a valid key": "有効な鍵ではありません", "ID this link names": "このリンクが示すID", @@ -55,45 +41,26 @@ export const RELAY_JA: Record = { "Continue with GitHub": "GitHubで続ける", "Continue with Google": "Googleで続ける", "Continue with Apple": "Appleで続ける", - "Loading your PCs…": "PC を読み込み中…", "The sign-in was cancelled.": "サインインはキャンセルされました。", - "See every PC on your account.": "アカウントのすべての PC を表示します。", + "Your PCs and devices, through the Ovis relay.": "Ovis リレーを通じて、PC とデバイスにつながります。", "The account only lists your PCs. Sessions stay end-to-end encrypted.": "アカウントが知るのは PC の一覧だけです。セッションはエンドツーエンドで暗号化されます。", "Sign in instead": "サインインに戻る", - "Ready": "接続可能", - "Needs approval": "承認が必要", "Offline · {when}": "オフライン · {when}", - "Offline": "オフライン", "The PC is off, or Ovis is not running on it.": "PCの電源が切れているか、Ovisが動いていません。", "The relay did not answer. Check this device's network.": "リレーが応答しません。このデバイスのネットワークを確認してください。", - "Update Ovis on it": "Ovis の更新が必要", - "To add a PC, sign in on it or scan its QR code.": "PC を追加するには、その PC でサインインするか QR コードをスキャンします。", "If they match, approve this phone on {name}.": "一致したら {name} でこのスマホを承認してください。", - "Ready · not in the account yet": "接続可能 · まだアカウント外", - "{name} is not taking new phones right now": "{name} は今は新しいスマートフォンを受け付けていません", + "Ready · not in the account yet": "準備完了 · まだアカウント外", "Its link may have run out (it works for 10 minutes), or it may be paused after too many tries or busy with other phones. Copy a new link on the PC, or wait a few minutes.": "リンクの有効期限が切れた(10 分間有効)か、試行が多すぎて一時停止しているか、ほかのスマートフォンの対応中かもしれません。PC で新しいリンクをコピーするか、数分待ってください。", "Pair with a code": "コードでペアリング", "On the PC, open Settings → Devices and PCs → Add a connection → Ovis relay (Connect, or Show a code to type, on an older Ovis). Type the code it shows here. Where the PC uses the Ovis account, sign in to the same account instead.": "PC で「設定 → デバイスと PC → 接続を追加 → Ovis リレー」を開いてください(古い Ovis では「接続」または「入力するコードを表示」)。表示されたコードをここに入力してください。PC が Ovis アカウントを使っている場合は、代わりに同じアカウントでサインインしてください。", "Code from the PC": "PC に表示されたコード", "Looking for the PC…": "PC を探しています…", - "Every PC of the account removes this device. You can pair it again later.": "アカウントのすべての PC からこのデバイスが外れます。あとでまたペアリングできます。", - "Remove this device": "このデバイスを外す", - "Remove this device from your account": "アカウントからこのデバイスを外す", "This device could not leave the account. Try again.": "このデバイスをアカウントから外せませんでした。もう一度お試しください。", "This device left the account. These PCs the account does not list still have it, so revoke it on them: {pcs}": "このデバイスをアカウントから外しました。アカウントにない次の PC にはまだ残っているので、そこで取り消してください: {pcs}", - "{name} has not added this device yet": "{name} にはまだこのデバイスが追加されていません", "It adds the devices of your account a few minutes after they join. Try again in a moment.": "アカウントに参加したデバイスは数分以内に追加されます。少ししてからもう一度お試しください。", - "Add this PC": "このPCを追加", - "Add {name} to your account?": "{name} をアカウントに追加しますか?", - "New PC": "新しいPC", - "Nothing was added. Tap the PC again to compare a new number, and type the one its own screen shows.": "何も追加されていません。もう一度PCをタップして新しい番号を比べ、そのPC自身の画面に出ている番号を入力してください。", "Number on {name}": "{name} の番号", - "Other ways": "その他の方法", - "Other ways: QR code or pairing code": "その他の方法:QRコードまたはペアリングコード", - "Sign in to see every PC on your account and add new ones with one approval.": "サインインすると、アカウントのすべてのPCが表示され、新しいPCを一度の承認で追加できます。", - "That is not the number {name} shows": "{name} に表示されている番号ではありません", - "Type the number {name} shows": "{name} に表示された番号を入力してください", - "You type the number {name} shows on its own screen, then approve there. Every device of your account can then use it.": "{name} 自身の画面に出ている番号を入力し、そのPCで承認します。その後はアカウントのすべてのデバイスから使えます。", + "Continue without an account ({count} PCs)": "アカウントなしで続ける(PC {count} 台)", + "Continue without an account (1 PC)": "アカウントなしで続ける(PC 1 台)", "{name} shows six digits on its own screen now. Type them here, then approve on {name}.": "{name} の画面に6桁の番号が表示されています。ここに入力してから {name} で承認してください。", "No number on {name}? Cancel, then tap {name} in your list.": "{name} に番号が出ていませんか?キャンセルして、一覧の {name} をタップしてください。", "Only the PC itself shows its ID, at the top of Settings → Devices and PCs (My devices, or under the code, on an older Ovis). Marked groups differ.": "ID を表示するのは PC 自身だけです。設定 → デバイスと PC の一番上にあります(古い Ovis では「マイデバイス」またはコードの下)。印の付いたまとまりが異なります。", @@ -102,4 +69,15 @@ export const RELAY_JA: Record = { "It matches": "一致しています", "Does {name}'s screen show this ID?": "{name} の画面にこの ID が表示されていますか?", "ID of the PC this code found": "このコードで見つかった PC の ID", + "{name} did not approve this phone": "{name} はこのスマートフォンを承認しませんでした", + "{name} is not taking new phones right now": "{name} は今は新しいスマートフォンを受け付けていません", + "{name} has not added this device yet": "{name} にはまだこのデバイスが追加されていません", + "{name} is offline": "{name}はオフラインです", + "{name} did not finish connecting": "{name}は接続を完了できませんでした", + "The relay could not be reached": "リレーに接続できません", + "Update needed": "アップデートが必要です", + "This phone was removed from {name}": "このスマホは{name}から削除されました", + "{name} has a new identity": "{name}の識別情報が変わりました", + "That pairing code no longer works": "このペアリングコードはもう使えません", + "This pairing link is damaged": "このペアリングリンクは壊れています", }; diff --git a/src/lib/i18n/relay.ko.ts b/src/lib/i18n/relay.ko.ts index e16a966d9..ec48fdc38 100644 --- a/src/lib/i18n/relay.ko.ts +++ b/src/lib/i18n/relay.ko.ts @@ -8,29 +8,18 @@ export const RELAY_KO: Record = { "To add another PC, scan the QR code it shows under Devices and PCs (Phone & devices on an older Ovis).": "다른 PC를 추가하려면 그 PC의 기기와 PC(이전 Ovis에서는 폰 및 기기) 화면에 나오는 QR 코드를 스캔하세요.", "Scan the QR code on your PC": "PC에서 QR 코드를 스캔하세요", "On the PC, open Settings → Devices and PCs (Phone & devices on an older Ovis) and turn on remote access through the Ovis relay. The QR code there opens this app paired with that PC.": "PC에서 설정 → 기기와 PC(이전 Ovis에서는 폰 및 기기)를 열고 Ovis 릴레이를 통한 원격 접속을 켜세요. 거기에 나오는 QR 코드가 이 앱을 그 PC와 페어링된 상태로 엽니다.", - "Pair with {name}?": "{name}와(과) 페어링할까요?", "This phone gets a key of its own for {name}. The PC lists it under Devices and PCs (Phone & devices on an older Ovis), where it can be removed.": "이 폰은 {name} 전용 키를 받습니다. PC의 기기와 PC(이전 Ovis에서는 폰 및 기기) 목록에 나타나며 거기서 삭제할 수 있습니다.", - "Pair": "페어링", - "Connecting to {name}…": "{name}에 연결하는 중…", - "{name} is offline": "{name}이(가) 오프라인입니다", "Its Ovis is not connected to the relay right now. Start Ovis on the PC, or check its connection, then try again.": "그 PC의 Ovis가 지금 릴레이에 연결되어 있지 않습니다. PC에서 Ovis를 시작하거나 연결 상태를 확인한 뒤 다시 시도하세요.", - "{name} did not finish connecting": "{name}이(가) 연결을 끝내지 못했습니다", "The PC answered through the relay but did not complete the connection. Try again; if it keeps happening, restart Ovis on the PC.": "PC가 릴레이를 통해 응답했지만 연결을 완료하지 못했습니다. 다시 시도하고, 계속 그러면 PC에서 Ovis를 다시 시작하세요.", - "The relay could not be reached": "릴레이에 연결할 수 없습니다", "Check this phone's connection, then try again.": "이 폰의 네트워크 연결을 확인한 뒤 다시 시도하세요.", - "Update needed": "업데이트가 필요합니다", "{name} and this app speak different versions of the relay protocol. Update Ovis on the PC, then try again.": "{name}와(과) 이 앱의 릴레이 프로토콜 버전이 다릅니다. PC의 Ovis를 업데이트한 뒤 다시 시도하세요.", - "This phone was removed from {name}": "이 폰은 {name}에서 제거되었습니다", "Its pairing was revoked on the PC, so this phone's key for it is gone. To pair again, scan a new QR code on the PC.": "PC에서 페어링이 폐기되어 이 폰의 해당 키도 삭제되었습니다. 다시 페어링하려면 PC에서 새 QR 코드를 스캔하세요.", - "{name} has a new identity": "{name}의 신원이 바뀌었습니다", "This phone forgot {name}": "이 폰이 {name}을(를) 잊었습니다", "This link is not {name}'s": "이 링크는 {name}의 링크가 아닙니다", "It carries a key that is not this PC's, so this phone will not pair with it. To pair, scan a new QR code on the PC itself.": "이 PC의 키가 아닌 키가 들어 있어 이 폰은 이 링크로 페어링하지 않습니다. 페어링하려면 PC 화면에서 새 QR 코드를 스캔하세요.", "The link you opened carried a different key, so it is not used to pair again. To pair, scan a new QR code on the PC itself.": "연 링크에 다른 키가 들어 있었으므로 그 링크로는 다시 페어링하지 않습니다. 페어링하려면 PC 화면에서 새 QR 코드를 스캔하세요.", "The PC's relay key is not the one this phone paired with, so the saved pairing cannot be trusted. Forget it, then pair again from a new QR code on the PC.": "PC의 릴레이 키가 이 폰이 페어링했던 키와 다르므로 저장된 페어링을 신뢰할 수 없습니다. 이 PC를 잊은 뒤 PC의 새 QR 코드로 다시 페어링하세요.", - "That pairing code no longer works": "그 페어링 코드는 더 이상 쓸 수 없습니다", "The code was wrong, used up or expired. Scan a new QR code on the PC.": "코드가 틀렸거나 이미 쓰였거나 만료되었습니다. PC에서 새 QR 코드를 스캔하세요.", - "This pairing link is damaged": "이 페어링 링크는 손상되었습니다", "Part of the link is missing or changed. Scan the QR code on the PC again, or copy its link whole.": "링크의 일부가 빠졌거나 바뀌었습니다. PC에서 QR 코드를 다시 스캔하거나 링크 전체를 복사하세요.", "Add to Home Screen to stay paired: Safari may clear this page's storage after a week without a visit.": "페어링을 유지하려면 홈 화면에 추가하세요. Safari는 일주일 동안 방문하지 않은 페이지의 저장 데이터를 지울 수 있습니다.", "Update Ovis on {name}": "{name}의 Ovis를 업데이트하세요", @@ -43,9 +32,6 @@ export const RELAY_KO: Record = { "Waiting for {name} to restart…": "{name}이(가) 다시 시작되기를 기다리는 중…", "The update did not finish.": "업데이트가 끝나지 않았습니다.", "Does {name} show this number?": "{name}에도 이 숫자가 보이나요?", - "Approve this phone on {name} to finish.": "{name}에서 이 휴대폰을 승인하면 끝납니다.", - "The numbers match": "숫자가 같아요", - "{name} did not approve this phone": "{name}에서 이 휴대폰을 승인하지 않았습니다", "It was declined on the PC, or not approved in time. Open the link again to compare new numbers.": "PC에서 거절했거나 제시간에 승인하지 않았습니다. 링크를 다시 열어 새 숫자를 비교하세요.", "Not a valid key": "올바른 키가 아닙니다", "ID this link names": "이 링크가 가리키는 ID", @@ -55,45 +41,26 @@ export const RELAY_KO: Record = { "Continue with GitHub": "GitHub로 계속하기", "Continue with Google": "Google로 계속하기", "Continue with Apple": "Apple로 계속하기", - "Loading your PCs…": "PC 목록을 불러오는 중…", "The sign-in was cancelled.": "로그인이 취소되었습니다.", - "See every PC on your account.": "계정의 PC를 한곳에서 봅니다.", + "Your PCs and devices, through the Ovis relay.": "Ovis 릴레이로 내 PC와 기기에 연결합니다.", "The account only lists your PCs. Sessions stay end-to-end encrypted.": "계정은 PC 목록만 압니다. 세션은 종단 간 암호화됩니다.", "Sign in instead": "로그인으로 돌아가기", - "Ready": "연결됨", - "Needs approval": "승인 필요", "Offline · {when}": "오프라인 · {when}", - "Offline": "오프라인", "The PC is off, or Ovis is not running on it.": "PC가 꺼져 있거나 Ovis가 실행 중이 아닙니다.", "The relay did not answer. Check this device's network.": "릴레이가 답하지 않습니다. 이 기기의 네트워크를 확인하세요.", - "Update Ovis on it": "Ovis 업데이트 필요", - "To add a PC, sign in on it or scan its QR code.": "PC를 추가하려면 그 PC에서 로그인하거나 QR 코드를 스캔하세요.", "If they match, approve this phone on {name}.": "같으면 {name}에서 이 폰을 승인하세요.", - "Ready · not in the account yet": "연결됨 · 아직 계정에 없음", - "{name} is not taking new phones right now": "{name}이(가) 지금은 새 휴대폰을 받지 않습니다", + "Ready · not in the account yet": "준비됨 · 아직 계정에 없음", "Its link may have run out (it works for 10 minutes), or it may be paused after too many tries or busy with other phones. Copy a new link on the PC, or wait a few minutes.": "링크가 만료됐거나(10분 동안 쓸 수 있음), 시도가 너무 많아 잠시 멈췄거나, 다른 휴대폰을 처리하는 중일 수 있습니다. PC에서 새 링크를 복사하거나 몇 분 기다리세요.", "Pair with a code": "코드로 페어링", "On the PC, open Settings → Devices and PCs → Add a connection → Ovis relay (Connect, or Show a code to type, on an older Ovis). Type the code it shows here. Where the PC uses the Ovis account, sign in to the same account instead.": "PC에서 설정 → 기기와 PC → 연결 추가 → Ovis 릴레이를 여세요(이전 Ovis에서는 연결 또는 입력할 코드 보기). 거기 보이는 코드를 여기에 입력하세요. PC가 Ovis 계정을 쓰면 대신 같은 계정으로 로그인하세요.", "Code from the PC": "PC에 표시된 코드", "Looking for the PC…": "PC를 찾는 중…", - "Every PC of the account removes this device. You can pair it again later.": "계정의 모든 PC에서 이 기기가 빠집니다. 나중에 다시 연결할 수 있습니다.", - "Remove this device": "이 기기 빼기", - "Remove this device from your account": "계정에서 이 기기 빼기", "This device could not leave the account. Try again.": "이 기기를 계정에서 빼지 못했습니다. 다시 시도하세요.", "This device left the account. These PCs the account does not list still have it, so revoke it on them: {pcs}": "이 기기를 계정에서 뺐습니다. 계정 목록에 없는 이 PC들에는 아직 남아 있으니 거기서 취소하세요: {pcs}", - "{name} has not added this device yet": "{name}에 아직 이 기기가 추가되지 않았습니다", "It adds the devices of your account a few minutes after they join. Try again in a moment.": "계정에 새 기기가 들어오면 몇 분 안에 추가됩니다. 잠시 뒤 다시 시도하세요.", - "Add this PC": "이 PC 추가", - "Add {name} to your account?": "{name}을(를) 계정에 추가할까요?", - "New PC": "새 PC", - "Nothing was added. Tap the PC again to compare a new number, and type the one its own screen shows.": "아무것도 추가되지 않았습니다. PC를 다시 눌러 새 숫자를 비교하고, 그 PC 화면에 나온 숫자를 입력하세요.", "Number on {name}": "{name}에 나온 숫자", - "Other ways": "다른 방법", - "Other ways: QR code or pairing code": "다른 방법: QR 코드나 연결 코드", - "Sign in to see every PC on your account and add new ones with one approval.": "로그인하면 계정의 모든 PC를 보고, 새 PC를 한 번 승인으로 추가할 수 있습니다.", - "That is not the number {name} shows": "{name}에 나온 숫자가 아닙니다", - "Type the number {name} shows": "{name}에 나온 숫자를 입력하세요", - "You type the number {name} shows on its own screen, then approve there. Every device of your account can then use it.": "{name} 화면에 나온 숫자를 입력하고 그 PC에서 승인하세요. 그러면 계정의 모든 기기가 쓸 수 있습니다.", + "Continue without an account ({count} PCs)": "계정 없이 계속 (PC {count}대)", + "Continue without an account (1 PC)": "계정 없이 계속 (PC 1대)", "{name} shows six digits on its own screen now. Type them here, then approve on {name}.": "지금 {name} 화면에 여섯 자리 숫자가 나옵니다. 여기에 입력한 뒤 {name}에서 승인하세요.", "No number on {name}? Cancel, then tap {name} in your list.": "{name}에 숫자가 없나요? 취소한 뒤 목록에서 {name}을(를) 누르세요.", "Only the PC itself shows its ID, at the top of Settings → Devices and PCs (My devices, or under the code, on an older Ovis). Marked groups differ.": "PC 자신만 자기 ID를 보여 줍니다. 설정 → 기기와 PC 맨 위에 있습니다(이전 Ovis에서는 내 기기 또는 코드 아래). 표시된 묶음이 다릅니다.", @@ -102,4 +69,15 @@ export const RELAY_KO: Record = { "It matches": "같습니다", "Does {name}'s screen show this ID?": "{name} 화면에 이 ID가 보이나요?", "ID of the PC this code found": "이 코드로 찾은 PC의 ID", + "{name} did not approve this phone": "{name}에서 이 휴대폰을 승인하지 않았습니다", + "{name} is not taking new phones right now": "{name}이(가) 지금은 새 휴대폰을 받지 않습니다", + "{name} has not added this device yet": "{name}에 아직 이 기기가 추가되지 않았습니다", + "{name} is offline": "{name}이(가) 오프라인입니다", + "{name} did not finish connecting": "{name}이(가) 연결을 끝내지 못했습니다", + "The relay could not be reached": "릴레이에 연결할 수 없습니다", + "Update needed": "업데이트가 필요합니다", + "This phone was removed from {name}": "이 폰은 {name}에서 제거되었습니다", + "{name} has a new identity": "{name}의 신원이 바뀌었습니다", + "That pairing code no longer works": "그 페어링 코드는 더 이상 쓸 수 없습니다", + "This pairing link is damaged": "이 페어링 링크는 손상되었습니다", }; diff --git a/src/lib/i18n/relay.zh.ts b/src/lib/i18n/relay.zh.ts index eda02339a..f8c1e5ac9 100644 --- a/src/lib/i18n/relay.zh.ts +++ b/src/lib/i18n/relay.zh.ts @@ -8,29 +8,18 @@ export const RELAY_ZH: Record = { "To add another PC, scan the QR code it shows under Devices and PCs (Phone & devices on an older Ovis).": "要添加另一台电脑,请扫描它在“手机与设备”中显示的二维码。", "Scan the QR code on your PC": "请扫描电脑上的二维码", "On the PC, open Settings → Devices and PCs (Phone & devices on an older Ovis) and turn on remote access through the Ovis relay. The QR code there opens this app paired with that PC.": "在电脑上打开设置 → 手机与设备,开启通过 Ovis 中继的远程访问。那里显示的二维码会以与该电脑配对的状态打开此应用。", - "Pair with {name}?": "与 {name} 配对?", "This phone gets a key of its own for {name}. The PC lists it under Devices and PCs (Phone & devices on an older Ovis), where it can be removed.": "此手机将获得一把专用于 {name} 的密钥。电脑会在“手机与设备”中列出它,并可在那里移除。", - "Pair": "配对", - "Connecting to {name}…": "正在连接 {name}…", - "{name} is offline": "{name} 已离线", "Its Ovis is not connected to the relay right now. Start Ovis on the PC, or check its connection, then try again.": "该电脑的 Ovis 当前未连接到中继。请在电脑上启动 Ovis 或检查其网络连接,然后重试。", - "{name} did not finish connecting": "{name} 未能完成连接", "The PC answered through the relay but did not complete the connection. Try again; if it keeps happening, restart Ovis on the PC.": "电脑已通过中继作出应答,但未完成连接。请重试;如果持续出现,请在电脑上重新启动 Ovis。", - "The relay could not be reached": "无法连接到中继", "Check this phone's connection, then try again.": "请检查此手机的网络连接,然后重试。", - "Update needed": "需要更新", "{name} and this app speak different versions of the relay protocol. Update Ovis on the PC, then try again.": "{name} 与此应用使用的中继协议版本不同。请更新电脑上的 Ovis,然后重试。", - "This phone was removed from {name}": "此手机已从 {name} 移除", "Its pairing was revoked on the PC, so this phone's key for it is gone. To pair again, scan a new QR code on the PC.": "电脑上已撤销配对,此手机对应的密钥也已删除。要重新配对,请在电脑上扫描新的二维码。", - "{name} has a new identity": "{name} 的身份已更改", "This phone forgot {name}": "此手机已忘记 {name}", "This link is not {name}'s": "此链接不属于 {name}", "It carries a key that is not this PC's, so this phone will not pair with it. To pair, scan a new QR code on the PC itself.": "此链接带有不属于这台电脑的密钥,因此此手机不会用它配对。要配对,请扫描电脑上新的二维码。", "The link you opened carried a different key, so it is not used to pair again. To pair, scan a new QR code on the PC itself.": "你打开的链接带有不同的密钥,因此不会用它重新配对。要配对,请扫描电脑上新的二维码。", "The PC's relay key is not the one this phone paired with, so the saved pairing cannot be trusted. Forget it, then pair again from a new QR code on the PC.": "电脑的中继密钥与此手机配对时的不同,已保存的配对不再可信。请忘记这台电脑,然后用电脑上新的二维码重新配对。", - "That pairing code no longer works": "该配对码已失效", "The code was wrong, used up or expired. Scan a new QR code on the PC.": "配对码错误、已使用或已过期。请在电脑上扫描新的二维码。", - "This pairing link is damaged": "此配对链接已损坏", "Part of the link is missing or changed. Scan the QR code on the PC again, or copy its link whole.": "链接有部分缺失或被改动。请重新扫描电脑上的二维码,或完整复制链接。", "Add to Home Screen to stay paired: Safari may clear this page's storage after a week without a visit.": "添加到主屏幕以保持配对:Safari 可能会清除一周未访问页面的存储数据。", "Update Ovis on {name}": "请更新 {name} 上的 Ovis", @@ -43,9 +32,6 @@ export const RELAY_ZH: Record = { "Waiting for {name} to restart…": "正在等待 {name} 重启…", "The update did not finish.": "更新未完成。", "Does {name} show this number?": "{name} 上也显示这个数字吗?", - "Approve this phone on {name} to finish.": "在 {name} 上批准这部手机即可完成。", - "The numbers match": "数字一致", - "{name} did not approve this phone": "{name} 没有批准这部手机", "It was declined on the PC, or not approved in time. Open the link again to compare new numbers.": "PC 上拒绝了,或没有及时批准。请重新打开链接,比对新的数字。", "Not a valid key": "不是有效的密钥", "ID this link names": "此链接指明的 ID", @@ -55,45 +41,26 @@ export const RELAY_ZH: Record = { "Continue with GitHub": "使用 GitHub 继续", "Continue with Google": "使用 Google 继续", "Continue with Apple": "使用 Apple 继续", - "Loading your PCs…": "正在加载你的 PC…", "The sign-in was cancelled.": "登录已取消。", - "See every PC on your account.": "查看账户中的所有 PC。", + "Your PCs and devices, through the Ovis relay.": "通过 Ovis 中继连接你的 PC 和设备。", "The account only lists your PCs. Sessions stay end-to-end encrypted.": "账户只知道你的 PC 列表。会话保持端到端加密。", "Sign in instead": "改为登录", - "Ready": "可连接", - "Needs approval": "需要批准", "Offline · {when}": "离线 · {when}", - "Offline": "离线", "The PC is off, or Ovis is not running on it.": "电脑已关机,或其上的 Ovis 未运行。", "The relay did not answer. Check this device's network.": "中继没有响应。请检查此设备的网络。", - "Update Ovis on it": "需要更新 Ovis", - "To add a PC, sign in on it or scan its QR code.": "要添加 PC,请在该 PC 上登录或扫描其二维码。", "If they match, approve this phone on {name}.": "如果一致,请在 {name} 上批准此手机。", - "Ready · not in the account yet": "可连接 · 尚未加入账户", - "{name} is not taking new phones right now": "{name} 现在不接受新手机", + "Ready · not in the account yet": "就绪 · 尚未加入账户", "Its link may have run out (it works for 10 minutes), or it may be paused after too many tries or busy with other phones. Copy a new link on the PC, or wait a few minutes.": "链接可能已过期(有效 10 分钟),或因尝试次数过多而暂停,或正在处理其他手机。请在电脑上复制新链接,或等几分钟。", "Pair with a code": "用代码配对", "On the PC, open Settings → Devices and PCs → Add a connection → Ovis relay (Connect, or Show a code to type, on an older Ovis). Type the code it shows here. Where the PC uses the Ovis account, sign in to the same account instead.": "在电脑上打开“设置 → 设备和电脑 → 添加连接 → Ovis 中继”(旧版 Ovis 中为“连接”或“显示要输入的代码”)。在这里输入显示的代码。如果电脑使用 Ovis 账户,请改为登录同一账户。", "Code from the PC": "电脑上显示的代码", "Looking for the PC…": "正在查找电脑…", - "Every PC of the account removes this device. You can pair it again later.": "账户中的所有 PC 都会移除此设备。之后可以再次配对。", - "Remove this device": "移除此设备", - "Remove this device from your account": "从账户中移除此设备", "This device could not leave the account. Try again.": "未能将此设备移出账户。请重试。", "This device left the account. These PCs the account does not list still have it, so revoke it on them: {pcs}": "此设备已离开账户。账户未列出的以下 PC 上仍保留它,请在那里撤销:{pcs}", - "{name} has not added this device yet": "{name} 尚未添加此设备", "It adds the devices of your account a few minutes after they join. Try again in a moment.": "账户中的新设备会在几分钟内被添加。请稍后重试。", - "Add this PC": "添加此电脑", - "Add {name} to your account?": "要将 {name} 添加到账号吗?", - "New PC": "新电脑", - "Nothing was added. Tap the PC again to compare a new number, and type the one its own screen shows.": "未添加任何内容。请再次点按该电脑以比较新的数字,并输入它自己屏幕上显示的数字。", "Number on {name}": "{name} 上的数字", - "Other ways": "其他方式", - "Other ways: QR code or pairing code": "其他方式:二维码或配对码", - "Sign in to see every PC on your account and add new ones with one approval.": "登录后可查看账号的所有电脑,并只需批准一次即可添加新电脑。", - "That is not the number {name} shows": "这不是 {name} 显示的数字", - "Type the number {name} shows": "输入 {name} 显示的数字", - "You type the number {name} shows on its own screen, then approve there. Every device of your account can then use it.": "输入 {name} 自己屏幕上显示的数字,然后在那台电脑上批准。之后账号的所有设备都能使用它。", + "Continue without an account ({count} PCs)": "不使用账户继续({count} 台 PC)", + "Continue without an account (1 PC)": "不使用账户继续(1 台 PC)", "{name} shows six digits on its own screen now. Type them here, then approve on {name}.": "{name} 的屏幕上现在显示六位数字。请在此输入,然后在 {name} 上批准。", "No number on {name}? Cancel, then tap {name} in your list.": "{name} 上没有数字?请取消,然后在列表中点按 {name}。", "Only the PC itself shows its ID, at the top of Settings → Devices and PCs (My devices, or under the code, on an older Ovis). Marked groups differ.": "只有电脑本身会显示它的 ID,在“设置 → 设备和电脑”的顶部(旧版 Ovis 为“我的设备”或代码下方)。标记的组不一致。", @@ -102,4 +69,15 @@ export const RELAY_ZH: Record = { "It matches": "一致", "Does {name}'s screen show this ID?": "{name} 的屏幕上显示的是这个 ID 吗?", "ID of the PC this code found": "此代码找到的电脑的 ID", + "{name} did not approve this phone": "{name} 没有批准这部手机", + "{name} is not taking new phones right now": "{name} 现在不接受新手机", + "{name} has not added this device yet": "{name} 尚未添加此设备", + "{name} is offline": "{name} 已离线", + "{name} did not finish connecting": "{name} 未能完成连接", + "The relay could not be reached": "无法连接到中继", + "Update needed": "需要更新", + "This phone was removed from {name}": "此手机已从 {name} 移除", + "{name} has a new identity": "{name} 的身份已更改", + "That pairing code no longer works": "该配对码已失效", + "This pairing link is damaged": "此配对链接已损坏", }; diff --git a/src/lib/i18n/updates.ja.ts b/src/lib/i18n/updates.ja.ts index 0457d29e5..db4787e55 100644 --- a/src/lib/i18n/updates.ja.ts +++ b/src/lib/i18n/updates.ja.ts @@ -43,7 +43,6 @@ export const UPDATES_JA: Record = { "How to restart": "再起動の方法", "Ovis v{version} is available.": "Ovis v{version} が利用可能です。", "An Ovis update is available.": "Ovis の更新が利用可能です。", - "Update": "更新", "Starting the update…": "更新を開始しています…", "The update could not be installed.": "更新をインストールできませんでした。", "Downloading the update": "更新をダウンロード中", diff --git a/src/lib/i18n/updates.ko.ts b/src/lib/i18n/updates.ko.ts index 4312749df..a6cbab326 100644 --- a/src/lib/i18n/updates.ko.ts +++ b/src/lib/i18n/updates.ko.ts @@ -43,7 +43,6 @@ export const UPDATES_KO: Record = { "How to restart": "다시 시작하는 방법", "Ovis v{version} is available.": "Ovis v{version}이 나왔습니다.", "An Ovis update is available.": "Ovis 업데이트가 있습니다.", - "Update": "업데이트", "Starting the update…": "업데이트를 시작하는 중…", "The update could not be installed.": "업데이트를 설치하지 못했습니다.", "Downloading the update": "업데이트 내려받는 중", diff --git a/src/lib/i18n/updates.zh.ts b/src/lib/i18n/updates.zh.ts index 985bf8e09..722c3c246 100644 --- a/src/lib/i18n/updates.zh.ts +++ b/src/lib/i18n/updates.zh.ts @@ -43,7 +43,6 @@ export const UPDATES_ZH: Record = { "How to restart": "如何重新启动", "Ovis v{version} is available.": "Ovis v{version} 已发布。", "An Ovis update is available.": "Ovis 有可用更新。", - "Update": "更新", "Starting the update…": "正在开始更新…", "The update could not be installed.": "无法安装更新。", "Downloading the update": "正在下载更新", diff --git a/src/lib/i18n/workspace.ja.ts b/src/lib/i18n/workspace.ja.ts index f8feccd3b..9768d8a0d 100644 --- a/src/lib/i18n/workspace.ja.ts +++ b/src/lib/i18n/workspace.ja.ts @@ -47,7 +47,6 @@ export const WORKSPACE_JA: Record = { "absolute path or ~/…": "絶対パスまたは ~/…", "Optional workspace label": "ワークスペースのラベル (任意)", "Workspace label; follows the branch until you change it": "ワークスペースのラベル。変更するまでブランチ名に従います", - "Open": "開く", "The workspace opened, but {agent} did not start. Check that it is installed on the PC, then open it again.": "ワークスペースは開きましたが、{agent} は起動しませんでした。PC にインストールされているか確認してから、もう一度開いてください。", "{agent} started, but its first message was not queued. It is in the new tab's message box: open the tab and send it.": "{agent} は起動しましたが、最初のメッセージはキューに入りませんでした。メッセージは新しいタブの入力欄にあります。タブを開いて送信してください。", "{agent} started, but this PC's bridge does not take a first message yet. It is in the new tab's message box: open the tab and send it.": "{agent} は起動しましたが、この PC のブリッジはまだ最初のメッセージを受け付けません。メッセージは新しいタブの入力欄にあります。タブを開いて送信してください。", diff --git a/src/lib/i18n/workspace.ko.ts b/src/lib/i18n/workspace.ko.ts index a8b6910f9..0483509eb 100644 --- a/src/lib/i18n/workspace.ko.ts +++ b/src/lib/i18n/workspace.ko.ts @@ -47,7 +47,6 @@ export const WORKSPACE_KO: Record = { "absolute path or ~/…": "절대 경로 또는 ~/…", "Optional workspace label": "워크스페이스 이름 (선택)", "Workspace label; follows the branch until you change it": "워크스페이스 이름. 직접 바꾸기 전까지 브랜치를 따릅니다", - "Open": "열기", "The workspace opened, but {agent} did not start. Check that it is installed on the PC, then open it again.": "워크스페이스는 열렸지만 {agent} 에이전트는 시작되지 않았습니다. PC에 설치되어 있는지 확인한 뒤 다시 여세요.", "{agent} started, but its first message was not queued. It is in the new tab's message box: open the tab and send it.": "{agent} 에이전트는 시작되었지만 첫 메시지는 대기열에 넣지 못했습니다. 메시지는 새 탭의 입력창에 있습니다. 탭을 열어 보내세요.", "{agent} started, but this PC's bridge does not take a first message yet. It is in the new tab's message box: open the tab and send it.": "{agent} 에이전트는 시작되었지만 이 PC의 브리지는 아직 첫 메시지를 받지 않습니다. 메시지는 새 탭의 입력창에 있습니다. 탭을 열어 보내세요.", diff --git a/src/lib/i18n/workspace.zh.ts b/src/lib/i18n/workspace.zh.ts index a6852939c..dfc47f49f 100644 --- a/src/lib/i18n/workspace.zh.ts +++ b/src/lib/i18n/workspace.zh.ts @@ -47,7 +47,6 @@ export const WORKSPACE_ZH: Record = { "absolute path or ~/…": "绝对路径或 ~/…", "Optional workspace label": "工作区名称(可选)", "Workspace label; follows the branch until you change it": "工作区名称;修改之前跟随分支名", - "Open": "打开", "The workspace opened, but {agent} did not start. Check that it is installed on the PC, then open it again.": "工作区已打开,但 {agent} 未能启动。请确认 PC 上已安装它,然后再次打开。", "{agent} started, but its first message was not queued. It is in the new tab's message box: open the tab and send it.": "{agent} 已启动,但第一条消息未能排队。消息在新标签页的输入框中:请打开该标签页并发送。", "{agent} started, but this PC's bridge does not take a first message yet. It is in the new tab's message box: open the tab and send it.": "{agent} 已启动,但这台 PC 的桥接尚不接收第一条消息。消息在新标签页的输入框中:请打开该标签页并发送。", diff --git a/src/lib/relay/dashboard.test.ts b/src/lib/relay/dashboard.test.ts new file mode 100644 index 000000000..51beb14b2 --- /dev/null +++ b/src/lib/relay/dashboard.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "bun:test"; +import type { MyPc } from "../account/myPcs.ts"; +import type { DashboardPc } from "./dashboard.ts"; +import { dashboardModel, dashboardPcs, relayHost } from "./dashboard.ts"; + +function pc(server_id: string, state: DashboardPc["state"], relay: string | null = null, last_seen_at: string | null = null): DashboardPc { + return { + server_id, name: server_id, state, relay, last_seen_at, added_at: null, + listed: true, paired: state === "paired", linked: false, + }; +} + +describe("dashboardModel", () => { + it("counts each PC state in its dashboard category", () => { + const model = dashboardModel([ + pc("paired", "paired"), pc("approval", "needs-approval"), + pc("update", "needs-update"), pc("offline", "offline"), + ], { label: "Me" }, "wss://relay.ovis.sh"); + expect(model.counts).toEqual({ ready: 1, needsYou: 2, offline: 1 }); + }); + + it("groups PCs by relay and orders hubs by descending PC count", () => { + const global = "wss://relay.ovis.sh"; + const regional = "wss://relay-ap.ovis.sh"; + const model = dashboardModel([ + pc("c", "paired", regional), pc("a", "paired", global), pc("b", "offline", global), + ], { label: "Me" }, global); + expect(model.relays).toEqual([ + { origin: regional, regional: true, pcs: ["c"] }, + { origin: global, regional: false, pcs: ["a", "b"] }, + ]); + expect(model.map.hubs.map((hub) => hub.origin)).toEqual([global, regional]); + }); + + it("places a PC without a relay under the default relay", () => { + const model = dashboardModel([pc("a", "paired")], { label: "Me" }, "wss://relay.ovis.sh"); + expect(model.relays).toEqual([{ origin: "wss://relay.ovis.sh", regional: false, pcs: ["a"] }]); + }); + + it("returns zero counts and no relay groups for an empty list", () => { + expect(dashboardModel([], { label: "Me" }, "wss://relay.ovis.sh")).toMatchObject({ + counts: { ready: 0, needsYou: 0, offline: 0 }, + relays: [], + map: { hubs: [] }, + }); + }); + + it("marks paired, unknown, and offline sightings", () => { + const model = dashboardModel([ + pc("seen", "paired", null, "2026-11-07T00:00:00Z"), + pc("never-seen", "paired"), + pc("old", "offline", null, "2026-11-01T00:00:00Z"), + pc("approval-seen", "needs-approval", null, "2026-11-07T00:00:00Z"), + pc("approval-unknown", "needs-approval"), + ], { label: "Me" }, "wss://relay.ovis.sh"); + expect(model.map.hubs[0]?.nodes.map((node) => [node.server_id, node.seen])).toEqual([ + ["seen", "lately"], ["never-seen", "unknown"], ["old", "stale"], + ["approval-seen", "unknown"], ["approval-unknown", "unknown"], + ]); + }); + + it("matches the four-PC relay homepage mockup", () => { + const regional = "wss://relay-ap.ovis.sh"; + const global = "wss://relay.ovis.sh"; + const model = dashboardModel([ + pc("Desk PC", "paired", regional, "2026-11-07T00:00:00Z"), + pc("MacBook", "paired", regional), + pc("Mac mini", "needs-approval", regional, "2026-11-07T00:00:00Z"), + pc("Home server", "offline", global, "2026-11-01T00:00:00Z"), + ], { label: "Me" }, global); + expect(model.counts).toEqual({ ready: 2, needsYou: 1, offline: 1 }); + expect(model.map.hubs.map((hub) => hub.origin)).toEqual([regional, global]); + expect(model.map.hubs[0]?.nodes.map(({ server_id, seen }) => [server_id, seen])).toEqual([ + ["Desk PC", "lately"], ["MacBook", "unknown"], ["Mac mini", "unknown"], + ]); + }); + + it("marks only configured regional relay origins as regional", () => { + const model = dashboardModel([ + pc("regional", "paired", "wss://relay-ap.ovis.sh"), + pc("self-hosted", "paired", "wss://relay.example.test"), + ], { label: "Me" }, "wss://relay.ovis.sh"); + expect(model.relays.map(({ origin, regional }) => [origin, regional])).toEqual([ + ["wss://relay-ap.ovis.sh", true], + ["wss://relay.example.test", false], + ]); + }); + + it("names a relay by its host, and an origin that is not a URL as it is", () => { + expect(relayHost("wss://relay-ap.ovis.sh")).toBe("relay-ap.ovis.sh"); + expect(relayHost("wss://relay.example:8443/path")).toBe("relay.example:8443"); + expect(relayHost("not a url")).toBe("not a url"); + // the map, the strip and the table name one origin alike: a default port is the same relay + expect(relayHost("wss://relay.example:443")).toBe("relay.example"); + }); + + it("preserves myPcs order for every PC needing attention", () => { + const model = dashboardModel([ + pc("one", "needs-update"), pc("ready", "paired"), pc("two", "offline"), pc("three", "needs-approval"), + ], { label: "Me" }, "wss://relay.ovis.sh"); + expect(model.attention).toEqual([ + { server_id: "one", reason: "needs-update" }, + { server_id: "two", reason: "offline" }, + { server_id: "three", reason: "needs-approval" }, + ]); + }); +}); + +describe("dashboardPcs", () => { + const row = (server_id: string, state: MyPc["state"], extra: Partial = {}): MyPc => ({ server_id, name: server_id, state, last_seen_at: null, added_at: null, listed: false, paired: state === "paired", linked: false, ...extra }); + + it("takes the directory's relay for a PC the account lists, its join route's for one this device is not paired with, and the pairing's for one paired without the account", () => { + const pcs = dashboardPcs( + [row("listed", "paired", { listed: true }), row("joinable", "needs-approval", { listed: true, join: { server_public_key: "k", relay: "wss://relay-ap.ovis.sh" } }), row("qr", "paired")], + [{ server_id: "listed", relay: "wss://relay.old.example" }, { server_id: "qr", relay: "wss://relay.example" }], + [{ server_id: "listed", relay: "wss://relay.ovis.sh" }, { server_id: "joinable", relay: "wss://relay-ap.ovis.sh" }], + ); + expect(pcs.map(({ server_id, relay }) => [server_id, relay])).toEqual([["listed", "wss://relay.ovis.sh"], ["joinable", "wss://relay-ap.ovis.sh"], ["qr", "wss://relay.example"]]); + // the rows keep everything myPcs said of them + expect(pcs[0]).toMatchObject({ state: "paired", listed: true, paired: true }); + }); + + it("falls back from a directory entry without a relay to the pairing's, and to null when neither knows one", () => { + const pcs = dashboardPcs( + [row("paired", "paired", { listed: true }), row("unknown", "needs-approval", { listed: true })], + [{ server_id: "paired", relay: "wss://relay.example" }], + [{ server_id: "paired", relay: null }, { server_id: "unknown", relay: null }], + ); + expect(pcs.map(({ relay }) => relay)).toEqual(["wss://relay.example", null]); + // the model then puts the unknown one under the shell's default relay + expect(dashboardModel(pcs, { label: "Me" }, "wss://relay.ovis.sh").relays.map(({ origin, pcs: ids }) => [origin, ids])).toEqual([["wss://relay.example", ["paired"]], ["wss://relay.ovis.sh", ["unknown"]]]); + }); + + it("works without a directory: every saved pairing is a local PC on its own relay", () => { + expect(dashboardPcs([row("a", "paired")], [{ server_id: "a", relay: "wss://relay-ap.ovis.sh" }]).map(({ relay }) => relay)).toEqual(["wss://relay-ap.ovis.sh"]); + }); +}); diff --git a/src/lib/relay/dashboard.ts b/src/lib/relay/dashboard.ts new file mode 100644 index 000000000..31bdfd3c0 --- /dev/null +++ b/src/lib/relay/dashboard.ts @@ -0,0 +1,66 @@ +import type { MyPc, MyPcState } from "../account/myPcs.ts"; +import { REGIONAL_RELAYS } from "../../../shared/relay-regions.ts"; + +export type DashboardPc = MyPc & { relay: string | null }; + +/** + * The PCs with the relay each one goes through: the account directory's `relay` for a PC it lists + * (the one its `join` route carries, docs/account.md "GET /v1/pcs"), else the relay of this device's + * own pairing (RelayDevice.relay) for a PC paired without the account, else null, which + * dashboardModel puts under the shell's default relay. Pure: `saved` is whatever the device store listed. + */ +export function dashboardPcs(pcs: readonly MyPc[], saved: readonly { server_id: string; relay: string }[], directory: readonly { server_id: string; relay: string | null }[] = []): DashboardPc[] { + const listed = new Map(directory.map((pc) => [pc.server_id, pc.relay])); + const paired = new Map(saved.map((device) => [device.server_id, device.relay])); + return pcs.map((pc) => ({ ...pc, relay: listed.get(pc.server_id) ?? pc.join?.relay ?? paired.get(pc.server_id) ?? null })); +} + +/** A relay origin as a row names it: its host alone (`relay-ap.ovis.sh`), or the origin as given when it is not a URL. */ +export function relayHost(origin: string): string { + try { return new URL(origin).host; } catch { return origin; } +} + +export function dashboardModel(pcs: readonly DashboardPc[], me: { label: string }, defaultRelay: string) { + const relays = new Map(); + const hubs = new Map(); + const counts = { ready: 0, needsYou: 0, offline: 0 }; + const attention: { server_id: string; reason: MyPcState }[] = []; + + for (const pc of pcs) { + if (pc.state === "paired") counts.ready++; + else if (pc.state === "offline") counts.offline++; + else counts.needsYou++; + + if (pc.state !== "paired") attention.push({ server_id: pc.server_id, reason: pc.state }); + + const origin = pc.relay ?? defaultRelay; + const relayPcs = relays.get(origin) ?? []; + relayPcs.push(pc.server_id); + relays.set(origin, relayPcs); + + const hubPcs = hubs.get(origin) ?? []; + hubPcs.push(pc); + hubs.set(origin, hubPcs); + } + + return { + counts, + relays: [...relays].map(([origin, ids]) => ({ origin, regional: REGIONAL_RELAYS.includes(origin), pcs: ids })), + attention, + map: { + me: { label: me.label }, + hubs: [...hubs] + .sort(([a, pcsA], [b, pcsB]) => pcsB.length - pcsA.length || a.localeCompare(b)) + .map(([origin, members]) => ({ + origin, + nodes: members.map((pc) => ({ + server_id: pc.server_id, + name: pc.name, + state: pc.state, + seen: pc.state === "offline" ? "stale" as const + : pc.state === "paired" && pc.last_seen_at !== null ? "lately" as const : "unknown" as const, + })), + })), + }, + }; +} diff --git a/src/lib/relay/shell.test.tsx b/src/lib/relay/shell.test.tsx new file mode 100644 index 000000000..4d2378c08 --- /dev/null +++ b/src/lib/relay/shell.test.tsx @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "bun:test"; +import type { ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { exportPublicKey, generateKeyPair } from "../../../shared/relay-e2ee.ts"; +import { encodeOffer } from "../../../shared/relay-offer.ts"; +import { deriveServerId } from "../../../shared/relay-server-proof.ts"; +import { RelayShell, type RelayShellProps } from "../../components/RelayShell.tsx"; +import { memoryAccountStore } from "../account/store.ts"; +import type { RelayDevice, RelayDeviceStore } from "./device.ts"; +import { mountRelayShell, type RelayShellMount } from "./shell.tsx"; + +const AP = "wss://relay-ap.ovis.sh"; +const SERVICE = "https://account.ovis.sh"; +const SHEET_JOIN = "ovis-add-pc-sheet"; + +const keyFor = () => { const pair = generateKeyPair(); return { server_id: deriveServerId(pair.publicKey), server_public_key: exportPublicKey(pair.publicKey) }; }; + +/** Spins the microtask queue until `holds`, or fails naming `what`: the shell's reads are promises alone, so no clock takes part. */ +async function until(holds: () => boolean, what: string): Promise { + for (let turn = 0; turn < 10_000; turn++) { + if (holds()) return; + await Promise.resolve(); + } + throw new Error(`gave up waiting for ${what}`); +} +/** Enough turns for any read the shell wrongly started to reach the fetch (a read takes a few dozen). */ +const drain = async (): Promise => { for (let turn = 0; turn < 500; turn++) await Promise.resolve(); }; + +describe("the hosted shell's front page", () => { + const descriptors = new Map(); + let visibility: "visible" | "hidden"; + let page: EventTarget; + let sessionStore: Map; + /** every element the shell asked its root to draw, in order */ + let rendered: ReactElement[]; + + beforeEach(() => { + visibility = "visible"; + page = new EventTarget(); + sessionStore = new Map(); + rendered = []; + for (const key of ["window", "document", "navigator", "localStorage", "sessionStorage", "matchMedia"]) descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + const storage = (store: Map) => ({ getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => { store.set(key, value); }, removeItem: (key: string) => { store.delete(key); }, key: () => null, length: 0, clear: () => store.clear() }); + const matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["en"], userAgent: "Mozilla/5.0 (X11; Linux x86_64)", maxTouchPoints: 0 } }); + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage(new Map()) }); + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage(sessionStore) }); + Object.defineProperty(globalThis, "matchMedia", { configurable: true, value: matchMedia }); + const events = new EventTarget(); + Object.defineProperty(globalThis, "window", { configurable: true, value: { + location: { href: "https://app.ovis.sh/", search: "", hash: "", origin: "https://app.ovis.sh", pathname: "/" }, + history: { state: null, replaceState() {} }, + matchMedia, innerWidth: 1440, + addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), dispatchEvent: events.dispatchEvent.bind(events), + } }); + Object.defineProperty(globalThis, "document", { configurable: true, value: { + get visibilityState() { return visibility; }, + documentElement: { dataset: {}, style: { setProperty() {}, removeProperty() {} } }, + querySelector: () => null, + addEventListener: page.addEventListener.bind(page), removeEventListener: page.removeEventListener.bind(page), dispatchEvent: page.dispatchEvent.bind(page), + } }); + }); + + afterEach(() => { + jest.useRealTimers(); + for (const [key, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + descriptors.clear(); + }); + + const deviceStore = (devices: RelayDevice[]): RelayDeviceStore => ({ + async get(serverId) { return devices.find((device) => device.server_id === serverId); }, + async list() { return [...devices]; }, + async remove(serverId) { const at = devices.findIndex((device) => device.server_id === serverId); if (at >= 0) devices.splice(at, 1); }, + async update(serverId, change) { const next = change(devices.find((device) => device.server_id === serverId)); const at = devices.findIndex((device) => device.server_id === serverId); if (at >= 0) devices[at] = next; else devices.push(next); return next; }, + }); + const mountFor = (over: Partial): RelayShellMount => ({ + container: {} as HTMLElement, entryUrl: "https://app.ovis.sh/", shellVersion: "0.0.0-test", label: "Test phone", + renderApp: () => { throw new Error("the app is not expected here"); }, + createRoot: () => ({ render(element: ReactElement) { rendered.push(element); }, unmount() {} }), + open: () => Promise.reject(new Error("no connection is expected here")), + checkPc: async () => ({ kind: "offline", reason: "relay" }), + reload: async () => {}, + accountService: null, + ...over, + }); + const shellProps = (element: ReactElement): RelayShellProps => { + let node = element; + while (node.type !== RelayShell) node = (node.props as { children: ReactElement }).children; + return node.props as RelayShellProps; + }; + const last = (): RelayShellProps => shellProps(rendered.at(-1)!); + const account = () => { const { state } = last(); if ((state.kind !== "list" && state.kind !== "empty") || state.account?.kind !== "signed-in") throw new Error(`not the signed-in front page: ${state.kind}`); return state.account; }; + const rows = (): number => renderToStaticMarkup(rendered.at(-1)!).match(/ { + jest.useFakeTimers(); + const desk = keyFor(), mini = keyFor(), nuc = keyFor(); + const now = Date.now(); + const seen = new Date(now - 60_000).toISOString(); + const directory = [ + { server_id: desk.server_id, label: "Desk PC", added_at: new Date(now - 86_400_000).toISOString(), last_seen_at: seen, public_key: null, relay: AP }, + { server_id: mini.server_id, label: "Mac mini", added_at: new Date(now - 86_400_000).toISOString(), last_seen_at: seen, public_key: null, relay: AP }, + { server_id: nuc.server_id, label: "nuc", added_at: new Date(now - 86_400_000).toISOString(), last_seen_at: seen, public_key: null, relay: "wss://relay.ovis.sh" }, + ]; + type Answer = { status: number; ok: boolean; json: () => Promise }; + const ok = (): Answer => ({ status: 200, ok: true, json: async () => ({ pcs: directory }) }); + const down = (): Answer => ({ status: 500, ok: false, json: async () => ({ error: { code: "down" } }) }); + let answer: () => Answer = ok; + let reads = 0; + const accountFetch = async (input: Request | string | URL): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname !== "/v1/pcs") throw new Error(`unexpected request ${url.pathname}`); + reads += 1; + return answer() as unknown as Response; + }; + const accountStore = memoryAccountStore(); + await accountStore.set("session", { service: SERVICE, account: "acct-test", provider: "github", access_token: "access", refresh_token: "refresh", expires_at: now + 3_600_000 }); + const paired: RelayDevice = { server_id: desk.server_id, device_id: "0".repeat(16), private_key: {} as CryptoKey, public_spki: "spki", label: "Test phone", server_public_key: "k", relay: AP, name: "Desk PC" }; + + // Given the page is opened in the background + visibility = "hidden"; + await mountRelayShell(mountFor({ accountService: SERVICE, accountProviders: ["github"], accountStore, accountFetch, store: deviceStore([paired]) })); + await until(() => reads === 1 && rendered.length > 0 && account().pcs !== null, "the first read drawn"); + expect(rows()).toBe(3); + expect(account().pcs!.map((pc) => [pc.name, pc.state])).toEqual([["Desk PC", "paired"], ["Mac mini", "needs-approval"], ["nuc", "needs-approval"]]); + + // Then nothing is read again while it stays hidden, however long + visible("hidden"); + jest.advanceTimersByTime(180_000); + await drain(); + expect(reads).toBe(1); + + // When it comes into view: one read at once + visible("visible"); + await until(() => reads === 2, "the read on coming into view"); + const drawn = rendered.length; + await until(() => rendered.length > drawn, "that read drawn"); + expect(rows()).toBe(3); + + // then one every 60 s, not before + jest.advanceTimersByTime(59_999); + await drain(); + expect(reads).toBe(2); + jest.advanceTimersByTime(1); + await until(() => reads === 3, "the read at 60 s"); + jest.advanceTimersByTime(60_000); + await until(() => reads === 4, "the read at 120 s"); + + // a read that fails keeps the rows and says so under them + answer = down; + const before = rendered.length; + jest.advanceTimersByTime(60_000); + await until(() => reads === 5 && rendered.length > before, "the failed read drawn"); + expect(rows()).toBe(3); + expect(account().pcs).toHaveLength(3); + expect(account().error).toContain("could not be reached"); + answer = ok; + + // hidden again: the timer stops with it + visible("hidden"); + jest.advanceTimersByTime(600_000); + await drain(); + expect(reads).toBe(5); + + // back in view: read again, and the failure's line goes + const shown = rendered.length; + visible("visible"); + await until(() => reads === 6 && rendered.length > shown, "the read on coming back drawn"); + expect(account().error).toBeNull(); + expect(rows()).toBe(3); + }); + + it("reads nothing again while the first read is still out, so an older answer never lands over a newer one", async () => { + const desk = keyFor(); + const now = Date.now(); + const directory = [{ server_id: desk.server_id, label: "Desk PC", added_at: new Date(now - 86_400_000).toISOString(), last_seen_at: new Date(now - 60_000).toISOString(), public_key: null, relay: AP }]; + let reads = 0; + let answer: () => void = () => {}; + const held = new Promise((resolve) => { answer = resolve; }); + const accountFetch = async (input: Request | string | URL): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname !== "/v1/pcs") throw new Error(`unexpected request ${url.pathname}`); + reads += 1; + if (reads === 1) await held; + return { status: 200, ok: true, json: async () => ({ pcs: directory }) } as unknown as Response; + }; + const accountStore = memoryAccountStore(); + await accountStore.set("session", { service: SERVICE, account: "acct-test", provider: "github", access_token: "access", refresh_token: "refresh", expires_at: now + 3_600_000 }); + + const mounted = mountRelayShell(mountFor({ accountService: SERVICE, accountProviders: ["github"], accountStore, accountFetch, store: deviceStore([]) })); + await until(() => reads === 1 && rendered.length > 0 && account().pcs === null, "the loading dashboard with its first read out"); + visible("visible"); + await drain(); + expect(reads).toBe(1); + + answer(); + await mounted; + await until(() => account().pcs !== null, "the first read drawn"); + expect(account().pcs!.map((pc) => pc.name)).toEqual(["Desk PC"]); + }); + + it("keeps a code being typed when the first read lands under it", async () => { + let answer: () => void = () => {}; + const held = new Promise((resolve) => { answer = resolve; }); + let reads = 0; + const accountFetch = async (): Promise => { + reads += 1; + await held; + return { status: 200, ok: true, json: async () => ({ pcs: [] }) } as unknown as Response; + }; + const accountStore = memoryAccountStore(); + await accountStore.set("session", { service: SERVICE, account: "acct-test", provider: "github", access_token: "access", refresh_token: "refresh", expires_at: Date.now() + 3_600_000 }); + + const mounted = mountRelayShell(mountFor({ accountService: SERVICE, accountProviders: ["github"], accountStore, accountFetch, store: deviceStore([]) })); + await until(() => reads === 1 && rendered.length > 0 && account().pcs === null, "the loading dashboard with its first read out"); + // Add a PC, Pair with a QR or code, Pair with a code + last().onCodeStart(); + await until(() => last().state.kind === "code-entry", "the code entry"); + + answer(); + await mounted; + await drain(); + expect(last().state.kind).toBe("code-entry"); + }); + + it("signs the page out when a read again finds the sign-in gone, rather than keeping the account's rows", async () => { + const desk = keyFor(); + const now = Date.now(); + const directory = [{ server_id: desk.server_id, label: "Desk PC", added_at: new Date(now - 86_400_000).toISOString(), last_seen_at: new Date(now - 60_000).toISOString(), public_key: null, relay: AP }]; + const accountFetch = async (): Promise => ({ status: 200, ok: true, json: async () => ({ pcs: directory }) }) as unknown as Response; + const accountStore = memoryAccountStore(); + await accountStore.set("session", { service: SERVICE, account: "acct-test", provider: "github", access_token: "access", refresh_token: "refresh", expires_at: now + 3_600_000 }); + + await mountRelayShell(mountFor({ accountService: SERVICE, accountProviders: ["github"], accountStore, accountFetch, store: deviceStore([]) })); + await until(() => rendered.length > 0 && account().pcs !== null, "the first read drawn"); + + // another tab signed out: the session is gone from the shared store + await accountStore.set("session", undefined); + visible("visible"); + await until(() => { const { state } = last(); return (state.kind === "list" || state.kind === "empty") && state.account?.kind === "signed-out"; }, "the signed-out front page"); + }); + + it("starts one read when two reasons to read again land in the same moment", async () => { + jest.useFakeTimers(); + const desk = keyFor(); + const now = Date.now(); + const directory = [{ server_id: desk.server_id, label: "Desk PC", added_at: new Date(now - 86_400_000).toISOString(), last_seen_at: new Date(now - 60_000).toISOString(), public_key: null, relay: AP }]; + let reads = 0; + /** holds every read after the first open until the test lets it answer, so the triggers below overlap it */ + let answer: () => void = () => {}; + const held = new Promise((resolve) => { answer = resolve; }); + const accountFetch = async (input: Request | string | URL): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname !== "/v1/pcs") throw new Error(`unexpected request ${url.pathname}`); + reads += 1; + if (reads > 1) await held; + return { status: 200, ok: true, json: async () => ({ pcs: directory }) } as unknown as Response; + }; + const accountStore = memoryAccountStore(); + await accountStore.set("session", { service: SERVICE, account: "acct-test", provider: "github", access_token: "access", refresh_token: "refresh", expires_at: now + 3_600_000 }); + + await mountRelayShell(mountFor({ accountService: SERVICE, accountProviders: ["github"], accountStore, accountFetch, store: deviceStore([]) })); + await until(() => reads === 1 && rendered.length > 0 && account().pcs !== null, "the first read drawn"); + + // two visibility changes in one tick (a tab switched away and back), then the 60 s tick while that read is out + visible("visible"); + visible("visible"); + await until(() => reads >= 2, "the read on coming into view"); + await drain(); + expect(reads).toBe(2); + jest.advanceTimersByTime(60_000); + await drain(); + expect(reads).toBe(2); + + const drawn = rendered.length; + answer(); + await until(() => rendered.length > drawn, "the read drawn"); + await drain(); + expect(reads).toBe(2); + expect(account().pcs!.map((pc) => pc.name)).toEqual(["Desk PC"]); + }); + + it("takes up the dashboard's sheet on the PC's page only for the PC the join was for, once", async () => { + const pc = keyFor(); + const other = keyFor(); + const entryUrl = encodeOffer({ v: 1, server_id: pc.server_id, server_public_key: pc.server_public_key, relay: AP, name: "Desk PC" }, "https://app.ovis.sh"); + const arriveWith = async (marker: unknown): Promise => { + if (marker !== undefined) sessionStore.set(SHEET_JOIN, typeof marker === "string" ? marker : JSON.stringify(marker)); + rendered = []; + await mountRelayShell(mountFor({ entryUrl })); + expect(last().state.kind).toBe("confirm"); + return last(); + }; + + // a marker for another PC is not this page's join + expect((await arriveWith({ mode: "new", server_id: other.server_id })).sheetJoin).toBeNull(); + // whatever the marker, the page took it: a later plain visit shows the screens alone + expect(sessionStore.has(SHEET_JOIN)).toBe(false); + // this PC's join goes on in the sheet, in its mode + expect((await arriveWith({ mode: "join", server_id: pc.server_id })).sheetJoin).toEqual({ mode: "join", server_id: pc.server_id }); + expect(sessionStore.has(SHEET_JOIN)).toBe(false); + expect((await arriveWith({ mode: "new", server_id: pc.server_id })).sheetJoin).toEqual({ mode: "new", server_id: pc.server_id }); + // no marker, a mode that is no join, or a marker that does not parse: the screens alone + expect((await arriveWith(undefined)).sheetJoin).toBeNull(); + expect((await arriveWith({ mode: "add", server_id: pc.server_id })).sheetJoin).toBeNull(); + expect((await arriveWith("{not json")).sheetJoin).toBeNull(); + }); +}); diff --git a/src/lib/relay/shell.tsx b/src/lib/relay/shell.tsx index a9117835b..4becbc7b5 100644 --- a/src/lib/relay/shell.tsx +++ b/src/lib/relay/shell.tsx @@ -11,9 +11,12 @@ import { ACCOUNT_FEATURE, MIN_SERVER_VERSION, MuxProtocolError } from "../../../ import type { RelayOffer } from "../../../shared/relay-offer.ts"; import type { UpdateStatus } from "../../../shared/update.ts"; import { RelayShell } from "../../components/RelayShell.tsx"; +import { GLOBAL_RELAY } from "../../../shared/relay-regions.ts"; import { myPcs } from "../account/myPcs.ts"; import type { MyPc } from "../account/myPcs.ts"; import { accountProvidersOf, AccountService, offeredProviders, shellAccountServiceOf, SignedOutError } from "../account/service.ts"; +import type { DirectoryPc } from "../account/service.ts"; +import type { AccountSession } from "../account/store.ts"; import { beginSignIn, finishSignIn, SignInError, signInReturn } from "../account/signin.ts"; import { enrollOnPc, keepRemovals, PEER_STATUS_MS, linkThisDevice, needsLink, pcAccountStatus, planEnroll, sendQueuedAdds, takeIntroduction } from "../account/join.ts"; import { leaveAccount } from "../account/leave.ts"; @@ -54,6 +57,8 @@ export interface RelayShellMount { entryUrl: string; /** mounts the app on the container once the transport is set; what it returns is unmounted should the PC turn this phone away later */ renderApp: () => Root; + /** draws the shell's screens on the container: react-dom's root by default; tests hand in their own */ + createRoot?: (container: HTMLElement) => Root; store?: RelayDeviceStore; pendingPush?: PendingPushStore; open?: typeof openRelayTransport; @@ -88,6 +93,9 @@ const INTRODUCED_GRACE_MS = 15 * 60_000; const INTRODUCTION_WAIT_MS = 60_000; const INTRODUCTION_POLL_MS = 3_000; +/** How often the dashboard reads the account's directory again while the page is in view (and once more when it comes back into view). */ +const DIRECTORY_REFRESH_MS = 60_000; + /** How long a PC's update may take, from the install request to the restarted server: a build on a slow PC included. */ const UPDATE_MS = 15 * 60_000; /** How often the shell reads the PC's update status while it installs. */ @@ -197,6 +205,27 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { const setTypedJoin = (serverId: string | null): void => { try { if (serverId === null) sessionStorage.removeItem(TYPED_JOIN); else sessionStorage.setItem(TYPED_JOIN, serverId); } catch { /* no storage: the plain comparison stays */ } }; + /** + * A join the dashboard's Add-a-PC sheet started: kept the same way across the PC's page load, read + * once when that page arrives with the join's offer, so the sheet takes the join up again there and + * a later plain visit to the PC's page never shows it (RelayShellProps.sheetJoin). + */ + const SHEET_JOIN = "ovis-add-pc-sheet"; + type SheetJoin = { mode: "new" | "join"; server_id: string }; + let sheetJoin: SheetJoin | null = null; + const takeSheetJoin = (serverId: string): SheetJoin | null => { + try { + const kept = sessionStorage.getItem(SHEET_JOIN); + sessionStorage.removeItem(SHEET_JOIN); + const parsed: unknown = kept === null ? null : JSON.parse(kept); + if (typeof parsed !== "object" || parsed === null) return null; + const { mode, server_id } = parsed as { mode?: unknown; server_id?: unknown }; + return (mode === "new" || mode === "join") && server_id === serverId ? { mode, server_id } : null; + } catch { return null; } + }; + const setSheetJoin = (join: SheetJoin | null): void => { + try { if (join === null) sessionStorage.removeItem(SHEET_JOIN); else sessionStorage.setItem(SHEET_JOIN, JSON.stringify(join)); } catch { /* no storage: the pairing screens stand alone */ } + }; /** the phone apps' way to the PC's direct carriers; none in a browser, which reaches the PC through the relay only */ const directSocket = nativeDirectSocket(); /** whether that socket opens the PC's Tailscale address too, asked now so no connect waits for the answer */ @@ -286,11 +315,14 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { }, (error: unknown) => console.warn("Ovis: the account service's sign-ins could not be read", error instanceof Error ? error.name : error)); } + /** the account's directory as last read, for the relay each PC of it named (the dashboard's rows) */ + let directory: readonly DirectoryPc[] = []; const show = (state: RelayShellState): void => { shown = state; - shell ??= createRoot(mount.container); + shell ??= (mount.createRoot ?? createRoot)(mount.container); shell.render(createElement(StrictMode, null, createElement(SettingsProvider, null, - createElement(RelayShell, { state, scoped, iosTab, pcStatus: new Map(pcStatus), onCheckPcs: () => void checkPcs(), onPair: pair, onRetry: retry, onCancel: cancel, onBack: toFront, onForget: forget, onUpdate: update, onMatch: match, onCodeStart: codeStart, onCodeSubmit: codeSubmit, onCodeMatch: codeMatch, ...(accountService === null ? {} : { onSignIn: signIn, accountProviders, onJoin: joinPc, onSignOut: signOut, onLeave: leave }) })))); + createElement(RelayShell, { state, scoped, iosTab, pcStatus: new Map(pcStatus), onCheckPcs: () => void checkPcs(), onPair: pair, onRetry: retry, onCancel: cancel, onBack: toFront, onForget: forget, onUpdate: update, onMatch: match, onCodeStart: codeStart, onCodeSubmit: codeSubmit, onCodeMatch: codeMatch, defaultRelay: GLOBAL_RELAY, meLabel: label, directoryRelays: directory, sheetJoin, ...(accountService === null ? {} : { onSignIn: signIn, accountProviders, onJoin: joinPc, onSignOut: signOut, onLeave: leave }) })))); + armRefresh(); }; /** The front page: the saved PCs, or the account's once signed in. */ @@ -303,6 +335,10 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { } }; + /** The rows of the front page: `listed` merged with the PCs this device is paired with, as of now. */ + const pcsOf = async (session: AccountSession, listed: readonly DirectoryPc[]): Promise => + myPcs(listed, await store.list(), { now: Date.now(), accountKey: (await accountStore.key()).public_spki, account: session.account }); + /** The account's directory merged with the PCs this device is paired with; the pairings work whatever the service says. */ const loadAccount = async (): Promise => { if (accountService === null) { await showFront(); return; } @@ -312,29 +348,85 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { accountView = signedIn; await showFront(); try { - const directory = await account.listPcs(); + directory = await account.listPcs(); // an add this device signed that some PCs missed goes out again whenever the account answers sendQueuedAdds({ service: account, store: accountStore, account: session.account }).catch((error: unknown) => console.warn("Ovis: kept adds could not be sent", error instanceof Error ? error.name : error)); - accountView = { ...signedIn, pcs: myPcs(directory, await store.list(), { now: Date.now(), accountKey: (await accountStore.key()).public_spki, account: session.account }) }; + accountView = { ...signedIn, pcs: await pcsOf(session, directory) }; } catch (error) { if (error instanceof SignedOutError) accountView = { kind: "signed-out", error: t("Your sign-in ended. Sign in again to see every PC of your account.") }; else { console.warn("Ovis: the account's PCs could not be read", error); - accountView = { ...signedIn, pcs: myPcs([], await store.list(), { now: Date.now(), accountKey: (await accountStore.key()).public_spki, account: session.account }), error: t("The account service could not be reached. The PCs this device is paired with still work.") }; + accountView = { ...signedIn, pcs: await pcsOf(session, []), error: t("The account service could not be reached. The PCs this device is paired with still work.") }; } } - await showFront(); + // a pairing screen opened while the read was out (Add a PC, then a code) stays, with what was typed + if (dashboardUp() || accountView?.kind === "signed-out") await showFront(); }; + /** Whether the dashboard is up: the front page, signed in, with no PC's app over it. */ + const dashboardUp = (): boolean => app === null && accountView?.kind === "signed-in" && (shown?.kind === "list" || shown?.kind === "empty"); + let refreshTimer: ReturnType | undefined; + let refreshing: Promise | undefined; + /** + * The dashboard's directory read again: what it says of each PC (seen lately, offline, waiting for + * this device) changes while the page is open, so it is read once more when the page comes back + * into view and every DIRECTORY_REFRESH_MS while it stays in view. A read that fails keeps the rows + * shown and says so under them; one the service refuses signs the page out, as the first read does. + * The guard is taken before anything is awaited, so a visibility change and a timer tick in the same + * moment start one read; a sign-out or leave meanwhile drops the read's rows and relays alike. + */ + const refreshAccount = async (): Promise => { + // rows still null: loadAccount's first read is out, and its answer must not land over a newer one + if (refreshing || !dashboardUp() || document.visibilityState !== "visible" || accountView?.kind !== "signed-in" || accountView.pcs === null) return; + const current = accountView; + const read: Promise = (async () => { + try { + const session = await account.session(); + if (accountView !== current) return; + // the sign-in ended elsewhere (another tab signed out): its rows go with it + if (session === undefined) throw new SignedOutError(); + const listed = await account.listPcs(); + if (accountView !== current) return; + sendQueuedAdds({ service: account, store: accountStore, account: session.account }).catch((error: unknown) => console.warn("Ovis: kept adds could not be sent", error instanceof Error ? error.name : error)); + const pcs = await pcsOf(session, listed); + if (accountView !== current) return; + directory = listed; + accountView = { ...current, pcs, error: null }; + } catch (error) { + if (accountView !== current) return; + if (error instanceof SignedOutError) accountView = { kind: "signed-out", error: t("Your sign-in ended. Sign in again to see every PC of your account.") }; + else { + console.warn("Ovis: the account's PCs could not be read again", error instanceof Error ? error.name : error); + accountView = { ...current, error: t("The account service could not be reached. The PCs this device is paired with still work.") }; + } + } + if (dashboardUp() || accountView?.kind === "signed-out") await showFront(); + })().finally(() => { if (refreshing === read) refreshing = undefined; }); + refreshing = read; + await read; + }; + /** The refresh timer runs only while the dashboard is up and the page in view; `show` and the app's mount call this. */ + const armRefresh = (): void => { + const wanted = dashboardUp() && document.visibilityState === "visible"; + if (wanted && refreshTimer === undefined) refreshTimer = setInterval(() => { void refreshAccount(); }, DIRECTORY_REFRESH_MS); + else if (!wanted && refreshTimer !== undefined) { clearInterval(refreshTimer); refreshTimer = undefined; } + }; + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") void refreshAccount(); + armRefresh(); + }); + /** * A PC of the account this device is not paired with: compare digits with it, as a link without a - * code does. `typed`: from the new-PC card, whose PC shows its digits on its own screen (docs/account.md, "Adding a PC"). + * code does. `typed`: a PC new in the account, whose PC shows its digits on its own screen (docs/account.md, "Adding a PC"); + * `sheet`: the dashboard's Add-a-PC sheet asked, and goes on over the PC's page in that mode. */ - const joinPc = (pc: MyPc, options: { typed?: boolean } = {}): void => { + const joinPc = (pc: MyPc, options: { typed?: boolean; sheet?: "new" | "join" } = {}): void => { const join = pc.join; if (join === undefined) return; const link = new URL(encodeOffer({ v: 1, server_id: pc.server_id, server_public_key: join.server_public_key, relay: join.relay, name: pc.name }, window.location.origin)); setTypedJoin(options.typed ? pc.server_id : null); + setSheetJoin(options.sheet ? { mode: options.sheet, server_id: pc.server_id } : null); navigate(link.pathname + link.hash); }; @@ -400,7 +492,7 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { const detail = error instanceof RelayUnreachableError && error.closeCode !== null ? error.message : null; const mismatch = error instanceof ServerIdentityChangedError && error.saved !== undefined && error.offered !== undefined ? keyMismatch(attempt.offer.server_id, [{ key: error.saved, from: "saved" }, { key: error.offered, from: "link" }]) : null; - show({ kind: "failed", failure, name: attempt.offer.name ?? null, detail, ...(mismatch ? { mismatch } : {}) }); + show({ kind: "failed", failure, name: attempt.offer.name ?? null, detail, server_id: attempt.offer.server_id, ...(mismatch ? { mismatch } : {}) }); }; const connect = async (attempt: Attempt): Promise => { @@ -469,6 +561,7 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { shell?.unmount(); shell = null; app = mount.renderApp(); + armRefresh(); registerPending(attempt.offer.server_id); void joinAccount(opened, attempt.offer.server_id, attempt.fresh === true); }; @@ -715,6 +808,7 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { const cancel = (): void => { comparison = null; setTypedJoin(null); + sheetJoin = null; codeOffer = null; opening?.abort(); opening = null; @@ -743,7 +837,11 @@ export async function mountRelayShell(mount: RelayShellMount): Promise { entry = { ...entry, offer: null }; return true; } - if (entry.offer !== null) { show({ kind: "confirm", offer: entry.offer }); return true; } + if (entry.offer !== null) { + sheetJoin = takeSheetJoin(entry.offer.server_id); + show({ kind: "confirm", offer: entry.offer }); + return true; + } return false; }; diff --git a/src/lib/relay/shellState.ts b/src/lib/relay/shellState.ts index 40ab5a610..5b748a041 100644 --- a/src/lib/relay/shellState.ts +++ b/src/lib/relay/shellState.ts @@ -89,8 +89,8 @@ export type RelayShellState = | { kind: "forgotten"; name: string | null } /** a link with another key under a PC's id (forgedOffer): refused every time, and no saved key is touched */ | { kind: "forged"; name: string | null; mismatch: KeyMismatch | null } - /** `mismatch`: on identity-changed, the key that is not the PC's, when the phone can name it */ - | { kind: "failed"; failure: RelayFailure; name: string | null; detail: string | null; mismatch?: KeyMismatch | null }; + /** `mismatch`: on identity-changed, the key that is not the PC's, when the phone can name it; `server_id`: the PC a connection attempt failed for */ + | { kind: "failed"; failure: RelayFailure; name: string | null; detail: string | null; mismatch?: KeyMismatch | null; server_id?: string }; /** The page a shell load is: the PC its path names and the offer its fragment carried, if any. */ export interface ShellEntry {