Skip to content

Commit cfecdb8

Browse files
feat(browser-bridge): put the installation key in a dialog that knows the extension's page
The key was spread across three places: a fingerprint in the row's description, the key in a field that only existed while the page stayed open, and a hint telling the person to find the extension's options page by hand. The person had to match a key to a browser by reading both sides. It is now one dialog. The fingerprint the extension's page shows back, the key itself, a copy action, and a link to the extension's own options page — the page the key is pasted on — beside them. The link opens in a new tab because the person has to paste into the page it opens and come back to the dialog for the copy; the dialog is where the pairing happens. The host cannot always name that page, and it does not pretend to. The extension's id is the one its origin carries, so the host knows it only for an extension it has seen: the live session first, then the identities this host has proved, then the ones an operator configured — the last two only when there is exactly one of them, because a page named for the wrong extension is worse than no page at all. With no known identity the dialog says so and gives the manual path instead of a link that could open the wrong extension. Both actions the person takes here — the key button and the extension package download — now use the shared button contract, so they read like every other button in Settings instead of like a page of their own. The published inventory regains the three source files the extension package commit added: they were committed but never recorded, so the source gate could not have passed for them. It also records three paths that are still being written elsewhere in this working tree, because the inventory describes the tree it is generated from.
1 parent 231157d commit cfecdb8

10 files changed

Lines changed: 329 additions & 32 deletions

File tree

‎packages/webui/docs/browser-use/04-pairing-credentials.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,16 @@ host, and `chrome.storage.local` in that one browser.
281281

282282
1. Switch the surface on in WebUI Settings. That provisions the installation key
283283
and mints a fresh pairing credential.
284-
2. Open the key in Settings (**查看密钥** for Edge or Chrome). This is the one
285-
place the key leaves the OS secure store, and it is the person's own tab; the
286-
key's purpose is to defeat a local *process*, not the person.
287-
3. Open the extension's options page (`options.html`, wired as `options_ui` in
288-
the manifest) and paste it.
284+
2. Open the key in Settings (**查看密钥** for Edge or Chrome). A dialog opens
285+
with the key, its fingerprint and a copy action. This is the one place the
286+
key leaves the OS secure store, and it is the person's own tab; the key's
287+
purpose is to defeat a local *process*, not the person.
288+
3. Paste it into the extension's options page (`options.html`, wired as
289+
`options_ui` in the manifest). The dialog links to that page and opens it
290+
in a new tab: the host names it from the extension's own identity, which it
291+
learns from the peer's origin, so the page is the right one rather than one
292+
the person has to find. A host that has not seen the extension yet cannot
293+
name the page, and says so instead of linking somewhere else.
289294
4. The extension stores it and never displays it again in full — the options page
290295
reads it back through a status type that carries a fingerprint and no key
291296
field at all.

‎packages/webui/src/client/features/browser/contracts/browser-port.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,20 @@ export type WebuiBrowserInstallationKeyResult =
132132
readonly keyId: string;
133133
readonly key: string;
134134
readonly fingerprint: string;
135+
/**
136+
* The extension this key belongs to, when the host knows it.
137+
*
138+
* The person pastes the key into that extension's own page, so naming it
139+
* is what keeps the two halves of the pairing together.
140+
*/
141+
readonly extensionId?: string;
142+
/**
143+
* The extension's options page, where the key is pasted.
144+
*
145+
* Absent when the host has not seen the extension: the page cannot be
146+
* named for an extension whose identity the host does not know.
147+
*/
148+
readonly optionsUrl?: string;
135149
};
136150

137151
/** What `sendBrowserInput` answers. */

‎packages/webui/src/client/features/browser/ui/BrowserAuthorizationSwitches.public.tsx‎

Lines changed: 137 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131

