History

feat(web): make pairing QR codes actually scannable, with endpoint choice (#5360)

Theo Browne committed 3 months ago

Commit fff6a5b1 parent da6e1a93 files · +315 −189Browse files

Unified · Split

3 files
  1. apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +92 −2
  2. apps/web/src/components/settings/ConnectionsSettings.logic.ts +44 −1
  3. apps/web/src/components/settings/ConnectionsSettings.tsx +179 −186
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+92−2
@@ -1,6 +1,10 @@
1−import type { DesktopWslState } from "@t3tools/contracts";
1+import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts";
22 import { describe, expect, it, vi } from "vite-plus/test";
3−import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
3+import {
4+ applyWslEnableSelection,
5+ isQrShareableEndpoint,
6+ selectQrEndpointOption,
7+} from "./ConnectionsSettings.logic";
48
59 const baseWslState: DesktopWslState = {
610 enabled: false,
@@ -73,3 +77,89 @@ describe("applyWslEnableSelection", () => { · 66 unmodified lines
7377 expect(state).toMatchObject({ enabled: true, wslOnly: true });
7478 });
7579 });
80+
81+function makeEndpoint(overrides: Partial<AdvertisedEndpoint>): AdvertisedEndpoint {
82+ return {
83+ id: "desktop-lan:http://192.168.1.42:4780",
84+ label: "Local network",
85+ provider: { id: "desktop-core", label: "Desktop", kind: "core", isAddon: false },
86+ httpBaseUrl: "http://192.168.1.42:4780",
87+ wsBaseUrl: "ws://192.168.1.42:4780",
88+ reachability: "lan",
89+ compatibility: { hostedHttpsApp: "unknown", desktopApp: "compatible" },
90+ source: "desktop-core",
91+ status: "available",
92+ ...overrides,
93+ };
94+}
95+
96+describe("isQrShareableEndpoint", () => {
97+ it("excludes loopback endpoints so a scanned phone never dials itself", () => {
98+ expect(
99+ isQrShareableEndpoint(
100+ makeEndpoint({
101+ id: "desktop-loopback:4780",
102+ reachability: "loopback",
103+ httpBaseUrl: "http://127.0.0.1:4780",
104+ }),
105+ ),
106+ ).toBe(false);
107+ });
108+
109+ it("excludes unavailable endpoints and keeps reachable ones", () => {
110+ expect(isQrShareableEndpoint(makeEndpoint({ status: "unavailable" }))).toBe(false);
111+ expect(isQrShareableEndpoint(makeEndpoint({}))).toBe(true);
112+ expect(
113+ isQrShareableEndpoint(makeEndpoint({ reachability: "private-network", status: "unknown" })),
114+ ).toBe(true);
115+ });
116+});
117+
118+describe("selectQrEndpointOption", () => {
119+ const options = [
120+ {
121+ id: "desktop-loopback:4780",
122+ preferenceKey: "desktop-core:loopback:http",
123+ qrShareable: false,
124+ },
125+ {
126+ id: "tailscale-ip:http://100.84.12.7:4780",
127+ preferenceKey: "tailscale:ip:http",
128+ qrShareable: true,
129+ },
130+ {
131+ id: "tailscale-ip:http://100.84.12.8:4780",
132+ preferenceKey: "tailscale:ip:http",
133+ qrShareable: true,
134+ },
135+ {
136+ id: "desktop-lan:http://192.168.1.42:4780",
137+ preferenceKey: "desktop-core:lan:http",
138+ qrShareable: true,
139+ },
140+ ];
141+
142+ it("resolves an explicit selection by unique endpoint id, not the shared preference key", () => {
143+ expect(selectQrEndpointOption(options, "tailscale-ip:http://100.84.12.8:4780", null)?.id).toBe(
144+ "tailscale-ip:http://100.84.12.8:4780",
145+ );
146+ });
147+
148+ it("falls back to the saved default preference key when nothing is selected", () => {
149+ expect(selectQrEndpointOption(options, null, "desktop-core:lan:http")?.id).toBe(
150+ "desktop-lan:http://192.168.1.42:4780",
151+ );
152+ });
153+
154+ it("skips non-QR-shareable options in the fallback so the panel never opens on loopback", () => {
155+ expect(selectQrEndpointOption(options, "tailscale-ip:gone", "nope")?.id).toBe(
156+ "tailscale-ip:http://100.84.12.7:4780",
157+ );
158+ });
159+
160+ it("returns the first option when nothing is QR-shareable, and null when empty", () => {
161+ const loopbackOnly = options.slice(0, 1);
162+ expect(selectQrEndpointOption(loopbackOnly, null, null)?.id).toBe("desktop-loopback:4780");
163+ expect(selectQrEndpointOption([], "anything", "anything")).toBeNull();
164+ });
165+});
apps/web/src/components/settings/ConnectionsSettings.logic.ts+44−1
@@ -1,7 +1,50 @@
1−import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts";
1+import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts";
22
33 type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;
44
5+/**
6+ * A QR code encoding a loopback URL makes the scanning device dial itself, so
7+ * loopback endpoints stay copyable from the endpoint menu but are never
8+ * offered as QR targets.
9+ */
10+export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean {
11+ return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback";
12+}
13+
14+export type QrEndpointOption = {
15+ /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */
16+ readonly id: string;
17+ /**
18+ * Stable per endpoint *type* (endpointDefaultPreferenceKey). Multiple
19+ * endpoints can share one, so it is only used to match the saved default.
20+ */
21+ readonly preferenceKey: string;
22+ /** False for endpoints that stay copyable but must never render as a QR. */
23+ readonly qrShareable: boolean;
24+};
25+
26+/**
27+ * Resolves which endpoint the share panel shows: the user's explicit pick,
28+ * else the saved default endpoint, else the first QR-shareable option (so the
29+ * panel never opens on a loopback QR), else the first option. A stale
30+ * selectedId (endpoint disappeared) falls back rather than blanking the panel.
31+ */
32+export function selectQrEndpointOption<T extends QrEndpointOption>(
33+ options: ReadonlyArray<T>,
34+ selectedId: string | null,
35+ defaultPreferenceKey: string | null,
36+): T | null {
37+ return (
38+ (selectedId !== null ? options.find((option) => option.id === selectedId) : undefined) ??
39+ (defaultPreferenceKey !== null
40+ ? options.find((option) => option.preferenceKey === defaultPreferenceKey)
41+ : undefined) ??
42+ options.find((option) => option.qrShareable) ??
43+ options[0] ??
44+ null
45+ );
46+}
47+
548 export async function applyWslEnableSelection(input: {
649 readonly bridge: WslEnableBridge;
750 readonly mode: "both" | "wsl-only";
apps/web/src/components/settings/ConnectionsSettings.tsx+179−186
@@ -1,5 +1,4 @@
11 import {
2− ChevronDownIcon,
32 ChevronsLeftRightEllipsisIcon,
43 PlusIcon,
54 QrCodeIcon,
@@ -8,7 +7,7 @@ import { · 2 unmodified lines
87 TriangleAlertIcon,
98 } from "lucide-react";
109 import { useAtomValue } from "@effect/atom-react";
11−import { type ReactNode, memo, useCallback, useMemo, useState } from "react";
10+import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";
1211 import {
1312 AuthAccessReadScope,
1413 AuthAccessWriteScope,
@@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; · 27 unmodified lines
4241 import { cn } from "../../lib/utils";
4342 import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
4443 import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
45−import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
44+import {
45+ applyWslEnableSelection,
46+ isQrShareableEndpoint,
47+ selectQrEndpointOption,
48+} from "./ConnectionsSettings.logic";
4649 import {
4750 SettingsPageContainer,
4851 SettingsRow,
@@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; · 33 unmodified lines
8285 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8386 import { Button } from "../ui/button";
8487 import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
85−import { Group, GroupSeparator } from "../ui/group";
8688 import { AnimatedHeight } from "../AnimatedHeight";
87−import {
88− Menu,
89− MenuGroup,
90− MenuGroupLabel,
91− MenuItem,
92− MenuPopup,
93− MenuSeparator,
94− MenuTrigger,
95−} from "../ui/menu";
9689 import { Textarea } from "../ui/textarea";
9790 import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
9891 import { readHostedPairingRequest } from "../../hostedPairing";
@@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean { · 407 unmodified lines
506499 }
507500 }
508501
502+function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {
503+ if (isHostedAppPairingUrl(url)) {
504+ return "Opens the hosted app, no install needed";
505+ }
506+ switch (endpoint.reachability) {
507+ case "lan":
508+ return "Devices on the same network";
509+ case "private-network":
510+ return "Devices on your private network";
511+ case "public":
512+ return "Reachable from anywhere";
513+ case "loopback":
514+ return "Clients on this machine";
515+ }
516+}
517+
509518 type PairingLinkListRowProps = {
510519 pairingLink: ServerPairingLinkRecord;
511520 endpointUrl: string | null | undefined;
@@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 19 unmodified lines
531540 [pairingLink.expiresAt],
532541 );
533542 const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false);
543+ const [isQrPanelOpen, setIsQrPanelOpen] = useState(false);
544+ // Ephemeral per-row choice of which endpoint the QR encodes (AdvertisedEndpoint.id);
545+ // null falls back to the saved default endpoint.
546+ const [qrEndpointId, setQrEndpointId] = useState<string | null>(null);
547+ const qrPanelId = useId();
534548
535549 const currentOriginPairingUrl = useMemo(
536550 () => resolveCurrentOriginPairingUrl(pairingLink.credential),
@@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 12 unmodified lines
549563 }, [defaultEndpointKey, endpoints, pairingLink.credential]);
550564 const endpointCopyOptions = useMemo(() => {
551565 const options: Array<{
552− readonly key: string;
566+ readonly id: string;
567+ readonly preferenceKey: string;
553568 readonly label: string;
554569 readonly url: string;
555570 readonly detail: string;
571+ readonly qrShareable: boolean;
556572 }> = [];
557573 for (const endpoint of endpoints) {
558574 if (endpoint.status === "unavailable") {
@@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 1 unmodified line
560576 }
561577 const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential);
562578 options.push({
563− key: endpointDefaultPreferenceKey(endpoint),
579+ id: endpoint.id,
580+ preferenceKey: endpointDefaultPreferenceKey(endpoint),
564581 label: endpoint.label,
565582 url,
566− detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL",
583+ detail: endpointShareHint(endpoint, url),
584+ qrShareable: isQrShareableEndpoint(endpoint),
567585 });
568586 }
569587 return options;
@@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 5 unmodified lines
575593 : isLoopbackHostname(window.location.hostname)
576594 ? null
577595 : currentOriginPairingUrl);
578− const revealValue = shareablePairingUrl ?? pairingLink.credential;
579− const isShareableHostedAppPairingUrl =
580− shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl);
596+ // Value of the copy attempt that last failed. The clipboard-failure reveal
597+ // dialog must show exactly what failed to copy, not the row's default URL.
598+ const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null);
599+ const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential;
600+ const isRevealValueUrl = revealValue !== pairingLink.credential;
601+ const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue);
602+ // Never render a QR for a loopback URL, even in the manual-copy fallback.
603+ const isRevealValueQrShareable =
604+ endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true;
581605 const canCopyToClipboard =
582606 typeof window !== "undefined" &&
583607 window.isSecureContext &&
584608 navigator.clipboard?.writeText != null;
585609
586− const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({
587− onCopy: (kind) => {
610+ const { copyToClipboard } = useCopyToClipboard<{
611+ value: string;
612+ kind: "code" | "hosted-link" | "link";
613+ }>({
614+ onCopy: ({ kind }) => {
588615 toastManager.add({
589616 type: "success",
590617 title:
@@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 10 unmodified lines
601628 : "Paste it into another client to finish pairing.",
602629 });
603630 },
604− onError: (error, kind) => {
631+ onError: (error, { value, kind }) => {
632+ // Captured per attempt so concurrent copies cannot make the dialog
633+ // reveal a different value than the one that failed.
634+ setFailedCopyValue(value);
605635 setIsRevealDialogOpen(true);
606636 toastManager.add(
607637 stackedThreadToast({
@@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 13 unmodified lines
621651
622652 const copyPairingValue = useCallback(
623653 (value: string, kind: "code" | "hosted-link" | "link") => {
624− copyToClipboard(value, kind);
654+ copyToClipboard(value, { value, kind });
625655 },
626656 [copyToClipboard],
627657 );
@@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 7 unmodified lines
635665 copyPairingValue(pairingLink.credential, "code");
636666 }, [copyPairingValue, pairingLink.credential]);
637667
638− const handleCopyDefaultLink = useCallback(() => {
639− if (!shareablePairingUrl) return;
640− copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl));
641− }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]);
642−
643668 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
644669
645670 const primaryLabel = pairingLink.label ?? "Pairing link";
646− const defaultEndpointCopyOption =
647− endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ??
648− endpointCopyOptions[0] ??
649− null;
650− const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL";
651− const backendEndpointCopyOptions = endpointCopyOptions.filter(
652− (option) => !isHostedAppPairingUrl(option.url),
653− );
654− const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) =>
655− isHostedAppPairingUrl(option.url),
656− );
657− const renderEndpointMenuItems = (
658− options: typeof endpointCopyOptions = endpointCopyOptions,
659− renderDetail = true,
660− ) =>
661− options.map((option) => (
662− <MenuItem
663− key={option.key}
664− onClick={() => copyPairingValue(option.url, copyKindForUrl(option.url))}
665− >
666− <span className="min-w-0 flex-1">
667− <span className="block truncate">{option.label}</span>
668− {renderDetail ? (
669− <span className="block truncate text-[11px] text-muted-foreground">
670− {option.detail}
671− </span>
672− ) : null}
673− </span>
674− </MenuItem>
675− ));
676− const renderPairingCodeMenuItem = (renderDetail = true) => (
677− <MenuItem onClick={handleCopyCode}>
678− <span className="min-w-0 flex-1">
679− <span className="block truncate">Copy code</span>
680− {renderDetail ? (
681− <span className="block truncate text-[11px] text-muted-foreground">Token only</span>
682− ) : null}
683− </span>
684− </MenuItem>
685− );
686− const renderCompactEndpointGroup = (
687− label: string,
688− options: typeof endpointCopyOptions,
689− includeSeparator: boolean,
690− ) =>
691− options.length > 0 ? (
692− <>
693− {includeSeparator ? <MenuSeparator /> : null}
694− <MenuGroup>
695− <MenuGroupLabel>{label}</MenuGroupLabel>
696− {renderEndpointMenuItems(options, false)}
697− </MenuGroup>
698− </>
699− ) : null;
700− const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => (
701− <>
702− {options?.codeFirst ? (
703− <>
704− <MenuGroup>
705− <MenuGroupLabel>Pairing code</MenuGroupLabel>
706− {renderPairingCodeMenuItem(false)}
707− </MenuGroup>
708− {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null}
709− </>
710− ) : null}
711− {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)}
712− {renderCompactEndpointGroup(
713− "Hosted app link",
714− hostedEndpointCopyOptions,
715− backendEndpointCopyOptions.length > 0,
716− )}
717− {!options?.codeFirst ? (
718− <>
719− {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null}
720− <MenuGroup>
721− <MenuGroupLabel>Pairing code</MenuGroupLabel>
722− {renderPairingCodeMenuItem(false)}
723− </MenuGroup>
724− </>
725− ) : null}
726− </>
727− );
728−
671+ const selectedQrOption = selectQrEndpointOption(
672+ endpointCopyOptions,
673+ qrEndpointId,
674+ defaultEndpointKey,
675+ );
676+ const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl;
677+ // With no endpoint list the fallback is never loopback: selectPairingEndpoint
678+ // skips loopback and the current-origin fallback is guarded by
679+ // isLoopbackHostname, so only an explicit loopback selection hides the QR.
680+ const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true;
729681 if (expiresAtMs <= nowMs) {
730682 return null;
731683 }
@@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 8 unmodified lines
740692 dotClassName="bg-amber-400"
741693 />
742694 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>
743− <Popover>
744− {shareablePairingUrl ? (
745− <>
746− <PopoverTrigger
747− openOnHover
748− delay={250}
749− closeDelay={100}
750− render={
751− <button
752− type="button"
753− className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground/50 outline-none hover:text-foreground"
754− aria-label="Show QR code"
755− />
756− }
757− >
758− <QrCodeIcon aria-hidden className="size-3" />
759− </PopoverTrigger>
760− <PopoverPopup side="top" align="start" tooltipStyle className="w-max">
761− <QRCodeSvg
762− value={shareablePairingUrl}
763− size={88}
764− level="M"
765− marginSize={2}
766− title="Pairing link — scan to open on another device"
767− />
768− </PopoverPopup>
769− </>
770− ) : null}
771− </Popover>
772695 </div>
773696 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>
774697 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
@@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 7 unmodified lines
782705 ) : null}
783706 </div>
784707 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
785− <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}>
708+ {shareablePairingUrl && canCopyToClipboard ? (
709+ <Button
710+ size="xs"
711+ variant="outline"
712+ aria-expanded={isQrPanelOpen}
713+ aria-controls={qrPanelId}
714+ onClick={() => setIsQrPanelOpen((open) => !open)}
715+ >
716+ <QrCodeIcon aria-hidden />
717+ Share
718+ </Button>
719+ ) : null}
720+ <Dialog
721+ open={isRevealDialogOpen}
722+ onOpenChange={(open) => {
723+ setIsRevealDialogOpen(open);
724+ if (!open) setFailedCopyValue(null);
725+ }}
726+ >
786727 {canCopyToClipboard ? (
787− <>
788− {shareablePairingUrl ? (
789− <Group aria-label="Copy selected endpoint">
790− <Button
791− size="xs"
792− variant="outline"
793− className="max-w-56"
794− title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`}
795− onClick={handleCopyDefaultLink}
796− >
797− <span className="truncate">
798− Copy pairing URL for: {defaultEndpointCopyLabel}
799− </span>
800− </Button>
801− <GroupSeparator />
802− <Menu>
803− <MenuTrigger
804− render={
805− <Button
806− size="icon-xs"
807− variant="outline"
808− aria-label="Choose endpoint to copy"
809− />
810− }
811− >
812− <ChevronDownIcon className="size-3.5" />
813− </MenuTrigger>
814− <MenuPopup align="end" className="min-w-60">
815− {renderGroupedCopyMenuItems()}
816− </MenuPopup>
817− </Menu>
818− </Group>
819− ) : (
820− <Button size="xs" variant="outline" onClick={handleCopyCode}>
821− Copy code
822− </Button>
823− )}
824− </>
728+ shareablePairingUrl ? null : (
729+ <Button size="xs" variant="outline" onClick={handleCopyCode}>
730+ Copy code
731+ </Button>
732+ )
825733 ) : (
826734 <DialogTrigger render={<Button size="xs" variant="outline" />}>
827735 {shareablePairingUrl ? "Show link" : "Show code"}
@@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 2 unmodified lines
830738 <DialogPopup className="max-w-md">
831739 <DialogHeader>
832740 <DialogTitle>
833− {shareablePairingUrl
834− ? isShareableHostedAppPairingUrl
741+ {isRevealValueUrl
742+ ? isRevealValueHostedAppPairingUrl
835743 ? "Hosted app pairing link"
836744 : "Pairing link"
837745 : "Pairing code"}
838746 </DialogTitle>
839747 <DialogDescription>
840− {shareablePairingUrl
841− ? isShareableHostedAppPairingUrl
748+ {isRevealValueUrl
749+ ? isRevealValueHostedAppPairingUrl
842750 ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect."
843751 : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."
844752 : "Clipboard copy is unavailable here. Manually copy this code into another client."}
@@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 3 unmodified lines
848756 <Textarea
849757 readOnly
850758 value={revealValue}
851− rows={shareablePairingUrl ? 4 : 3}
759+ rows={isRevealValueUrl ? 4 : 3}
852760 className="text-xs leading-relaxed"
853761 onFocus={(event) => event.currentTarget.select()}
854762 onClick={(event) => event.currentTarget.select()}
855763 />
856− {shareablePairingUrl ? (
764+ {isRevealValueUrl && isRevealValueQrShareable ? (
857765 <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4">
858766 <QRCodeSvg
859− value={shareablePairingUrl}
767+ value={revealValue}
860768 size={132}
861769 level="M"
862770 marginSize={2}
@@ -887,6 +795,91 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 24 unmodified lines
887795 </Button>
888796 </div>
889797 </div>
798+ {isQrPanelOpen && qrPairingUrl !== null ? (
799+ <div
800+ id={qrPanelId}
801+ className="mt-3 flex flex-col gap-4 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between"
802+ >
803+ <div className="min-w-0 flex-1 space-y-3">
804+ {endpointCopyOptions.length > 1 ? (
805+ <div
806+ className="space-y-1.5"
807+ role="radiogroup"
808+ aria-label="Endpoint the pairing QR code and URL use"
809+ >
810+ <p className="text-[11px] text-muted-foreground/70">Reach this machine via</p>
811+ {endpointCopyOptions.map((option) => {
812+ const isSelected = option.id === selectedQrOption?.id;
813+ return (
814+ <button
815+ key={option.id}
816+ type="button"
817+ role="radio"
818+ aria-checked={isSelected}
819+ className={cn(
820+ "flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
821+ isSelected
822+ ? "border-foreground/60 bg-muted/30"
823+ : "border-border/50 hover:bg-muted/20",
824+ )}
825+ onClick={() => setQrEndpointId(option.id)}
826+ >
827+ <span
828+ className={cn(
829+ "text-xs font-medium",
830+ isSelected ? "text-foreground" : "text-muted-foreground",
831+ )}
832+ >
833+ {option.label}
834+ </span>
835+ <span className="min-w-0 truncate text-[11px] text-muted-foreground/70">
836+ {option.detail}
837+ </span>
838+ </button>
839+ );
840+ })}
841+ </div>
842+ ) : null}
843+ <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5">
844+ <code
845+ title={qrPairingUrl}
846+ className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"
847+ >
848+ {qrPairingUrl}
849+ </code>
850+ <Button
851+ size="xs"
852+ variant="ghost"
853+ className="shrink-0"
854+ onClick={() => copyPairingValue(qrPairingUrl, copyKindForUrl(qrPairingUrl))}
855+ >
856+ Copy link
857+ </Button>
858+ </div>
859+ <Button size="xs" variant="ghost" onClick={handleCopyCode}>
860+ Copy code only
861+ </Button>
862+ </div>
863+ {canRenderQrForSelection ? (
864+ <div className="w-fit shrink-0 self-center rounded-xl bg-white p-3 sm:self-start">
865+ <QRCodeSvg
866+ value={qrPairingUrl}
867+ size={168}
868+ level="M"
869+ marginSize={1}
870+ title="Pairing link — scan to open on another device"
871+ />
872+ </div>
873+ ) : (
874+ <div className="flex size-[192px] shrink-0 items-center justify-center self-center rounded-xl border border-border/50 p-4 sm:self-start">
875+ <p className="text-center text-[11px] text-muted-foreground/70">
876+ No QR for this endpoint. Another device scanning a loopback link would dial itself;
877+ copy the URL for use on this machine instead.
878+ </p>
879+ </div>
880+ )}
881+ </div>
882+ ) : null}
890883 </div>
891884 );
892885 });