From 64dbdb955134f447873ada83564acf08aa7aa3f2 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:24:06 -0400 Subject: [PATCH 1/6] Make phone pairing survive real networks A phone pairs over plain http on the LAN, which is an insecure browser context, and the app assumed secure-context APIs throughout. Every direct crypto.randomUUID call threw there (the existing fallback was a decoy that re-threw the same error), leaving a freshly paired phone in an empty shell. UUIDs now come from one shared helper that falls back to RFC 4122 v4 over getRandomValues; the eight ad-hoc clipboard call sites share one helper with an execCommand fallback; getUserMedia's error names the insecure origin when that is the cause. The pairing surfaces got the same treatment end to end: serve mode prints a pairing URL and QR on a reachable interface instead of localhost (display only; binding is unchanged); the reveal dialog shows a labelled setup link, labelled pairing code, QR, and working copy buttons on LAN origins; Add computer no longer hangs on Adding forever when its unbounded getConfig await outlives a socket swap (bounded reconnect-retrying read, plus timeboxed remote auth fetches); and the desktop Devices list says it is loading instead of rendering an empty region that reads as no devices while revoke sits disabled. --- apps/server/src/startupAccess.test.ts | 37 ++++- apps/server/src/startupAccess.ts | 9 +- apps/web/src/components/DiffPanel.tsx | 11 +- .../src/components/browser/BrowserPanel.tsx | 3 +- .../src/components/chat/copyTextWithToast.ts | 15 +- .../file-viewer/FileViewerOverlay.tsx | 16 +- .../settings/ConnectionsSettings.tsx | 156 ++++++++++-------- .../settings/DiagnosticsSettings.tsx | 4 +- .../settings/ExtensionsSettings.tsx | 14 +- .../settings/SettingsPanels.browser.tsx | 90 +++++++++- .../source-control/SourceControlPanel.tsx | 14 +- apps/web/src/environments/remote/api.test.ts | 6 + apps/web/src/environments/remote/api.ts | 10 ++ apps/web/src/hooks/useCopyToClipboard.ts | 9 +- apps/web/src/lib/clipboard.browser.tsx | 106 ++++++++++++ apps/web/src/lib/clipboard.ts | 111 +++++++++++++ apps/web/src/lib/utils.ts | 15 +- apps/web/src/realtimeAudio.ts | 8 +- apps/web/src/rpc/wsRpcClient.test.ts | 28 ++++ apps/web/src/rpc/wsRpcClient.ts | 13 +- packages/shared/src/uuid.test.ts | 47 ++++++ packages/shared/src/uuid.ts | 46 +++++- 22 files changed, 612 insertions(+), 156 deletions(-) create mode 100644 apps/web/src/lib/clipboard.browser.tsx create mode 100644 apps/web/src/lib/clipboard.ts create mode 100644 packages/shared/src/uuid.test.ts diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f..71c2c368d 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -9,9 +9,40 @@ import { resolveListeningPort, } from "./startupAccess.ts"; -it("prefers localhost when no explicit host is configured", () => { - expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); - expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); +const LAN_INTERFACES = { + en0: [ + { + address: "192.168.1.42", + netmask: "255.255.255.0", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.168.1.42/24", + }, + ], + lo0: [ + { + address: "127.0.0.1", + netmask: "255.0.0.0", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: true, + cidr: "127.0.0.1/8", + }, + ], +}; + +// An unset host binds every interface, so the advertised URL has to be one +// another device can open. A loopback URL here is a dead pairing link. +it("resolves an unset host to a reachable interface", () => { + expect(resolveHeadlessConnectionHost(undefined, LAN_INTERFACES)).toBe("192.168.1.42"); + expect(resolveHeadlessConnectionString(undefined, 3773, LAN_INTERFACES)).toBe( + "http://192.168.1.42:3773", + ); +}); + +it("falls back to localhost when no external interface exists", () => { + expect(resolveHeadlessConnectionHost(undefined, { lo0: LAN_INTERFACES.lo0 })).toBe("localhost"); }); it("keeps explicit bind hosts in the connection string", () => { diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 43df7e6f9..a998701e1 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -46,11 +46,10 @@ export const resolveHeadlessConnectionHost = ( host: string | undefined, interfaces: NetworkInterfacesMap = networkInterfaces(), ): string => { - if (!host) { - return "localhost"; - } - - if (!isWildcardHost(host)) { + // An unset host binds every interface, exactly like an explicit wildcard. + // Reporting `localhost` for it printed a pairing URL only this machine could + // open, which is useless for the one thing `serve` exists to do: pair a phone. + if (host !== undefined && !isWildcardHost(host)) { return normalizeHost(host); } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 2bb0c39f0..00379fd02 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,6 +30,7 @@ import { gitWorkingTreeDiffQueryOptions, invalidateGitWorkingTreeDiffQueries, } from "~/lib/gitReactQuery"; +import { copyTextToClipboard } from "~/lib/clipboard"; import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; @@ -945,15 +946,7 @@ export default function DiffPanel({ [activeCwd], ); const copyDiffFilePath = useCallback((filePath: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add({ - type: "error", - title: "Failed to copy path", - description: "Clipboard API unavailable.", - }); - return; - } - void navigator.clipboard.writeText(filePath).then( + void copyTextToClipboard(filePath).then( () => { toastManager.add({ type: "success", title: "Path copied", description: filePath }); }, diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index a71758baf..d666360a4 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -52,6 +52,7 @@ import { } from "../../browserPanelStore"; import { isElectron } from "../../env"; import { useTheme } from "../../hooks/useTheme"; +import { copyTextToClipboard } from "../../lib/clipboard"; import { cn } from "../../lib/utils"; import { Menu, @@ -854,7 +855,7 @@ export function BrowserPanel({ disabled={activeUrl === null} onClick={() => { if (activeUrl !== null) { - void navigator.clipboard.writeText(activeUrl).catch(() => {}); + void copyTextToClipboard(activeUrl).catch(() => {}); } }} > diff --git a/apps/web/src/components/chat/copyTextWithToast.ts b/apps/web/src/components/chat/copyTextWithToast.ts index 37497dd40..90f73cce6 100644 --- a/apps/web/src/components/chat/copyTextWithToast.ts +++ b/apps/web/src/components/chat/copyTextWithToast.ts @@ -1,3 +1,5 @@ +import { copyTextToClipboard } from "~/lib/clipboard"; + import { stackedThreadToast, toastManager } from "../ui/toast"; /** @@ -8,18 +10,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; * that says nothing reads as the menu item being broken. */ export function copyTextWithToast(value: string, title: string): void { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } - - void navigator.clipboard.writeText(value).then( + void copyTextToClipboard(value).then( () => { toastManager.add({ type: "success", diff --git a/apps/web/src/components/file-viewer/FileViewerOverlay.tsx b/apps/web/src/components/file-viewer/FileViewerOverlay.tsx index 754bcbb4b..0338541b9 100644 --- a/apps/web/src/components/file-viewer/FileViewerOverlay.tsx +++ b/apps/web/src/components/file-viewer/FileViewerOverlay.tsx @@ -22,7 +22,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EditorId } from "@threadlines/contracts"; import { readLocalApi } from "../../localApi"; -import { cn, isMacPlatform } from "~/lib/utils"; +import { copyTextToClipboard } from "~/lib/clipboard"; +import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { usePreferredEditor } from "../../editorPreferences"; import { useServerAvailableEditors } from "../../rpc/serverState"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -143,9 +144,14 @@ function workspaceAbsolutePath(cwd: string, path: string): string { } function copyRelativePathToClipboard(path: string): void { - void navigator.clipboard?.writeText(path).then(() => { - toastManager.add({ type: "success", title: "Path copied", description: path }); - }); + void copyTextToClipboard(path).then( + () => { + toastManager.add({ type: "success", title: "Path copied", description: path }); + }, + () => { + toastManager.add({ type: "error", title: "Failed to copy path" }); + }, + ); } function openPathInEditor(cwd: string, path: string, editor: EditorId): void { @@ -590,7 +596,7 @@ function AddSelectionToChatFooter({ // selection the quoted lines are serialized at send time; without one the // whole file is attached as an `@path` mention the agent reads itself. addFileSelectionContext(threadRef, { - id: crypto.randomUUID(), + id: randomUUID(), threadId: threadRef.threadId, createdAt: new Date().toISOString(), relativePath: activePath, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 33d4936ee..4f7b8cd02 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -28,6 +28,7 @@ import * as DateTime from "effect/DateTime"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick"; +import { isClipboardCopySupported } from "../../lib/clipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; @@ -698,13 +699,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : isLoopbackHostname(window.location.hostname) ? null : currentOriginPairingUrl); - const revealValue = shareablePairingUrl ?? pairingLink.credential; const isShareableHostedAppPairingUrl = shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); - const canCopyToClipboard = - typeof window !== "undefined" && - window.isSecureContext && - navigator.clipboard?.writeText != null; + // Pairing links are minted on the LAN origin (plain http), where the async + // Clipboard API is missing. `isClipboardCopySupported` also accepts the + // execCommand fallback, so the copy buttons survive there. + const canCopyToClipboard = isClipboardCopySupported(); const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ onCopy: (kind) => { @@ -847,6 +847,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ ) : null} + + setIsRevealDialogOpen(true)}> + Show link, code, and QR + ); @@ -899,49 +903,43 @@ const PairingLinkListRow = memo(function PairingLinkListRow({