3232
import { useCallback, useState, type ReactElement } from "react";
3333
import { WebuiTextInput } from "../../../ui/field.js";
34+
import { WebuiDialog } from "../../../ui/dialog.js";
35+
import { WebuiStack } from "../../../ui/layout-primitives.js";
36+
import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js";
37+
import { useWebuiToast } from "../../../ui/toast.js";
3438
import {
3539
WEBUI_BROWSER_AUTHORIZATION_DEFAULT,
3640
type WebuiBrowserAuthorization,
@@ -43,7 +47,7 @@ import type {
4347
} from "../contracts/browser-port.js";
4448
import { ToggleSwitch } from "../../../ui/ToggleSwitch.js";
4549
import { SettingsRow, SettingsSection } from "../../../ui/settings-layout.js";
46-
import { WebuiButton } from "../../../ui/button.js";
50+
import { WebuiButton, WebuiButtonLink } from "../../../ui/button.js";
4751

4852
const SURFACE_ROWS: readonly { readonly surface: WebuiBrowserSurface; readonly title: string; readonly description: string }[] = [
4953
{
@@ -145,8 +149,17 @@ export interface BrowserInstallationKeyProps {
145149

146150
interface RevealedKey {
147151
readonly surface: WebuiBridgeBrowserSurface;
152+
/** The browser's name, so the dialog says which browser it is about. */
153+
readonly title: string;
148154
readonly key: string;
149155
readonly fingerprint: string;
156+
/**
157+
* The extension's own page, where the key is pasted.
158+
*
159+
* Absent when the host has not seen the extension: the page cannot be named
160+
* for an extension whose identity the host does not know.
161+
*/
162+
readonly optionsUrl?: string;
150163
}
151164

152165
const BRIDGE_SURFACES = [
@@ -155,7 +168,7 @@ const BRIDGE_SURFACES = [
155168
] as const satisfies readonly { readonly surface: WebuiBridgeBrowserSurface; readonly title: string }[];
156169

157170
/**
158-
* The installation key, shown once so the person can paste it into the
171+
* The installation key, shown in a dialog so the person can paste it into the
159172
* extension.
160173
*
161174
* This is the one place the key leaves the host's OS secure store, and it is
@@ -167,6 +180,7 @@ export function BrowserInstallationKey({ readKey }: BrowserInstallationKeyProps)
167180
const [revealed, setRevealed] = useState<RevealedKey | undefined>();
168181
const [notProvisioned, setNotProvisioned] = useState<readonly WebuiBridgeBrowserSurface[]>([]);
169182
const [busy, setBusy] = useState(false);
183+
const toast = useWebuiToast();
170184

171185
const reveal = useCallback(
172186
async (surface: WebuiBridgeBrowserSurface): Promise<void> => {
@@ -175,26 +189,37 @@ export function BrowserInstallationKey({ readKey }: BrowserInstallationKeyProps)
175189
try {
176190
const result = await readKey({ surface });
177191
if (result.present) {
178-
setRevealed({ surface: result.surface, key: result.key, fingerprint: result.fingerprint });
192+
setRevealed({
193+
surface: result.surface,
194+
title:
195+
BRIDGE_SURFACES.find((entry) => entry.surface === result.surface)?.title ??
196+
result.surface,
197+
key: result.key,
198+
fingerprint: result.fingerprint,
199+
...(result.optionsUrl ? { optionsUrl: result.optionsUrl } : {}),
200+
});
179201
setNotProvisioned((prior) => prior.filter((entry) => entry !== surface));
180202
return;
181203
}
182204
// Not an error: the surface has never been switched on, so there is
183205
// nothing provisioned and nothing to paste.
184206
setNotProvisioned((prior) => (prior.includes(surface) ? prior : [...prior, surface]));
185-
setRevealed((prior) => (prior?.surface === surface ? undefined : prior));
207+
} catch (reason) {
208+
// A read that failed carries the host's reason, and it is the only
209+
// thing the person can act on.
210+
toast.error(reason instanceof Error ? reason.message : String(reason));
186211
} finally {
187212
setBusy(false);
188213
}
189214
},
190-
[readKey],
215+
[readKey, toast],
191216
);
192217

193218
return (
194219
<SettingsSection
195220
title="浏览器桥接安装密钥"
196221
testId="browser-installation-key"
197-
hint="扩展需要这把密钥才能完成配对。打开对应浏览器的开关后在此查看,并粘贴到扩展的选项页:浏览器扩展管理页(edge://extensions 或 chrome://extensions)里找到「MiniMax Code Browser Bridge」,点「扩展选项」。"
222+
hint="扩展需要这把密钥才能完成配对。打开对应浏览器的开关后在此查看密钥,复制到扩展的选项页即可。"
198223
>
199224
{BRIDGE_SURFACES.map(({ surface, title }) => (
200225
<SettingsRow
@@ -203,15 +228,13 @@ export function BrowserInstallationKey({ readKey }: BrowserInstallationKeyProps)
203228
description={
204229
notProvisioned.includes(surface)
205230
? "该浏览器尚未启用,没有可复制的密钥。"
206-
: revealed?.surface === surface
207-
? `指纹 ${revealed.fingerprint}`
208-
: "查看并复制这把密钥。"
231+
: "查看并复制这把密钥。"
209232
}
210233
testId={`browser-installation-key-${surface}-row`}
211234
>
212235
<WebuiButton
213-
variant="quiet-secondary"
214-
size="inline"
236+
variant="secondary"
237+
size="compact"
215238
data-testid={`browser-installation-key-${surface}-reveal`}
216239
disabled={!readKey || busy}
217240
onClick={() => {
@@ -223,24 +246,118 @@ export function BrowserInstallationKey({ readKey }: BrowserInstallationKeyProps)
223246
</SettingsRow>
224247
))}
225248
{revealed ? (
226-
<SettingsRow
227-
title="复制用密钥"
228-
description="粘贴到扩展的选项页后即可配对。密钥只保存在这台浏览器的本地存储中。"
229-
testId="browser-installation-key-value-row"
230-
>
249+
<BrowserInstallationKeyDialog
250+
revealed={revealed}
251+
onClose={() => setRevealed(undefined)}
252+
/>
253+
) : null}
254+
</SettingsSection>
255+
);
256+
}
257+
258+
/**
259+
* The key, in a dialog, with the extension's page beside it.
260+
*
261+
* The key is pasted into the extension's own options page, so the dialog holds
262+
* the two halves of the pairing together: the fingerprint — which that page
263+
* shows back — and the key itself, with a copy action and a link to the page.
264+
* The link opens in a new tab because the person has to paste the key into
265+
* the page the link opens, and the dialog is where they come back to.
266+
*/
267+
function BrowserInstallationKeyDialog({
268+
revealed,
269+
onClose,
270+
}: {
271+
readonly revealed: RevealedKey;
272+
readonly onClose: () => void;
273+
}): ReactElement {
274+
const { dom } = useWebuiBrowserCapabilities();
275+
const toast = useWebuiToast();
276+
277+
const copy = useCallback(async (): Promise<void> => {
278+
try {
279+
if (typeof navigator === "undefined" || !navigator.clipboard) {
280+
// The clipboard can be absent, and the key is still the person's to
281+
// copy: the fallback copies it through a hidden field.
282+
dom.copyTextFallback(revealed.key);
283+
} else {
284+
await navigator.clipboard.writeText(revealed.key);
285+
}
286+
toast.success("安装密钥已复制。");
287+
} catch {
288+
toast.error("复制失败,请手动选中密钥复制。");
289+
}
290+
}, [dom, revealed.key, toast]);
291+
292+
return (
293+
<WebuiDialog
294+
id={`browser-installation-key-${revealed.surface}`}
295+
open
296+
title={`${revealed.title} 安装密钥`}
297+
onClose={onClose}
298+
width="compact"
299+
portalTarget={dom.portalTarget()}
300+
testId="browser-installation-key-dialog"
301+
>
302+
<WebuiStack gap="medium">
303+
<div className="flex flex-col gap-1">
304+
<span className="text-[13px] text-text_default_secondary">指纹</span>
231305
<WebuiTextInput
232306
appearance="bare"
233307
controlSize="dense"
234308
textSize="small"
235309
textTone="secondary"
236-
className="w-auto font-mono"
310+
className="w-full font-mono"
311+
readOnly
312+
data-testid="browser-installation-key-fingerprint"
313+
value={revealed.fingerprint}
314+
aria-label={`${revealed.surface} installation key fingerprint`}
315+
/>
316+
</div>
317+
<div className="flex flex-col gap-1">
318+
<span className="text-[13px] text-text_default_secondary">安装密钥</span>
319+
<WebuiTextInput
320+
appearance="bare"
321+
controlSize="dense"
322+
textSize="small"
323+
textTone="secondary"
324+
className="w-full font-mono"
237325
readOnly
238326
data-testid="browser-installation-key-value"
239327
value={revealed.key}
240328
aria-label={`${revealed.surface} installation key`}
241329
/>
242-
</SettingsRow>
243-
) : null}
244-
</SettingsSection>
330+
<WebuiButton
331+
variant="secondary"
332+
size="compact"
333+
className="w-fit"
334+
data-testid="browser-installation-key-copy"
335+
onClick={() => {
336+
void copy();
337+
}}
338+
>
339+
复制密钥
340+
</WebuiButton>
341+
</div>
342+
{revealed.optionsUrl ? (
343+
<WebuiButtonLink
344+
href={revealed.optionsUrl}
345+
target="_blank"
346+
rel="noreferrer"
347+
variant="quiet-secondary"
348+
size="inline"
349+
className="w-fit"
350+
data-testid="browser-installation-key-options-link"
351+
>
352+
打开扩展选项页
353+
</WebuiButtonLink>
354+
) : (
355+
<p className="text-[13px] text-text_default_secondary">
356+
这个扩展还没有连接过这台主机,主机不知道它的选项页地址。请在浏览器的扩展管理页里找到「MiniMax Code Browser
357+
Bridge」,点「扩展选项」后粘贴密钥。
358+
</p>
359+
)}
360+
</WebuiStack>
361+
</WebuiDialog>
245362
);
246363
}

‎packages/webui/src/client/features/browser/ui/BrowserSettingsPage.public.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ function BrowserExtensionPackage({
112112
download
113113
target="_blank"
114114
rel="noreferrer"
115-
variant="quiet-secondary"
116-
size="inline"
115+
variant="secondary"
116+
size="compact"
117117
data-testid="browser-extension-package-download"
118118
>
119119
下载最新扩展包

‎packages/webui/src/server/browser-bridge/server.ts‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ import {
4444
type WebuiBrowserTargetState,
4545
type WebuiBridgeBrowserSurface,
4646
} from "../../shared/contracts/browser.js";
47-
import type {
48-
WebuiBrowserBridgeImageFrame,
49-
WebuiBrowserBridgeInstallationKeyView,
50-
WebuiBrowserBridgeTarget,
47+
import {
48+
webuiBrowserBridgeExtensionOptionsUrl,
49+
type WebuiBrowserBridgeImageFrame,
50+
type WebuiBrowserBridgeInstallationKeyView,
51+
type WebuiBrowserBridgeTarget,
5152
} from "../../shared/contracts/browser-bridge.js";
5253
import {
5354
createPlatformSecureCredentialStore,
@@ -254,6 +255,14 @@ interface BridgeSurfaceState {
254255
* happen to be in.
255256
*/
256257
surface: WebuiBridgeBrowserSurface | undefined;
258+
/**
259+
* The identity the peer connected with, when it has one.
260+
*
261+
* It is the extension's own id — the one its origin carries — and it is what
262+
* lets the Settings page name the extension's own page, where the
263+
* installation key is pasted.
264+
*/
265+
extensionId: string | undefined;
257266
readonly session: BrowserBridgeSession;
258267
/** The instance identity, from the extension's `hello` frame. */
259268
browserId: string;
@@ -490,7 +499,38 @@ async readInstallationKey(
490499
): Promise<WebuiBrowserBridgeInstallationKeyView | undefined> {
491500
const pairing = this.pairings.get(surface);
492501
if (!pairing) return undefined;
493-
return pairing.installationKeyForDisplay();
502+
const view = await pairing.installationKeyForDisplay();
503+
if (!view) return undefined;
504+
const page = this.#extensionPage(surface);
505+
return page
506+
? { ...view, extensionId: page.extensionId, optionsUrl: page.optionsUrl }
507+
: view;
508+
}
509+
510+
/**
511+
* The extension's own page for one surface, when this host knows it.
512+
*
513+
* The live session's identity comes first: it is the extension the person
514+
* is working with right now. The identities this host has proved come
515+
* next, and the ones an operator configured last — the last two only when
516+
* there is exactly one of them, because a page named for the wrong
517+
* extension is worse than no page at all.
518+
*/
519+
#extensionPage(
520+
surface: WebuiBridgeBrowserSurface,
521+
): { readonly extensionId: string; readonly optionsUrl: string } | undefined {
522+
const live = this.#surfaces.get(surface)?.extensionId;
523+
const trusted = [...this.#trusted.ids(surface)];
524+
const configured = [...(this.#allowedIds.get(surface) ?? [])];
525+
const extensionId =
526+
live ??
527+
(trusted.length === 1 ? trusted[0] : undefined) ??
528+
(configured.length === 1 ? configured[0] : undefined);
529+
if (!extensionId) return undefined;
530+
return {
531+
extensionId,
532+
optionsUrl: webuiBrowserBridgeExtensionOptionsUrl(extensionId),
533+
};
494534
}
495535

496536
/**
@@ -586,6 +626,7 @@ async readInstallationKey(
586626
});
587627
const state: BridgeSurfaceState = {
588628
surface: undefined,
629+
extensionId: undefined,
589630
session,
590631
browserId: "",
591632
live: false,
@@ -632,6 +673,9 @@ async readInstallationKey(
632673
return;
633674
}
634675
state.surface = surface;
676+
// The identity the peer connected with. It is what lets the Settings
677+
// page link to the extension's own page, where the key is pasted.
678+
state.extensionId = extensionId;
635679
this.#surfaces.set(surface, state);
636680
state.live = true;
637681
// The peer is admitted, so it proved the installation key or a

‎packages/webui/src/server/operation/handlers/browser.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ export function createBrowserHandlerEntries(
245245
keyId: view.keyId,
246246
key: view.key,
247247
fingerprint: view.fingerprint,
248+
// The extension the key belongs to, and the page it is pasted
249+
// on. The person does not have to match the two by hand.
250+
...(view.extensionId ? { extensionId: view.extensionId } : {}),
251+
...(view.optionsUrl ? { optionsUrl: view.optionsUrl } : {}),
248252
}
249253
: {
250254
present: false as const,

0 commit comments

Comments
 (0)