Skip to content

Commit 04058fb

Browse files
aussedatloclaude
andcommitted
feat(lld): open the mock server configuration UI from the top bar indicator
Right-clicking the indicator opens the mock server's configuration UI on the session Ledger Live is already using, so its devices can be edited without pasting a token by hand. Left click still copies the token. The token is handed over in the URL fragment (`#token=…`), which the UI reads once and wipes from its address bar. A fragment never reaches the server, so the token stays out of its access logs, and the link is opened with no analytics event name because `openURL` otherwise reports the URL it opened. `TopBarAction` gains an optional `onContextMenu`, so a right click means nothing on every other top bar button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f0e404c commit 04058fb

8 files changed

Lines changed: 156 additions & 3 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"ledger-live-desktop": patch
3+
---
4+
5+
Open the mock server configuration UI by right-clicking the top bar indicator.
6+
7+
The indicator still copies the session token on a left click. A right click now opens the mock server's own configuration UI — served from the server root — on that same session, so the devices Ledger Live sees can be edited without pasting a token by hand.
8+
9+
The token travels in the URL fragment (`#token=…`), which never reaches the server, and the link is opened without an analytics event so the token stays out of the tracking payload.

apps/ledger-live-desktop/src/mvvm/components/TopBar/components/TopBarActionButton.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function TopBarActionButton({
2020
appearance = "gray",
2121
className,
2222
onTooltipShow,
23+
onContextMenu,
2324
cta,
2425
}: TopBarActionButtonProps) {
2526
const testId = `topbar-action-button-${label.replace(/\s+/g, "-").toLowerCase()}`;
@@ -31,12 +32,22 @@ export function TopBarActionButton({
3132
[onTooltipShow],
3233
);
3334

35+
const handleContextMenu = useCallback(
36+
(event: React.MouseEvent) => {
37+
if (!onContextMenu) return;
38+
event.preventDefault();
39+
onContextMenu();
40+
},
41+
[onContextMenu],
42+
);
43+
3444
const button = cta ? (
3545
<Button
3646
appearance={appearance}
3747
size="sm"
3848
icon={icon}
3949
onClick={onClick}
50+
onContextMenu={handleContextMenu}
4051
data-testid={testId}
4152
disabled={!isInteractive}
4253
className={`rounded-full${className ? ` ${className}` : ""}`}
@@ -50,6 +61,7 @@ export function TopBarActionButton({
5061
aria-label={label}
5162
icon={icon}
5263
onClick={onClick}
64+
onContextMenu={handleContextMenu}
5365
data-testid={testId}
5466
disabled={!isInteractive}
5567
className={className}

apps/ledger-live-desktop/src/mvvm/components/TopBar/components/__tests__/TopBarActionButton.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,37 @@ describe("TopBarActionButton", () => {
6767
const button = screen.getByTestId("topbar-action-button-test-action");
6868
expect(button).toBeDisabled();
6969
});
70+
71+
it("calls onContextMenu instead of opening the native menu on right click", async () => {
72+
const handleContextMenu = jest.fn();
73+
74+
const { user } = render(
75+
<TopBarActionButton {...defaultProps} onContextMenu={handleContextMenu} />,
76+
);
77+
78+
const contextMenu = jest.fn();
79+
document.addEventListener("contextmenu", contextMenu);
80+
await user.pointer({
81+
target: screen.getByTestId("topbar-action-button-test-action"),
82+
keys: "[MouseRight]",
83+
});
84+
document.removeEventListener("contextmenu", contextMenu);
85+
86+
expect(handleContextMenu).toHaveBeenCalledTimes(1);
87+
expect(contextMenu.mock.calls[0][0].defaultPrevented).toBe(true);
88+
});
89+
90+
it("leaves the native menu alone when no onContextMenu is given", async () => {
91+
const { user } = render(<TopBarActionButton {...defaultProps} />);
92+
93+
const contextMenu = jest.fn();
94+
document.addEventListener("contextmenu", contextMenu);
95+
await user.pointer({
96+
target: screen.getByTestId("topbar-action-button-test-action"),
97+
keys: "[MouseRight]",
98+
});
99+
document.removeEventListener("contextmenu", contextMenu);
100+
101+
expect(contextMenu.mock.calls[0][0].defaultPrevented).toBe(false);
102+
});
70103
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { renderHook, act } from "tests/testSetup";
2+
import { openURL } from "~/renderer/linking";
3+
import { useMockServerTransport } from "../useMockServerTransport";
4+
import { useMockServerStatus, type MockServerStatus } from "../useMockServerStatus";
5+
6+
jest.mock("@ledgerhq/live-dmk-desktop", () => ({
7+
getMockServerTransportUrl: () => "https://mock.example",
8+
}));
9+
10+
jest.mock("~/renderer/linking", () => ({
11+
openURL: jest.fn(),
12+
}));
13+
14+
jest.mock("../useMockServerStatus", () => ({
15+
useMockServerStatus: jest.fn(),
16+
}));
17+
18+
const mockCopyToClipboard = jest.fn();
19+
jest.mock("LLD/hooks/useCopyToClipboard", () => ({
20+
useCopyToClipboard: () => mockCopyToClipboard,
21+
}));
22+
23+
const givenStatus = (status: Partial<MockServerStatus>) =>
24+
jest.mocked(useMockServerStatus).mockReturnValue({ enabled: true, connected: true, ...status });
25+
26+
describe("useMockServerTransport", () => {
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
givenStatus({ sessionToken: "a-session-token" });
30+
});
31+
32+
it("copies the session token when clicked", () => {
33+
const { result } = renderHook(() => useMockServerTransport());
34+
35+
act(() => result.current.handleMockServer());
36+
37+
expect(mockCopyToClipboard).toHaveBeenCalledWith("a-session-token");
38+
});
39+
40+
it("opens the configuration UI on the current session", () => {
41+
const { result } = renderHook(() => useMockServerTransport());
42+
43+
act(() => result.current.handleOpenConfigurationUi());
44+
45+
expect(openURL).toHaveBeenCalledWith("https://mock.example/#token=a-session-token", "");
46+
});
47+
48+
it("escapes a session token that is not URL safe", () => {
49+
givenStatus({ sessionToken: "a+b/c=" });
50+
const { result } = renderHook(() => useMockServerTransport());
51+
52+
act(() => result.current.handleOpenConfigurationUi());
53+
54+
expect(openURL).toHaveBeenCalledWith("https://mock.example/#token=a%2Bb%2Fc%3D", "");
55+
});
56+
57+
it("does nothing without a session token", () => {
58+
givenStatus({ sessionToken: undefined });
59+
const { result } = renderHook(() => useMockServerTransport());
60+
61+
act(() => {
62+
result.current.handleMockServer();
63+
result.current.handleOpenConfigurationUi();
64+
});
65+
66+
expect(mockCopyToClipboard).not.toHaveBeenCalled();
67+
expect(openURL).not.toHaveBeenCalled();
68+
});
69+
70+
it("is hidden while the transport is disabled", () => {
71+
givenStatus({ enabled: false, sessionToken: undefined });
72+
const { result } = renderHook(() => useMockServerTransport());
73+
74+
expect(result.current.isVisible).toBe(false);
75+
});
76+
});

apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useMockServerTransport.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
import { useCallback } from "react";
22
import { useTranslation } from "react-i18next";
33
import { Devices } from "@ledgerhq/lumen-ui-react/symbols";
4+
import { getMockServerTransportUrl } from "@ledgerhq/live-dmk-desktop";
5+
import { openURL } from "~/renderer/linking";
46
import { useMockServerStatus } from "./useMockServerStatus";
57
import { useCopyToClipboard } from "LLD/hooks/useCopyToClipboard";
68

9+
/**
10+
* The mock server's configuration UI, served from the server root, takes over a
11+
* session handed to it as `#token=…`. A fragment never reaches the server, so
12+
* the token stays out of its logs.
13+
*/
14+
const configurationUiUrl = (token: string): string =>
15+
`${getMockServerTransportUrl()}/#${new URLSearchParams({ token }).toString()}`;
16+
717
/**
818
* Drives the developer top bar indicator for the Device Management Kit mock
919
* server transport. The button is only visible while the transport is enabled
1020
* (env `MOCK_SERVER_TRANSPORT`), and is colored green when the mock server is
1121
* reachable, red otherwise. Clicking it copies the current mock server session
12-
* token to the clipboard.
22+
* token to the clipboard; right-clicking opens the mock server configuration UI
23+
* on that session.
1324
*/
1425
export const useMockServerTransport = () => {
1526
const { t } = useTranslation();
@@ -20,12 +31,19 @@ export const useMockServerTransport = () => {
2031
if (sessionToken) copyToClipboard(sessionToken);
2132
}, [copyToClipboard, sessionToken]);
2233

34+
// No event name: `openURL` reports the URL it opened, and this one carries the
35+
// session token.
36+
const handleOpenConfigurationUi = useCallback(() => {
37+
if (sessionToken) openURL(configurationUiUrl(sessionToken), "");
38+
}, [sessionToken]);
39+
2340
return {
2441
isVisible: enabled,
2542
handleMockServer,
43+
handleOpenConfigurationUi,
2644
icon: Devices,
2745
tooltip: connected
28-
? t("settings.developer.mockServerStatus.copySessionToken")
46+
? t("settings.developer.mockServerStatus.sessionActions")
2947
: t("settings.developer.mockServerStatus.disconnected"),
3048
// Solid green / red circle, matching the experimental & feature-flag buttons.
3149
className: connected

apps/ledger-live-desktop/src/mvvm/components/TopBar/hooks/useTopBarViewModel.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const useTopBarViewModel = () => {
4545
const {
4646
isVisible: isMockServerVisible,
4747
handleMockServer,
48+
handleOpenConfigurationUi: handleMockServerConfigurationUi,
4849
icon: mockServerIcon,
4950
tooltip: mockServerTooltip,
5051
className: mockServerClassName,
@@ -91,9 +92,11 @@ const useTopBarViewModel = () => {
9192
action: {
9293
label: "mock server",
9394
tooltip: mockServerTooltip,
95+
tooltipClassName: "max-w-sm text-wrap",
9496
icon: mockServerIcon,
9597
isInteractive: true,
9698
onClick: handleMockServer,
99+
onContextMenu: handleMockServerConfigurationUi,
97100
appearance: "accent" as const,
98101
className: mockServerClassName,
99102
},

apps/ledger-live-desktop/src/mvvm/components/TopBar/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ type TopBarAction = {
4141
tooltipClassName?: string;
4242
/** Called when the tooltip is shown (e.g. on hover). Used for analytics when showing error tooltip. */
4343
onTooltipShow?: () => void;
44+
/** When set, right-clicking the button calls this instead of opening the native context menu. */
45+
onContextMenu?: () => void;
4446
/** When set, renders a Button (icon + text label) instead of an IconButton. */
4547
cta?: string;
4648
};

apps/ledger-live-desktop/static/i18n/en/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5535,7 +5535,7 @@
55355535
"mockServerStatus": {
55365536
"connected": "Mock server connected",
55375537
"disconnected": "Mock server unreachable",
5538-
"copySessionToken": "Mock server connected · click to copy session token"
5538+
"sessionActions": "Mock server connected · click to copy the session token, right-click to open the configuration UI"
55395539
},
55405540
"openOnboardingAppInstallDebug": "Open onboarding app install debug screen",
55415541
"openOnboardingAppInstallDebugDesc": "Open a screen with the components from the sync onboarding app installation step"

0 commit comments

Comments
 (0)