authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-04 16:53:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 16:53:54-07:00
logfff6a5b028f85122ffef8d3636f390f95ade5172
tree828c5deff0a9df30f754ab46ea673829e8f69c52
parentda6e1a96782594cab3a6925f441731a65be57c11
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 315 insertions(+), 189 deletions(-)

apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+92-2
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import type { DesktopWslState } from "@t3tools/contracts";1import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";2import { describe, expect, it, vi } from "vite-plus/test";
3import { applyWslEnableSelection } from "./ConnectionsSettings.logic";3import {
4 applyWslEnableSelection,
5 isQrShareableEndpoint,
6 selectQrEndpointOption,
7} from "./ConnectionsSettings.logic";
48
5const baseWslState: DesktopWslState = {9const baseWslState: DesktopWslState = {
6 enabled: false,10 enabled: false,
...@@ -73,3 +77,89 @@ describe("applyWslEnableSelection", () => {...@@ -73,3 +77,89 @@ describe("applyWslEnableSelection", () => {
73 expect(state).toMatchObject({ enabled: true, wslOnly: true });77 expect(state).toMatchObject({ enabled: true, wslOnly: true });
74 });78 });
75});79});
80
81function 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
96describe("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
118describe("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,7 +1,50 @@
1import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts";1import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts";
22
3type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;3type 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 */
10export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean {
11 return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback";
12}
13
14export 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 */
32export 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
5export async function applyWslEnableSelection(input: {48export async function applyWslEnableSelection(input: {
6 readonly bridge: WslEnableBridge;49 readonly bridge: WslEnableBridge;
7 readonly mode: "both" | "wsl-only";50 readonly mode: "both" | "wsl-only";
apps/web/src/components/settings/ConnectionsSettings.tsx+179-186
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import {1import {
2 ChevronDownIcon,
3 ChevronsLeftRightEllipsisIcon,2 ChevronsLeftRightEllipsisIcon,
4 PlusIcon,3 PlusIcon,
5 QrCodeIcon,4 QrCodeIcon,
...@@ -8,7 +7,7 @@ import {...@@ -8,7 +7,7 @@ import {
8 TriangleAlertIcon,7 TriangleAlertIcon,
9} from "lucide-react";8} from "lucide-react";
10import { useAtomValue } from "@effect/atom-react";9import { useAtomValue } from "@effect/atom-react";
11import { type ReactNode, memo, useCallback, useMemo, useState } from "react";10import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";
12import {11import {
13 AuthAccessReadScope,12 AuthAccessReadScope,
14 AuthAccessWriteScope,13 AuthAccessWriteScope,
...@@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";...@@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
42import { cn } from "../../lib/utils";41import { cn } from "../../lib/utils";
43import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";42import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
44import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";43import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
45import { applyWslEnableSelection } from "./ConnectionsSettings.logic";44import {
45 applyWslEnableSelection,
46 isQrShareableEndpoint,
47 selectQrEndpointOption,
48} from "./ConnectionsSettings.logic";
46import {49import {
47 SettingsPageContainer,50 SettingsPageContainer,
48 SettingsRow,51 SettingsRow,
...@@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";...@@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
82import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";85import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
83import { Button } from "../ui/button";86import { Button } from "../ui/button";
84import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";87import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
85import { Group, GroupSeparator } from "../ui/group";
86import { AnimatedHeight } from "../AnimatedHeight";88import { AnimatedHeight } from "../AnimatedHeight";
87import {
88 Menu,
89 MenuGroup,
90 MenuGroupLabel,
91 MenuItem,
92 MenuPopup,
93 MenuSeparator,
94 MenuTrigger,
95} from "../ui/menu";
96import { Textarea } from "../ui/textarea";89import { Textarea } from "../ui/textarea";
97import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";90import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
98import { readHostedPairingRequest } from "../../hostedPairing";91import { readHostedPairingRequest } from "../../hostedPairing";
...@@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean {...@@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean {
506 }499 }
507}500}
508501
502function 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
509type PairingLinkListRowProps = {518type PairingLinkListRowProps = {
510 pairingLink: ServerPairingLinkRecord;519 pairingLink: ServerPairingLinkRecord;
511 endpointUrl: string | null | undefined;520 endpointUrl: string | null | undefined;
...@@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
531 [pairingLink.expiresAt],540 [pairingLink.expiresAt],
532 );541 );
533 const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false);542 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
535 const currentOriginPairingUrl = useMemo(549 const currentOriginPairingUrl = useMemo(
536 () => resolveCurrentOriginPairingUrl(pairingLink.credential),550 () => resolveCurrentOriginPairingUrl(pairingLink.credential),
...@@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
549 }, [defaultEndpointKey, endpoints, pairingLink.credential]);563 }, [defaultEndpointKey, endpoints, pairingLink.credential]);
550 const endpointCopyOptions = useMemo(() => {564 const endpointCopyOptions = useMemo(() => {
551 const options: Array<{565 const options: Array<{
552 readonly key: string;566 readonly id: string;
567 readonly preferenceKey: string;
553 readonly label: string;568 readonly label: string;
554 readonly url: string;569 readonly url: string;
555 readonly detail: string;570 readonly detail: string;
571 readonly qrShareable: boolean;
556 }> = [];572 }> = [];
557 for (const endpoint of endpoints) {573 for (const endpoint of endpoints) {
558 if (endpoint.status === "unavailable") {574 if (endpoint.status === "unavailable") {
...@@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
560 }576 }
561 const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential);577 const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential);
562 options.push({578 options.push({
563 key: endpointDefaultPreferenceKey(endpoint),579 id: endpoint.id,
580 preferenceKey: endpointDefaultPreferenceKey(endpoint),
564 label: endpoint.label,581 label: endpoint.label,
565 url,582 url,
566 detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL",583 detail: endpointShareHint(endpoint, url),
584 qrShareable: isQrShareableEndpoint(endpoint),
567 });585 });
568 }586 }
569 return options;587 return options;
...@@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
575 : isLoopbackHostname(window.location.hostname)593 : isLoopbackHostname(window.location.hostname)
576 ? null594 ? null
577 : currentOriginPairingUrl);595 : currentOriginPairingUrl);
578 const revealValue = shareablePairingUrl ?? pairingLink.credential;596 // Value of the copy attempt that last failed. The clipboard-failure reveal
579 const isShareableHostedAppPairingUrl =597 // dialog must show exactly what failed to copy, not the row's default URL.
580 shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl);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;
581 const canCopyToClipboard =605 const canCopyToClipboard =
582 typeof window !== "undefined" &&606 typeof window !== "undefined" &&
583 window.isSecureContext &&607 window.isSecureContext &&
584 navigator.clipboard?.writeText != null;608 navigator.clipboard?.writeText != null;
585609
586 const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({610 const { copyToClipboard } = useCopyToClipboard<{
587 onCopy: (kind) => {611 value: string;
612 kind: "code" | "hosted-link" | "link";
613 }>({
614 onCopy: ({ kind }) => {
588 toastManager.add({615 toastManager.add({
589 type: "success",616 type: "success",
590 title:617 title:
...@@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
601 : "Paste it into another client to finish pairing.",628 : "Paste it into another client to finish pairing.",
602 });629 });
603 },630 },
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);
605 setIsRevealDialogOpen(true);635 setIsRevealDialogOpen(true);
606 toastManager.add(636 toastManager.add(
607 stackedThreadToast({637 stackedThreadToast({
...@@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
621651
622 const copyPairingValue = useCallback(652 const copyPairingValue = useCallback(
623 (value: string, kind: "code" | "hosted-link" | "link") => {653 (value: string, kind: "code" | "hosted-link" | "link") => {
624 copyToClipboard(value, kind);654 copyToClipboard(value, { value, kind });
625 },655 },
626 [copyToClipboard],656 [copyToClipboard],
627 );657 );
...@@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
635 copyPairingValue(pairingLink.credential, "code");665 copyPairingValue(pairingLink.credential, "code");
636 }, [copyPairingValue, pairingLink.credential]);666 }, [copyPairingValue, pairingLink.credential]);
637667
638 const handleCopyDefaultLink = useCallback(() => {
639 if (!shareablePairingUrl) return;
640 copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl));
641 }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]);
642
643 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);668 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
644669
645 const primaryLabel = pairingLink.label ?? "Pairing link";670 const primaryLabel = pairingLink.label ?? "Pairing link";
646 const defaultEndpointCopyOption =671 const selectedQrOption = selectQrEndpointOption(
647 endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ??672 endpointCopyOptions,
648 endpointCopyOptions[0] ??673 qrEndpointId,
649 null;674 defaultEndpointKey,
650 const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL";675 );
651 const backendEndpointCopyOptions = endpointCopyOptions.filter(676 const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl;
652 (option) => !isHostedAppPairingUrl(option.url),677 // With no endpoint list the fallback is never loopback: selectPairingEndpoint
653 );678 // skips loopback and the current-origin fallback is guarded by
654 const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) =>679 // isLoopbackHostname, so only an explicit loopback selection hides the QR.
655 isHostedAppPairingUrl(option.url),680 const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true;
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
729 if (expiresAtMs <= nowMs) {681 if (expiresAtMs <= nowMs) {
730 return null;682 return null;
731 }683 }
...@@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
740 dotClassName="bg-amber-400"692 dotClassName="bg-amber-400"
741 />693 />
742 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>694 <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>
772 </div>695 </div>
773 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>696 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>
774 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}697 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
...@@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
782 ) : null}705 ) : null}
783 </div>706 </div>
784 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">707 <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 >
786 {canCopyToClipboard ? (727 {canCopyToClipboard ? (
787 <>728 shareablePairingUrl ? null : (
788 {shareablePairingUrl ? (729 <Button size="xs" variant="outline" onClick={handleCopyCode}>
789 <Group aria-label="Copy selected endpoint">730 Copy code
790 <Button731 </Button>
791 size="xs"732 )
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 </>
825 ) : (733 ) : (
826 <DialogTrigger render={<Button size="xs" variant="outline" />}>734 <DialogTrigger render={<Button size="xs" variant="outline" />}>
827 {shareablePairingUrl ? "Show link" : "Show code"}735 {shareablePairingUrl ? "Show link" : "Show code"}
...@@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
830 <DialogPopup className="max-w-md">738 <DialogPopup className="max-w-md">
831 <DialogHeader>739 <DialogHeader>
832 <DialogTitle>740 <DialogTitle>
833 {shareablePairingUrl741 {isRevealValueUrl
834 ? isShareableHostedAppPairingUrl742 ? isRevealValueHostedAppPairingUrl
835 ? "Hosted app pairing link"743 ? "Hosted app pairing link"
836 : "Pairing link"744 : "Pairing link"
837 : "Pairing code"}745 : "Pairing code"}
838 </DialogTitle>746 </DialogTitle>
839 <DialogDescription>747 <DialogDescription>
840 {shareablePairingUrl748 {isRevealValueUrl
841 ? isShareableHostedAppPairingUrl749 ? isRevealValueHostedAppPairingUrl
842 ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect."750 ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect."
843 : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."751 : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."
844 : "Clipboard copy is unavailable here. Manually copy this code into another client."}752 : "Clipboard copy is unavailable here. Manually copy this code into another client."}
...@@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
848 <Textarea756 <Textarea
849 readOnly757 readOnly
850 value={revealValue}758 value={revealValue}
851 rows={shareablePairingUrl ? 4 : 3}759 rows={isRevealValueUrl ? 4 : 3}
852 className="text-xs leading-relaxed"760 className="text-xs leading-relaxed"
853 onFocus={(event) => event.currentTarget.select()}761 onFocus={(event) => event.currentTarget.select()}
854 onClick={(event) => event.currentTarget.select()}762 onClick={(event) => event.currentTarget.select()}
855 />763 />
856 {shareablePairingUrl ? (764 {isRevealValueUrl && isRevealValueQrShareable ? (
857 <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4">765 <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4">
858 <QRCodeSvg766 <QRCodeSvg
859 value={shareablePairingUrl}767 value={revealValue}
860 size={132}768 size={132}
861 level="M"769 level="M"
862 marginSize={2}770 marginSize={2}
...@@ -887,6 +795,91 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -887,6 +795,91 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
887 </Button>795 </Button>
888 </div>796 </div>
889 </div>797 </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}
890 </div>883 </div>
891 );884 );
892});885});