From 710235ceac1cec83925ae730394e2ab2586f4850 Mon Sep 17 00:00:00 2001 From: kipavy Date: Mon, 17 Aug 2026 09:31:32 +0000 Subject: [PATCH 1/3] refactor(multiplayer): collapse the repeated server+JWT preamble Six call sites resolved the server URL and JWT with the same four lines and the same two error keys. listActiveSessions keeps its own copy: it degrades to an empty list instead of throwing. --- src/services/multiplayerService.ts | 93 ++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 24 deletions(-) diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts index b16582a95..07c4c8e5a 100644 --- a/src/services/multiplayerService.ts +++ b/src/services/multiplayerService.ts @@ -4,6 +4,7 @@ import { getVaultKey } from "@/services/vault"; import * as teamService from "@/services/teamService"; import { freshPublicKeys, type InviteTarget } from "@/services/teamSharing"; import { appFetch } from "@/services/http"; +import { normalizeShortCode } from "@/services/shortCode"; import { openXChaCha20Poly1305, sealXChaCha20Poly1305 } from "@/services/crypto/xchacha"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -118,6 +119,18 @@ export async function unwrapSessionKey( // ─── Server API ─────────────────────────────────────────────────────────────── +/** + * Server URL and JWT, or a throw naming whichever half is missing. Callers that + * degrade instead of failing (`listActiveSessions`) read the two values directly. + */ +async function requireServer(): Promise<{ serverUrl: string; jwt: string }> { + const serverUrl = await teamService.getServerUrlValue(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const jwt = await teamService.getJwtToken(); + if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated")); + return { serverUrl, jwt }; +} + export async function listActiveSessions(): Promise { const serverUrl = await teamService.getServerUrlValue(); if (!serverUrl) return []; @@ -196,10 +209,7 @@ export async function createVaultSession( ): Promise<{ sessionId: string; sessionKey: SessionKey; sessionKeyBytes: Uint8Array }> { const { sessionKey, sessionKeyBytes, wrappedKeys } = await prepareWrappedSessionKey(members); - const serverUrl = await teamService.getServerUrlValue(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const jwt = await teamService.getJwtToken(); - if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated")); + const { serverUrl, jwt } = await requireServer(); const res = await appFetch(`${serverUrl}/v1/terminal-sessions`, { method: "POST", @@ -231,10 +241,7 @@ export async function createDirectSession( ): Promise<{ sessionId: string; sessionKey: SessionKey; sessionKeyBytes: Uint8Array }> { const { sessionKey, sessionKeyBytes, wrappedKeys } = await prepareWrappedSessionKey(invitees); - const serverUrl = await teamService.getServerUrlValue(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const jwt = await teamService.getJwtToken(); - if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated")); + const { serverUrl, jwt } = await requireServer(); const res = await appFetch(`${serverUrl}/v1/terminal-sessions`, { method: "POST", @@ -268,10 +275,7 @@ export async function inviteUserToSession( // wrapping to a cached key fails with aead::Error on the recipient (#66). const wrappedKey = await wrapSessionKeyForUser(sessionKeyBytes, await resolveStrangerPublicKey(target.user_id)); - const serverUrl = await teamService.getServerUrlValue(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const jwt = await teamService.getJwtToken(); - if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated")); + const { serverUrl, jwt } = await requireServer(); const res = await appFetch(`${serverUrl}/v1/terminal-sessions/${sessionId}/invitees`, { method: "POST", @@ -291,10 +295,7 @@ export async function inviteUserToSession( export async function createInviteLinkSession( connectionName: string, ): Promise<{ sessionId: string; sessionKey: SessionKey; inviteToken: string }> { - const serverUrl = await teamService.getServerUrlValue(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const jwt = await teamService.getJwtToken(); - if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated")); + const { serverUrl, jwt } = await requireServer(); const sessionKeyBytes = crypto.getRandomValues(new Uint8Array(32)); const sessionKey = await importSessionKey(sessionKeyBytes); @@ -318,14 +319,61 @@ export async function createInviteLinkSession( return { sessionId: session_id, sessionKey, inviteToken: invite_token as string }; } +/** + * Host: mint a short code for an already-live invite-link session. Minting revokes + * any previous code server-side, so the value returned here is the only live one. + */ +export async function mintSessionCode( + sessionId: string, +): Promise<{ code: string; expiresAt: string }> { + const { serverUrl, jwt } = await requireServer(); + + const res = await appFetch(`${serverUrl}/v1/terminal-sessions/${sessionId}/code`, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (!res.ok) throw new Error(i18n.t("common.error.failedToMintInviteCode", { status: res.status })); + const { code, expires_at } = await res.json(); + + return { code: code as string, expiresAt: expires_at as string }; +} + +/** + * Guest: exchange a short code for the session it belongs to and a join secret of + * this guest's own. The secret is what every later request presents; the code is + * never sent again. + */ +export async function redeemSessionCode( + code: string, +): Promise<{ sessionId: string; inviteToken: string }> { + const normalized = normalizeShortCode(code); + if (!normalized) throw new Error(i18n.t("common.error.inviteCodeMalformed")); + + const { serverUrl, jwt } = await requireServer(); + + const res = await appFetch(`${serverUrl}/v1/terminal-sessions/redeem`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: normalized }), + }); + // The server answers 404 for unknown, expired and revoked alike — deliberately, + // so a wrong code reveals nothing. Do not invent a distinction here. + if (res.status === 404) throw new Error(i18n.t("common.error.inviteCodeNotFound")); + if (res.status === 429) throw new Error(i18n.t("common.error.inviteCodeTooManyAttempts")); + if (!res.ok) throw new Error(i18n.t("common.error.failedToRedeemInviteCode", { status: res.status })); + const { session_id, invite_token } = await res.json(); + + return { sessionId: session_id as string, inviteToken: invite_token as string }; +} + export async function getMySessionKey( sessionId: string, inviteToken?: string, ): Promise<{ sessionKey: SessionKey; hostPublicKey: string }> { - const serverUrl = await teamService.getServerUrlValue(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const jwt = await teamService.getJwtToken(); - if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated")); + const { serverUrl, jwt } = await requireServer(); const url = inviteToken ? `${serverUrl}/v1/terminal-sessions/${sessionId}/my-key?invite_token=${encodeURIComponent(inviteToken)}` @@ -352,10 +400,7 @@ export async function getMySessionKey( } export async function endMultiplayerSession(sessionId: string): Promise { - const serverUrl = await teamService.getServerUrlValue(); - if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); - const jwt = await teamService.getJwtToken(); - if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated")); + const { serverUrl, jwt } = await requireServer(); await appFetch(`${serverUrl}/v1/terminal-sessions/${sessionId}`, { method: "DELETE", headers: { Authorization: `Bearer ${jwt}` }, From df6f6c5ef7e7ec29a581a65df61d69fad2d3bbc2 Mon Sep 17 00:00:00 2001 From: kipavy Date: Mon, 17 Aug 2026 09:31:41 +0000 Subject: [PATCH 2/3] fix(share): keep an invite link reachable after the menu closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server returns invite_token once, at creation, and it was held only in ShareMenu's local state — so reopening the menu on a session that was already sharing showed an empty Link tab, with no way back to the link short of stopping and restarting the share. Retain it on the connection instead. --- src/components/terminal/ShareMenu.tsx | 15 ++++++++++++--- src/stores/teamSessionStore.ts | 4 ++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/terminal/ShareMenu.tsx b/src/components/terminal/ShareMenu.tsx index f3d503a60..98164e71c 100644 --- a/src/components/terminal/ShareMenu.tsx +++ b/src/components/terminal/ShareMenu.tsx @@ -10,6 +10,7 @@ import { uninviteFromSession } from "@/services/teamService"; import { guestCapFor, highestOwnerTier, inviteSessionOf, membersOfTeams, seatUsage, type InviteSession, type InviteTarget, type ShareTier } from "@/services/teamSharing"; import { useDelayedUnmount } from "@/hooks/useDelayedUnmount"; import { InviteCodeField } from "./InviteCodeField"; +import { SpokenCodeRow } from "./SpokenCodeRow"; import { PeopleTab } from "./PeopleTab"; import { ParticipantsRatioNotice } from "./ParticipantsRatioNotice"; @@ -59,6 +60,10 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio const activeMp = mpConnections[activeSessionId]; const isSharing = !!activeMp && !activeMp.ended; + // The store's copy outlives this menu's local state, so reopening a sharing + // session shows the link it already has instead of an empty tab. + const linkToken = activeMp?.inviteToken ?? inviteLinkToken; + // The server's record of this session, if one exists yet — the source of truth for // vault scope and per-invitee grants (#66). Empty until this local session has a // multiplayer counterpart the server has told us about. @@ -313,7 +318,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio connectionName={connectionName} loading={loading} guestCap={guestCap} - inviteLinkToken={inviteLinkToken} + inviteLinkToken={linkToken} autoCopied={autoCopied} tier={tier} inviteSession={inviteSession} @@ -394,7 +399,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio ) : ( +
+
)} @@ -673,6 +679,9 @@ function InviteLinkTab({ {t("terminal.share.shareCodeDescription")}

+
+ +
) : ( <> diff --git a/src/stores/teamSessionStore.ts b/src/stores/teamSessionStore.ts index 3914e013e..afc6fbc86 100644 --- a/src/stores/teamSessionStore.ts +++ b/src/stores/teamSessionStore.ts @@ -79,6 +79,9 @@ export interface MultiplayerSessionState { vaultOwnerTier?: string; // Raw session key bytes, retained so a live E2EE session can invite more members later (#66). sessionKeyBytes?: Uint8Array; + // Invite-link sessions only. Retained because the server returns it once, at + // creation: without it a host who reopens ShareMenu can never see the link again. + inviteToken?: string; // Runtime-only wiring between the terminal view and store; never persisted. _termWrite?: (data: Uint8Array) => void; _pendingOutput?: Uint8Array; @@ -162,6 +165,7 @@ async function attachAsHost( [localSessionId]: { multiplayerSessionId: sessionId, role: "host", myUserId, participants: [], controlHolder: "", controlRequester: null, connection: conn, vaultOwnerTier: extra.vaultOwnerTier, sessionKeyBytes: extra.sessionKeyBytes, + inviteToken: extra.inviteToken, }, }, })); From 4b73c64d42addc51b2ae1667018d270c6ad3a29c Mon Sep 17 00:00:00 2001 From: kipavy Date: Mon, 17 Aug 2026 09:31:52 +0000 Subject: [PATCH 3/3] feat(share): short spoken codes for live session invites Hosts can mint a 10-character Crockford code for an invite-link session and read it down a phone line; the server kills it after ten minutes, so what lands in chat scrollback or a clipboard manager is a dead credential rather than a session-lifetime token. Minted on demand, not alongside the link, so the window starts when the host needs it. Guests can paste a code anywhere the other invite shapes already work: detection stays synchronous on shape, and only activating a join costs a request. Joining by a bare sessionId:token now requires a real session id. TeamSessions previously accepted any colon-separated pair, unlike OmniSearch, which had always been strict; one existing test carried the looser contract and moved to a real UUID. --- src/components/hosts/TeamSessions.test.tsx | 8 +- src/components/hosts/TeamSessions.tsx | 11 +- src/components/omni/OmniSearch.tsx | 27 ++- .../terminal/SpokenCodeRow.test.tsx | 163 ++++++++++++++++++ src/components/terminal/SpokenCodeRow.tsx | 108 ++++++++++++ src/i18n/locales/en/common.json | 7 +- src/i18n/locales/en/terminal.json | 8 +- src/i18n/locales/fr/common.json | 7 +- src/i18n/locales/fr/terminal.json | 8 +- src/i18n/locales/ru/common.json | 7 +- src/i18n/locales/ru/terminal.json | 8 +- src/i18n/locales/zh/common.json | 7 +- src/i18n/locales/zh/terminal.json | 8 +- .../multiplayerService.shortCode.test.ts | 78 +++++++++ src/services/resolveJoinInput.test.ts | 62 +++++++ src/services/resolveJoinInput.ts | 33 ++++ src/services/shortCode.test.ts | 53 ++++++ src/services/shortCode.ts | 32 ++++ 18 files changed, 605 insertions(+), 30 deletions(-) create mode 100644 src/components/terminal/SpokenCodeRow.test.tsx create mode 100644 src/components/terminal/SpokenCodeRow.tsx create mode 100644 src/services/multiplayerService.shortCode.test.ts create mode 100644 src/services/resolveJoinInput.test.ts create mode 100644 src/services/resolveJoinInput.ts create mode 100644 src/services/shortCode.test.ts create mode 100644 src/services/shortCode.ts diff --git a/src/components/hosts/TeamSessions.test.tsx b/src/components/hosts/TeamSessions.test.tsx index 1b6f0ff3e..03cf9effd 100644 --- a/src/components/hosts/TeamSessions.test.tsx +++ b/src/components/hosts/TeamSessions.test.tsx @@ -79,6 +79,8 @@ const { teamState, sessionState, uiState, getMyUserId, accessibleVaultIds } = h; import { TeamSessions } from "./TeamSessions"; +const SESSION_ID = "8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90"; + const active = (o: Partial<{ id: string; connection_name: string; host_user_id: string; participant_count: number; participants: { user_id: string; handle: string }[]; vault_ids: string[]; @@ -178,10 +180,12 @@ test("valid code calls joinSession with sessionId + token", async () => { render(); fireEvent.click(screen.getByText("hosts.teamSessions.joinByCode")); const input = screen.getByPlaceholderText("hosts.teamSessions.inviteCodePlaceholder"); - fireEvent.change(input, { target: { value: "sess-9:tok-9" } }); + // A real session id: the field now rejects shapes that only look like one, so + // `host:22` and friends can no longer reach the join call. + fireEvent.change(input, { target: { value: `${SESSION_ID}:tok-9` } }); fireEvent.click(screen.getByText("hosts.teamSessions.join")); await waitFor(() => - expect(teamState.joinSession).toHaveBeenCalledWith("sess-9", expect.any(Function), "tok-9"), + expect(teamState.joinSession).toHaveBeenCalledWith(SESSION_ID, expect.any(Function), "tok-9"), ); }); diff --git a/src/components/hosts/TeamSessions.tsx b/src/components/hosts/TeamSessions.tsx index 1a09c2508..33cad7c75 100644 --- a/src/components/hosts/TeamSessions.tsx +++ b/src/components/hosts/TeamSessions.tsx @@ -10,7 +10,7 @@ import { useAccessibleVaultIds } from "@/hooks/useAccessibleVaultIds"; import { AvatarStack } from "@/components/shared/AvatarStack"; import { AvatarTile } from "@/components/shared/AvatarTile"; import { BaseCard } from "@/components/shared/BaseCard"; -import { parseInviteCode } from "@/services/inviteCode"; +import { isJoinInput, resolveJoinInput } from "@/services/resolveJoinInput"; import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin"; import { sessionDisplayName } from "@/services/teamSharing"; @@ -110,17 +110,18 @@ export function TeamSessions() { const code = inviteCode.trim(); if (!code) return; - const parsed = parseInviteCode(code); - if (!parsed) { + if (!isJoinInput(code)) { setJoinError(t("hosts.teamSessions.invalidCodeFormat")); return; } - const { sessionId, token } = parsed; setJoinLoading(true); setJoinError(null); try { - await doJoinSession(sessionId, token); + // A short code is exchanged for a session and a secret here; the other two + // shapes already carry theirs. + const { sessionId, inviteToken } = await resolveJoinInput(code); + await doJoinSession(sessionId, inviteToken); setShowJoinModal(false); } catch (err) { setJoinError(err instanceof Error ? err.message : t("hosts.teamSessions.failedToJoinSession")); diff --git a/src/components/omni/OmniSearch.tsx b/src/components/omni/OmniSearch.tsx index bb2e8ac4f..051b0f3dd 100644 --- a/src/components/omni/OmniSearch.tsx +++ b/src/components/omni/OmniSearch.tsx @@ -34,7 +34,7 @@ import { useToggleSettings } from "@/hooks/useToggleSettings"; import { parseQuickConnect, type QuickConnectIntent } from "@/services/quickConnect"; import { launchHost, launchQuickConnect, launchLocalShell } from "@/services/launch"; import { sessionDisplayName } from "@/services/teamSharing"; -import { isInviteCode, parseInviteCode } from "@/services/inviteCode"; +import { isJoinInput, resolveJoinInput } from "@/services/resolveJoinInput"; import { computeSectionBoundaries } from "./omniSections"; import { selectRecentHosts, @@ -226,7 +226,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { } if (category === "marketplace") return []; if (category === "join") { - if (isInviteCode(q)) { + if (isJoinInput(q)) { return [{ kind: "join-code", id: "", label: "", icon: "", code: query.trim() }]; } const sessionItems = teamSessions @@ -238,7 +238,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { const result: OmniItem[] = []; // A valid invite code cannot also be a host, so it outranks quick-connect. - if (isInviteCode(query)) { + if (isJoinInput(query)) { result.push({ kind: "join-code", id: "", label: "", icon: "", code: query.trim() }); } @@ -494,18 +494,15 @@ export default function OmniSearch({ onClose }: OmniSearchProps) { } }, 0); } else if (item.kind === "join-code") { - const parsed = parseInviteCode(item.code); - if (parsed) { - const { sessionId, token } = parsed; - (async () => { - await joinTeamSessionAndOpenTab({ - sessionId, - connectionName: "Shared Terminal", - inviteToken: token, - }); - setSidebarOpen(false); - })().catch(console.error); - } + (async () => { + const { sessionId, inviteToken } = await resolveJoinInput(item.code); + await joinTeamSessionAndOpenTab({ + sessionId, + connectionName: "Shared Terminal", + inviteToken, + }); + setSidebarOpen(false); + })().catch(console.error); onClose(); } else if (item.kind === "local-shell") { launchLocalShell(item.shell?.path); diff --git a/src/components/terminal/SpokenCodeRow.test.tsx b/src/components/terminal/SpokenCodeRow.test.tsx new file mode 100644 index 000000000..4fe10fb05 --- /dev/null +++ b/src/components/terminal/SpokenCodeRow.test.tsx @@ -0,0 +1,163 @@ +import { test, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + // Interpolates, because the countdown assertions read the substituted value. + useTranslation: () => ({ + t: (k: string, opts?: Record) => (opts?.time ? `${k} ${opts.time}` : k), + }), +})); +vi.mock("@iconify/react", () => ({ Icon: () => null })); + +const writeClipboard = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("@/utils/clipboard", () => ({ writeClipboard })); + +const mintSessionCode = vi.hoisted(() => vi.fn()); +vi.mock("@/services/multiplayerService", () => ({ mintSessionCode })); + +import { SpokenCodeRow } from "./SpokenCodeRow"; + +function mintsIn(seconds: number, code = "K7M2-P9QX-3B") { + mintSessionCode.mockResolvedValue({ + code, + expiresAt: new Date(Date.now() + seconds * 1000).toISOString(), + }); +} + +const T0 = Date.parse("2026-08-17T09:00:00.000Z"); + +/** + * Fakes the clock the component reads along with the timers it schedules, and + * advances only when told to. `shouldAdvanceTime` must stay off: combined with a + * fixed expiry it let real wall time leak into the arithmetic, so the first tick + * read 0:00 once real UTC passed the fixture's expiry. + */ +function withControlledClock(startMs = T0) { + vi.useFakeTimers({ + toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"], + }); + vi.setSystemTime(startMs); + return { + async advance(ms: number) { + await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + }, + }; +} + +/** Flushes the mint promise without waitFor, which would poll on faked timers. */ +async function mintWithFakeTimers() { + fireEvent.click(screen.getByRole("button")); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); +} + +function expiringAt(offsetMs: number, code = "K7M2-P9QX-3B") { + mintSessionCode.mockResolvedValue({ + code, + expiresAt: new Date(T0 + offsetMs).toISOString(), + }); +} + +async function mint() { + fireEvent.click(screen.getByRole("button")); + await waitFor(() => expect(screen.getByRole("textbox")).toBeTruthy()); +} + +beforeEach(() => { + writeClipboard.mockClear(); + mintSessionCode.mockReset(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +test("mints only when asked, so opening the tab does not spend a code", () => { + mintsIn(600); + render(); + + expect(mintSessionCode).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox")).toBeNull(); +}); + +test("shows the minted code and copies exactly what it displays", async () => { + mintsIn(600); + render(); + await mint(); + + const field = screen.getByRole("textbox") as HTMLInputElement; + expect(field.value).toBe("K7M2-P9QX-3B"); + expect(mintSessionCode).toHaveBeenCalledWith("sess-1"); + + fireEvent.click(screen.getByText("common.action.copy")); + await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith("K7M2-P9QX-3B")); +}); + +test("groups a code the server sent unformatted", async () => { + mintsIn(600, "K7M2P9QX3B"); + render(); + await mint(); + + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("K7M2-P9QX-3B"); +}); + +test("counts down toward expiry", async () => { + const clock = withControlledClock(); + expiringAt(600_000); + render(); + await mintWithFakeTimers(); + + expect(screen.getByText(/expiresIn 10:00/)).toBeTruthy(); + await clock.advance(62_000); + expect(screen.getByText(/expiresIn 8:58/)).toBeTruthy(); +}); + +// An expired code is worse than no code: it looks usable and fails at the guest's end. +test("drops an expired code and offers a fresh one", async () => { + const clock = withControlledClock(); + expiringAt(5_000); + render(); + await mintWithFakeTimers(); + + await clock.advance(6_000); + + expect(screen.queryByRole("textbox")).toBeNull(); + expect(screen.getByText("terminal.share.codeExpired")).toBeTruthy(); +}); + +test("regenerating replaces the code, matching the server revoking the old one", async () => { + mintsIn(600, "K7M2-P9QX-3B"); + render(); + await mint(); + + mintsIn(600, "AAAA-BBBB-CC"); + fireEvent.click(screen.getByText("terminal.share.newCode")); + + await waitFor(() => + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("AAAA-BBBB-CC"), + ); +}); + +test("surfaces a mint failure instead of showing a stale code", async () => { + mintSessionCode.mockRejectedValue(new Error("boom")); + render(); + + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => expect(screen.getByText("boom")).toBeTruthy()); + expect(screen.queryByRole("textbox")).toBeNull(); +}); + +// The interval must die with the component; a leaked one ticks against an unmounted +// tree for the rest of the session (the leak PR #128 had to fix). +test("clears its countdown on unmount", async () => { + withControlledClock(); + expiringAt(600_000); + const view = render(); + await mintWithFakeTimers(); + + const clearSpy = vi.spyOn(globalThis, "clearInterval"); + view.unmount(); + + expect(clearSpy).toHaveBeenCalled(); +}); diff --git a/src/components/terminal/SpokenCodeRow.tsx b/src/components/terminal/SpokenCodeRow.tsx new file mode 100644 index 000000000..a9dc7cca3 --- /dev/null +++ b/src/components/terminal/SpokenCodeRow.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import { mintSessionCode } from "@/services/multiplayerService"; +import { formatShortCode } from "@/services/shortCode"; +import { InviteCodeField } from "./InviteCodeField"; + +function mmss(secondsLeft: number): string { + const m = Math.floor(secondsLeft / 60); + const s = secondsLeft % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +/** + * The read-aloud half of an invite: a short code the server kills after ten + * minutes. Minted on demand rather than alongside the link, so its window starts + * when the host actually needs to say it. + */ +export function SpokenCodeRow({ sessionId }: { sessionId: string }) { + const { t } = useTranslation(); + const [code, setCode] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); + const [remaining, setRemaining] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expired, setExpired] = useState(false); + const intervalRef = useRef | null>(null); + + useEffect(() => { + if (expiresAt === null) return; + const tick = () => { + const left = Math.max(0, Math.round((expiresAt - Date.now()) / 1000)); + setRemaining(left); + if (left === 0) { + setCode(null); + setExpiresAt(null); + setExpired(true); + } + }; + tick(); + intervalRef.current = setInterval(tick, 1000); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [expiresAt]); + + const handleMint = async () => { + setLoading(true); + setError(null); + setExpired(false); + try { + const minted = await mintSessionCode(sessionId); + setCode(formatShortCode(minted.code)); + setExpiresAt(new Date(minted.expiresAt).getTime()); + } catch (err) { + setError(err instanceof Error ? err.message : t("terminal.share.failedToMintCode")); + } finally { + setLoading(false); + } + }; + + return ( +
+ {code ? ( + <> + +
+ + {t("terminal.share.readAloudHint")} + {" · "} + + {t("terminal.share.expiresIn", { time: mmss(remaining) })} + + + +
+ + ) : ( + <> + + {error && ( +

{error}

+ )} + + )} +
+ ); +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 441bf8c64..d9e07ee0d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -163,7 +163,12 @@ "termiusDumpInvalidJson": "Termius dump must be valid JSON", "legacyTermiusUnsupported": "Legacy Termius dump format is no longer supported; use Auto Extract.", "termiusExtractionFormat": "Termius extraction must return { version: 2, records: [...] }", - "failedToFetchSource": "Failed to fetch source: HTTP {{status}}" + "failedToFetchSource": "Failed to fetch source: HTTP {{status}}", + "failedToMintInviteCode": "Failed to create invite code: {{status}}", + "failedToRedeemInviteCode": "Failed to use invite code: {{status}}", + "inviteCodeMalformed": "That code is not in the right format", + "inviteCodeNotFound": "That code has expired or is incorrect", + "inviteCodeTooManyAttempts": "Too many attempts — wait a moment and try again" }, "clipboard": { "cut_one": "{{count}} item cut", diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json index 118240346..11188d320 100644 --- a/src/i18n/locales/en/terminal.json +++ b/src/i18n/locales/en/terminal.json @@ -164,7 +164,13 @@ "deepLinkJoinBody": "Someone shared a live terminal session with you. Joining connects you to it now.", "deepLinkJoinUnknownHost": "Voltius can't identify who shared it until you join.", "deepLinkJoinAction": "Join session", - "deepLinkJoinFailed": "Couldn't join that session — the link may have expired." + "deepLinkJoinFailed": "Couldn't join that session — the link may have expired.", + "getSpokenCode": "Get a code to read aloud", + "codeExpired": "Code expired — get a new one", + "newCode": "New code", + "readAloudHint": "Say it out loud", + "expiresIn": "expires in {{time}}", + "failedToMintCode": "Failed to create code" }, "snippetVariableModal": { "on": "On", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 5943f51c7..282da9c05 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -163,7 +163,12 @@ "termiusDumpInvalidJson": "Le fichier Termius doit être un JSON valide", "legacyTermiusUnsupported": "L'ancien format d'export Termius n'est plus pris en charge ; utilisez l'extraction automatique.", "termiusExtractionFormat": "L'extraction Termius doit renvoyer { version: 2, records: [...] }", - "failedToFetchSource": "Échec de la récupération de la source : HTTP {{status}}" + "failedToFetchSource": "Échec de la récupération de la source : HTTP {{status}}", + "failedToMintInviteCode": "Échec de la création du code d'invitation : {{status}}", + "failedToRedeemInviteCode": "Échec de l'utilisation du code d'invitation : {{status}}", + "inviteCodeMalformed": "Ce code n'a pas le bon format", + "inviteCodeNotFound": "Ce code a expiré ou est incorrect", + "inviteCodeTooManyAttempts": "Trop de tentatives — patientez un instant et réessayez" }, "clipboard": { "cut_one": "{{count}} élément coupé", diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json index bb71b4140..437f5445a 100644 --- a/src/i18n/locales/fr/terminal.json +++ b/src/i18n/locales/fr/terminal.json @@ -164,7 +164,13 @@ "deepLinkJoinBody": "Quelqu'un a partagé une session de terminal en direct avec vous. En rejoignant, vous vous y connectez immédiatement.", "deepLinkJoinUnknownHost": "Voltius ne peut pas identifier l'expéditeur avant que vous rejoigniez la session.", "deepLinkJoinAction": "Rejoindre la session", - "deepLinkJoinFailed": "Impossible de rejoindre cette session — le lien a peut-être expiré." + "deepLinkJoinFailed": "Impossible de rejoindre cette session — le lien a peut-être expiré.", + "getSpokenCode": "Obtenir un code à dicter", + "codeExpired": "Code expiré — en obtenir un nouveau", + "newCode": "Nouveau code", + "readAloudHint": "Dictez-le à voix haute", + "expiresIn": "expire dans {{time}}", + "failedToMintCode": "Échec de la création du code" }, "snippetVariableModal": { "on": "Activé", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index d45254e42..eda02a7cc 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -163,7 +163,12 @@ "termiusDumpInvalidJson": "Дамп Termius должен быть корректным JSON", "legacyTermiusUnsupported": "Старый формат дампа Termius больше не поддерживается; используйте автоэкспорт.", "termiusExtractionFormat": "Экспорт Termius должен возвращать { version: 2, records: [...] }", - "failedToFetchSource": "Не удалось получить источник: HTTP {{status}}" + "failedToFetchSource": "Не удалось получить источник: HTTP {{status}}", + "failedToMintInviteCode": "Не удалось создать код приглашения: {{status}}", + "failedToRedeemInviteCode": "Не удалось использовать код приглашения: {{status}}", + "inviteCodeMalformed": "Код указан в неверном формате", + "inviteCodeNotFound": "Код истёк или указан неверно", + "inviteCodeTooManyAttempts": "Слишком много попыток — подождите и попробуйте снова" }, "clipboard": { "cut_one": "Вырезан {{count}} элемент", diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json index 88b49b229..d41d764ba 100644 --- a/src/i18n/locales/ru/terminal.json +++ b/src/i18n/locales/ru/terminal.json @@ -174,7 +174,13 @@ "deepLinkJoinBody": "Кто-то поделился с вами активной сессией терминала. Присоединение подключит вас к ней сейчас.", "deepLinkJoinUnknownHost": "Voltius не сможет определить отправителя, пока вы не присоединитесь.", "deepLinkJoinAction": "Присоединиться", - "deepLinkJoinFailed": "Не удалось присоединиться к сессии — возможно, срок действия ссылки истёк." + "deepLinkJoinFailed": "Не удалось присоединиться к сессии — возможно, срок действия ссылки истёк.", + "getSpokenCode": "Получить код для передачи голосом", + "codeExpired": "Код истёк — получите новый", + "newCode": "Новый код", + "readAloudHint": "Продиктуйте его", + "expiresIn": "истекает через {{time}}", + "failedToMintCode": "Не удалось создать код" }, "snippetVariableModal": { "on": "Вкл", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 363099db5..29a67f06f 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -163,7 +163,12 @@ "termiusDumpInvalidJson": "Termius 导出必须是有效的 JSON", "legacyTermiusUnsupported": "不再支持旧版 Termius 导出格式;请使用自动提取。", "termiusExtractionFormat": "Termius 提取必须返回 { version: 2, records: [...] }", - "failedToFetchSource": "获取源失败:HTTP {{status}}" + "failedToFetchSource": "获取源失败:HTTP {{status}}", + "failedToMintInviteCode": "创建邀请码失败:{{status}}", + "failedToRedeemInviteCode": "使用邀请码失败:{{status}}", + "inviteCodeMalformed": "该邀请码格式不正确", + "inviteCodeNotFound": "该邀请码已过期或不正确", + "inviteCodeTooManyAttempts": "尝试次数过多 — 请稍后重试" }, "clipboard": { "cut_one": "已剪切 {{count}} 个项目", diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json index ef93474d4..79c857cfe 100644 --- a/src/i18n/locales/zh/terminal.json +++ b/src/i18n/locales/zh/terminal.json @@ -164,7 +164,13 @@ "deepLinkJoinBody": "有人与你共享了一个实时终端会话。加入后将立即连接。", "deepLinkJoinUnknownHost": "在你加入之前,Voltius 无法识别共享者。", "deepLinkJoinAction": "加入会话", - "deepLinkJoinFailed": "无法加入该会话——链接可能已失效。" + "deepLinkJoinFailed": "无法加入该会话——链接可能已失效。", + "getSpokenCode": "获取口述验证码", + "codeExpired": "验证码已过期 — 请获取新的", + "newCode": "新验证码", + "readAloudHint": "可直接读出", + "expiresIn": "{{time}} 后过期", + "failedToMintCode": "创建验证码失败" }, "snippetVariableModal": { "on": "开", diff --git a/src/services/multiplayerService.shortCode.test.ts b/src/services/multiplayerService.shortCode.test.ts new file mode 100644 index 000000000..16ac51bbf --- /dev/null +++ b/src/services/multiplayerService.shortCode.test.ts @@ -0,0 +1,78 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ + invoke: vi.fn(), + appFetch: vi.fn(), + getServerUrlValue: vi.fn(), + getJwtToken: vi.fn(), + getVaultKey: vi.fn(), + getUserPublicKey: vi.fn(), + freshPublicKeys: vi.fn(), +})); +vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); +vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); +vi.mock("@/services/vault", () => ({ getVaultKey: h.getVaultKey })); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@/services/teamService", () => ({ + getServerUrlValue: h.getServerUrlValue, + getJwtToken: h.getJwtToken, + getUserPublicKey: h.getUserPublicKey, +})); +vi.mock("@/services/teamSharing", () => ({ freshPublicKeys: h.freshPublicKeys })); + +import { mintSessionCode, redeemSessionCode } from "./multiplayerService"; + +function mockAppFetch(body: unknown, status = 200) { + h.appFetch.mockResolvedValue({ ok: status < 300, status, json: async () => body }); +} + +beforeEach(() => { + Object.values(h).forEach((m) => m.mockReset()); + h.getServerUrlValue.mockResolvedValue("https://srv.test"); + h.getJwtToken.mockResolvedValue("jwt"); +}); + +test("mintSessionCode posts to the session's code route and maps the response", async () => { + mockAppFetch({ code: "K7M2-P9QX-3B", expires_at: "2026-08-17T09:00:00Z" }, 201); + + const minted = await mintSessionCode("sess-1"); + + expect(h.appFetch.mock.calls[0][0]).toBe("https://srv.test/v1/terminal-sessions/sess-1/code"); + expect(h.appFetch.mock.calls[0][1]).toMatchObject({ method: "POST" }); + expect(minted).toEqual({ code: "K7M2-P9QX-3B", expiresAt: "2026-08-17T09:00:00Z" }); +}); + +test("redeemSessionCode sends the normalized code, not what the guest typed", async () => { + mockAppFetch({ session_id: "sess-1", invite_token: "fake-guest-secret" }); + + await redeemSessionCode(" k7m2-p9qx-3b\n"); + + expect(h.appFetch.mock.calls[0][0]).toBe("https://srv.test/v1/terminal-sessions/redeem"); + expect(JSON.parse(h.appFetch.mock.calls[0][1].body)).toEqual({ code: "K7M2P9QX3B" }); +}); + +test("redeemSessionCode returns the session and the guest secret to join with", async () => { + mockAppFetch({ session_id: "sess-1", invite_token: "fake-guest-secret" }); + + expect(await redeemSessionCode("K7M2-P9QX-3B")).toEqual({ + sessionId: "sess-1", + inviteToken: "fake-guest-secret", + }); +}); + +// The server answers 404 for unknown, malformed, expired and revoked alike, so the +// client must not invent a distinction it cannot know. +test("redeemSessionCode reports an unknown or expired code as one outcome", async () => { + mockAppFetch({}, 404); + await expect(redeemSessionCode("K7M2-P9QX-3B")).rejects.toThrow("common.error.inviteCodeNotFound"); +}); + +test("redeemSessionCode reports rate limiting separately", async () => { + mockAppFetch({}, 429); + await expect(redeemSessionCode("K7M2-P9QX-3B")).rejects.toThrow("common.error.inviteCodeTooManyAttempts"); +}); + +test("redeemSessionCode refuses a code the server would reject anyway", async () => { + await expect(redeemSessionCode("nonsense")).rejects.toThrow("common.error.inviteCodeMalformed"); + expect(h.appFetch).not.toHaveBeenCalled(); +}); diff --git a/src/services/resolveJoinInput.test.ts b/src/services/resolveJoinInput.test.ts new file mode 100644 index 000000000..487d2d78f --- /dev/null +++ b/src/services/resolveJoinInput.test.ts @@ -0,0 +1,62 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const redeemSessionCode = vi.hoisted(() => vi.fn()); +vi.mock("@/services/multiplayerService", () => ({ redeemSessionCode })); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); + +import { resolveJoinInput } from "./resolveJoinInput"; + +const SESSION = "8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90"; + +// Braces matter: a hook that returns the mock hands vitest the mock as its +// teardown callback, which then calls it after the test. +beforeEach(() => { + redeemSessionCode.mockReset(); +}); + +test("a bare sessionId:token needs no server round-trip", async () => { + expect(await resolveJoinInput(`${SESSION}:faketoken`)).toEqual({ + sessionId: SESSION, + inviteToken: "faketoken", + }); + expect(redeemSessionCode).not.toHaveBeenCalled(); +}); + +test("a deep link needs no server round-trip", async () => { + expect(await resolveJoinInput(`voltius://join?s=${SESSION}&t=faketoken`)).toEqual({ + sessionId: SESSION, + inviteToken: "faketoken", + }); + expect(redeemSessionCode).not.toHaveBeenCalled(); +}); + +test("a short code is redeemed for a session and a guest secret", async () => { + redeemSessionCode.mockResolvedValue({ sessionId: SESSION, inviteToken: "fake-guest-secret" }); + + expect(await resolveJoinInput("K7M2-P9QX-3B")).toEqual({ + sessionId: SESSION, + inviteToken: "fake-guest-secret", + }); + expect(redeemSessionCode).toHaveBeenCalledWith("K7M2-P9QX-3B"); +}); + +test("redemption failures reach the caller unchanged", async () => { + redeemSessionCode.mockImplementation(async () => { + throw new Error("common.error.inviteCodeNotFound"); + }); + + const caught = await resolveJoinInput("K7M2-P9QX-3B").catch((e: unknown) => e); + expect((caught as Error).message).toBe("common.error.inviteCodeNotFound"); +}); + +test("input that is neither shape is rejected without a request", async () => { + await expect(resolveJoinInput("nonsense")).rejects.toThrow("common.error.inviteCodeMalformed"); + expect(redeemSessionCode).not.toHaveBeenCalled(); +}); + +// Quick-connect targets share the colon shape, and mistaking one for an invite +// would fire a redeem for every `host:22` a user types. +test("quick-connect shapes are not treated as invites", async () => { + await expect(resolveJoinInput("host:22")).rejects.toThrow("common.error.inviteCodeMalformed"); + expect(redeemSessionCode).not.toHaveBeenCalled(); +}); diff --git a/src/services/resolveJoinInput.ts b/src/services/resolveJoinInput.ts new file mode 100644 index 000000000..4f5cb1325 --- /dev/null +++ b/src/services/resolveJoinInput.ts @@ -0,0 +1,33 @@ +import i18n from "@/i18n"; +import { isInviteCode, parseInviteCode } from "@/services/inviteCode"; +import { redeemSessionCode } from "@/services/multiplayerService"; +import { isShortCode } from "@/services/shortCode"; + +/** + * Turns anything a guest can paste or type — a bare `sessionId:token`, a + * `voltius://join` link, or a short spoken code — into the pair every join needs. + * + * Only the short code costs a request: the other two already carry their token, so + * detection stays synchronous and callers can decide what to offer before asking + * the server anything. + */ +export async function resolveJoinInput( + input: string, +): Promise<{ sessionId: string; inviteToken: string }> { + const trimmed = input.trim(); + + if (isInviteCode(trimmed)) { + const parsed = parseInviteCode(trimmed); + if (parsed) return { sessionId: parsed.sessionId, inviteToken: parsed.token }; + } + + if (isShortCode(trimmed)) return redeemSessionCode(trimmed); + + throw new Error(i18n.t("common.error.inviteCodeMalformed")); +} + +/** True for anything `resolveJoinInput` can act on, without contacting the server. */ +export function isJoinInput(input: string): boolean { + const trimmed = input.trim(); + return isInviteCode(trimmed) || isShortCode(trimmed); +} diff --git a/src/services/shortCode.test.ts b/src/services/shortCode.test.ts new file mode 100644 index 000000000..e6a23f830 --- /dev/null +++ b/src/services/shortCode.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from "vitest"; +import { formatShortCode, isShortCode, normalizeShortCode } from "./shortCode"; + +test("normalizeShortCode folds spelling variants to one value", () => { + const canonical = normalizeShortCode("K7M2-P9QX-3B"); + expect(canonical).toBe("K7M2P9QX3B"); + for (const variant of ["k7m2p9qx3b", "K7M2 P9QX 3B", " k7m2-p9qx-3b\n", "K7M2--P9QX--3B"]) { + expect(normalizeShortCode(variant)).toBe(canonical); + } +}); + +// Crockford treats these as digits, so a guest who hears "oh" and types O still gets in. +test("normalizeShortCode maps the confusable letters onto digits", () => { + expect(normalizeShortCode("O1IL-2345-67")).toBe("0111234567"); +}); + +test("normalizeShortCode rejects U, which the alphabet excludes", () => { + expect(normalizeShortCode("K7M2-P9QU-3B")).toBeNull(); +}); + +test("normalizeShortCode rejects wrong lengths and foreign symbols", () => { + expect(normalizeShortCode("K7M2-P9QX")).toBeNull(); + expect(normalizeShortCode("K7M2-P9QX-3B4")).toBeNull(); + expect(normalizeShortCode("K7M2-P9QX-3$")).toBeNull(); + expect(normalizeShortCode("")).toBeNull(); +}); + +test("isShortCode accepts every spelling the server would accept", () => { + expect(isShortCode("K7M2-P9QX-3B")).toBe(true); + expect(isShortCode("k7m2p9qx3b")).toBe(true); + expect(isShortCode("nonsense")).toBe(false); +}); + +// A session id and a bare `sessionId:token` must never be mistaken for a code, +// or the join paths would redeem instead of using the token they already have. +test("isShortCode rejects the other invite shapes", () => { + expect(isShortCode("8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90")).toBe(false); + expect(isShortCode("8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90:faketoken")).toBe(false); + expect(isShortCode("voltius://join?s=8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90&t=faketoken")).toBe(false); + expect(isShortCode("host:22")).toBe(false); +}); + +test("formatShortCode groups a canonical code 4-4-2", () => { + expect(formatShortCode("K7M2P9QX3B")).toBe("K7M2-P9QX-3B"); +}); + +test("formatShortCode normalizes before grouping", () => { + expect(formatShortCode("k7m2-p9qx-3b")).toBe("K7M2-P9QX-3B"); +}); + +test("formatShortCode returns the input unchanged when it is not a code", () => { + expect(formatShortCode("nonsense")).toBe("nonsense"); +}); diff --git a/src/services/shortCode.ts b/src/services/shortCode.ts new file mode 100644 index 000000000..39ed8bff8 --- /dev/null +++ b/src/services/shortCode.ts @@ -0,0 +1,32 @@ +/** + * Server-resolved short invite codes. This mirrors the server's normalizer + * (`src/session_grants.rs`) exactly — a code the server accepts must normalize + * to the same 10 symbols here, or a guest who typed it correctly is refused. + */ + +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const CODE_LENGTH = 10; + +export function normalizeShortCode(input: string): string | null { + let normalized = ""; + for (const raw of input) { + if (/\s/.test(raw) || raw === "-") continue; + const upper = raw.toUpperCase(); + normalized += upper === "I" || upper === "L" ? "1" : upper === "O" ? "0" : upper; + } + if (normalized.length !== CODE_LENGTH) return null; + for (const symbol of normalized) { + if (!ALPHABET.includes(symbol)) return null; + } + return normalized; +} + +export function isShortCode(value: string): boolean { + return normalizeShortCode(value) !== null; +} + +export function formatShortCode(code: string): string { + const normalized = normalizeShortCode(code); + if (!normalized) return code; + return `${normalized.slice(0, 4)}-${normalized.slice(4, 8)}-${normalized.slice(8)}`; +}