{shareablePairingUrl === null ? (

- Copy the code and use it with this computer's connection address. + Open Threadlines at this computer's network address to get a link a phone can + scan. From localhost there is no address a phone can reach, so pair with the code + instead.

) : null}
- {canCopyToClipboard ? ( - <> - {shareablePairingUrl ? ( - - - - - - } - > - - - - {renderGroupedCopyMenuItems()} - - - - ) : ( - - )} - + {canCopyToClipboard && shareablePairingUrl ? ( + + + + + + } + > + + + + {renderGroupedCopyMenuItems()} + + + ) : ( }> {shareablePairingUrl ? "Show link" : "Show code"} @@ -949,30 +947,43 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ )} - - {shareablePairingUrl - ? isShareableHostedAppPairingUrl - ? "Device link" - : "Device link" - : "Pairing code"} - + {shareablePairingUrl ? "Device link" : "Pairing code"} {shareablePairingUrl ? isShareableHostedAppPairingUrl - ? "Clipboard copy is unavailable here. Open or manually copy this link on your phone or tablet." - : "Clipboard copy is unavailable here. Open or manually copy this link on the device you want to connect." - : "Clipboard copy is unavailable here. Manually copy this code into another device."} + ? "Scan the code or open this link in the browser on your phone or tablet. You can also type the pairing code by hand." + : "Scan the code or open this link on the device you want to connect. You can also type the pairing code by hand." + : "Enter this code on the device you want to connect."} -