authorclover caruso <git@paperclover.net> 2026-10-13 10:14:41-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log96a2bb7293207bd038d5eb5754b664d9b1c58e5c
tree4c5bc27786db32385249bc98f5cc79282ea54a53
parent4b5a0105eece1c3aa7f02739122464260a9c5def
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

fix: use a normal user agent in the browser

Clover wants a normal bundled Chrome identity in desktop profiles, private tabs, sign-in popups, and server contexts. Hold initial requests until identity is ready, normalize user agents and client hints, and preserve cookies and popup relationships. Keep native popup initialization independent of guest background paint and the guest viewport above the narrow sidebar. Clover wants audio that continues in hidden browser tabs to remain obvious. Show a prominent theme-colored red warning above the chat composer for every audible tab, including tabs belonging to another chat. Open the source chat and browser from the warning, mute through the existing desktop bridge, and clear the warning when playback stops, muting succeeds, or the guest disconnects. Assisted-by: gpt-6.1-sol

13 files changed, 744 insertions(+), 92 deletions(-)

apps/desktop/src/preview/BrowserSession.test.ts+10-10
......@@ -122,12 +122,7 @@ describe("BrowserSession", () => {
122122 }).pipe(Effect.provide(layer)),
123123 );
124124
125 // A rewritten session UA — any variant, even ones that keep the Electron
126 // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so
127 // the guest must end up with Electron's native User-Agent. The mock applies
128 // setUserAgent calls, so this fails on any reintroduced rewrite while still
129 // permitting a harmless re-set of the unchanged native string.
130 it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () =>
125 it.effect("presents persistent preview sessions as the bundled Chrome", () =>
131126 Effect.gen(function* () {
132127 // Electron's real UA shape: app token, then Chrome, then Electron, then
133128 // Safari — the token order and casing matter to any strip regex.
......@@ -151,12 +146,17 @@ describe("BrowserSession", () => {
151146 });
152147
153148 const browserSessions = yield* BrowserSession.BrowserSession;
154 const partition = yield* browserSessions.getPartition("scope-a");
155149 yield* browserSessions.getSession("scope-a");
150 yield* browserSessions.getSession("private", false);
151 yield* browserSessions.getSession("work", true, "profile");
156152
157 const browserSession = sessions.get(partition);
158 assert.isDefined(browserSession);
159 assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent);
153 assert.strictEqual(sessions.size, 3);
154 for (const browserSession of sessions.values()) {
155 assert.strictEqual(
156 browserSession.getUserAgent(),
157 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
158 );
159 }
160160 }).pipe(Effect.provide(layer)),
161161 );
162162
apps/desktop/src/preview/BrowserSession.ts+7-6
......@@ -9,6 +9,9 @@ import * as PlatformError from "effect/PlatformError";
99import * as Schema from "effect/Schema";
1010import * as SynchronizedRef from "effect/SynchronizedRef";
1111
12import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14
1215import * as ElectronDialog from "../electron/ElectronDialog.ts";
1316
1417const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
......@@ -214,6 +217,8 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined
214217/** @public Service construction is part of the canonical Effect module API. */
215218export const make = Effect.gen(function* BrowserSessionMake() {
216219 const crypto = yield* Crypto.Crypto;
220 const platform = yield* HostProcess.Platform;
221 const arch = yield* HostProcess.Architecture;
217222 const electronDialog = yield* ElectronDialog.ElectronDialog;
218223 const runFork = Effect.runForkWith(yield* Effect.context<never>());
219224 // One prompt at a time, so a page cannot stack dialogs by looping a deep link.
......@@ -297,12 +302,8 @@ export const make = Effect.gen(function* BrowserSessionMake() {
297302 return Effect.try({
298303 try: () => {
299304 const browserSession = session.fromPartition(partition);
300 // The guest keeps Electron's native User-Agent. Rewriting it in any
301 // form — even variants that keep the Electron token — makes Cloudflare
302 // Turnstile fail its integrity check with error 600010 and recreate
303 // the challenge every few seconds, so logins behind it never complete
304 // (#5002). Re-setting the unchanged native string is harmless, so it
305 // is the rewritten string itself that trips the check.
305 const identity = browserChromeIdentity(browserSession.getUserAgent(), { platform, arch });
306 if (identity) browserSession.setUserAgent(identity.userAgent);
306307 browserSession.setPermissionRequestHandler(
307308 (webContents, permission, callback, details) => {
308309 if (permission === "openExternal") {
apps/desktop/src/preview/Manager.test.ts+291-1
......@@ -226,7 +226,11 @@ const {
226226 fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null),
227227 getFocusedWebContents: vi.fn(() => null),
228228 mkdir: vi.fn((_path: string) => undefined),
229 previewSession: { on: vi.fn() },
229 previewSession: {
230 on: vi.fn(),
231 getUserAgent: vi.fn(() => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36"),
232 webRequest: { onBeforeSendHeaders: vi.fn() },
233 },
230234 showItemInFolder: vi.fn(),
231235 webviewSend: vi.fn(),
232236 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),
......@@ -381,6 +385,7 @@ const makeTestPreviewWebContents = (
381385 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
382386 isDestroyed: () => false,
383387 getType: () => "webview",
388 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
384389 getURL: () => "https://example.com",
385390 getTitle: () => "Example",
386391 isLoadingMainFrame: () => false,
......@@ -486,6 +491,7 @@ const makeFaviconWebContents = (options?: {
486491 id: options?.id ?? 42,
487492 isDestroyed: () => destroyed,
488493 getType: () => "webview",
494 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
489495 getURL: () => currentUrl,
490496 getTitle: () => "Preview",
491497 isLoadingMainFrame: () => loading,
......@@ -500,6 +506,9 @@ const makeFaviconWebContents = (options?: {
500506 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
501507 listeners.set(event, listener);
502508 }),
509 once: vi.fn((event: string, listener: (...args: never[]) => void) => {
510 listeners.set(event, listener);
511 }),
503512 off,
504513 ipc: { on: vi.fn(), off: vi.fn() },
505514 send: webviewSend,
......@@ -592,6 +601,7 @@ describe("PreviewManager", () => {
592601 getFocusedWebContents.mockReturnValue(null);
593602 mkdir.mockClear();
594603 previewSession.on.mockClear();
604 previewSession.webRequest.onBeforeSendHeaders.mockClear();
595605 writeFile.mockClear();
596606 showItemInFolder.mockClear();
597607 clipboardItemConstructor.mockClear();
......@@ -795,6 +805,253 @@ describe("PreviewManager", () => {
795805 ),
796806 );
797807
808 effectIt.effect.each([
809 ["default", true, undefined],
810 ["work", true, "profile"],
811 ["incognito", false, "profile"],
812 ] as const)(
813 "holds the first %s request until its Chrome identity is ready",
814 ([profile, persistent, namespace]) =>
815 withManager((manager) =>
816 Effect.gen(function* () {
817 yield* manager.getBrowserSession(profile, persistent, namespace);
818 yield* manager.getBrowserSession(profile, persistent, namespace);
819 expect(previewSession.webRequest.onBeforeSendHeaders).toHaveBeenCalledTimes(1);
820 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
821 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
822 callback: (response: Electron.BeforeSendResponse) => void,
823 ) => void;
824 for (const kind of ["webview", "window"]) {
825 const preview = makeFaviconWebContents({ id: kind === "webview" ? 42 : 43 });
826 Object.assign(preview.webContents, { getType: () => kind });
827 const overrideStarted = Promise.withResolvers<void>();
828 const overrideReady = Promise.withResolvers<void>();
829 const request = Promise.withResolvers<Electron.BeforeSendResponse>();
830 const send = vi.fn(request.resolve);
831 const headers = {
832 "User-Agent": "Electron/44.4.5",
833 Accept: "text/html",
834 Referer: "https://app.example/signin",
835 "Sec-CH-UA-Full-Version-List": '"Electron";v="44.4.5"',
836 };
837 const command = (preview.webContents as Electron.WebContents).debugger
838 .sendCommand as ReturnType<typeof vi.fn>;
839 command.mockImplementation(async (method: string) => {
840 if (method === "Emulation.setUserAgentOverride") {
841 overrideStarted.resolve();
842 await overrideReady.promise;
843 }
844 });
845 intercept(
846 {
847 url: "http://127.0.0.1:3001/first",
848 resourceType: "mainFrame",
849 webContents: preview.webContents,
850 requestHeaders: headers,
851 },
852 send,
853 );
854 yield* Effect.promise(() => overrideStarted.promise);
855 expect(send).not.toHaveBeenCalled();
856 overrideReady.resolve();
857 const first = yield* Effect.promise(() => request.promise);
858 expect(send).toHaveBeenCalledTimes(1);
859 const override = command.mock.calls.find(
860 ([method]) => method === "Emulation.setUserAgentOverride",
861 )![1];
862 expect(first.cancel).not.toBe(true);
863 expect(first.requestHeaders).toEqual(
864 expect.objectContaining({
865 "User-Agent": override.userAgent,
866 "sec-ch-ua": expect.stringContaining('"Google Chrome";v="148"'),
867 "sec-ch-ua-mobile": "?0",
868 "sec-ch-ua-platform": '"macOS"',
869 Accept: headers.Accept,
870 Referer: headers.Referer,
871 "Sec-CH-UA-Full-Version-List": override.userAgentMetadata.fullVersionList
872 .map(
873 ({ brand, version }: { brand: string; version: string }) =>
874 `${JSON.stringify(brand)};v=${JSON.stringify(version)}`,
875 )
876 .join(", "),
877 }),
878 );
879 expect(headers["User-Agent"]).toBe("Electron/44.4.5");
880 yield* manager.prepareWebview(preview.webContents);
881 }
882 }),
883 ),
884 );
885
886 effectIt.effect(
887 "releases a native popup's first request without waiting for its document background",
888 () =>
889 withManager((manager) =>
890 Effect.gen(function* () {
891 yield* manager.getBrowserSession("default");
892 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
893 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
894 callback: (response: Electron.BeforeSendResponse) => void,
895 ) => void;
896 const opener = makeFaviconWebContents();
897 fromId.mockReturnValue(opener.webContents);
898 yield* manager.createTab("popup_opener");
899 yield* manager.registerWebview("popup_opener", 42);
900 yield* manager.prepareWebview(opener.webContents);
901 const popup = makeFaviconWebContents({ id: 43 });
902 Object.assign(popup.webContents, { getType: () => "window" });
903 const identityStarted = Promise.withResolvers<void>();
904 const identityReady = Promise.withResolvers<void>();
905 const request = Promise.withResolvers<Electron.BeforeSendResponse>();
906 const send = vi.fn(request.resolve);
907 const command = (popup.webContents as Electron.WebContents).debugger
908 .sendCommand as ReturnType<typeof vi.fn>;
909 command.mockImplementation(async (method: string) => {
910 if (method === "Emulation.setDefaultBackgroundColorOverride") await request.promise;
911 if (method === "Emulation.setUserAgentOverride") {
912 identityStarted.resolve();
913 await identityReady.promise;
914 }
915 });
916 opener.listeners.get("did-create-window")!({ webContents: popup.webContents } as never);
917 intercept(
918 {
919 url: "http://127.0.0.1:3001/popup",
920 resourceType: "mainFrame",
921 webContents: popup.webContents,
922 requestHeaders: { Referer: "https://app.example/signin" },
923 },
924 send,
925 );
926 yield* Effect.promise(() => identityStarted.promise);
927 expect(send).not.toHaveBeenCalled();
928 identityReady.resolve();
929 const released = yield* Effect.promise(() => request.promise);
930 expect(send).toHaveBeenCalledTimes(1);
931 expect(command).not.toHaveBeenCalledWith(
932 "Emulation.setDefaultBackgroundColorOverride",
933 expect.anything(),
934 );
935 expect(released.requestHeaders).toEqual(
936 expect.objectContaining({
937 "User-Agent": "Mozilla/5.0 Chrome/148.0.0.0 Safari/537.36",
938 "sec-ch-ua": expect.stringContaining('"Google Chrome";v="148"'),
939 Referer: "https://app.example/signin",
940 }),
941 );
942 }),
943 ),
944 );
945
946 effectIt.effect("only supplies low entropy Chrome hints on trustworthy destinations", () =>
947 withManager((manager) =>
948 Effect.gen(function* () {
949 yield* manager.getBrowserSession("default");
950 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
951 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
952 callback: (response: Electron.BeforeSendResponse) => void,
953 ) => void;
954 for (const [url, trusted] of [
955 ["https://example.com", true],
956 ["http://localhost:3001", true],
957 ["http://app.localhost:3001", true],
958 ["http://[::1]:3001", true],
959 ["http://127.0.0.42:3001", true],
960 ["http://100.65.180.100:3001", false],
961 ["http://example.com", false],
962 ] as const) {
963 const responseReady = Promise.withResolvers<Electron.BeforeSendResponse>();
964 intercept({ url, requestHeaders: { Accept: "text/html" } }, responseReady.resolve);
965 const response = yield* Effect.promise(() => responseReady.promise);
966 expect(response.requestHeaders?.["sec-ch-ua"]).toBe(
967 trusted
968 ? '"Chromium";v="148", "Google Chrome";v="148", "Not A(Brand";v="99"'
969 : undefined,
970 );
971 expect(response.requestHeaders?.["sec-ch-ua-full-version-list"]).toBeUndefined();
972 }
973 }),
974 ),
975 );
976
977 effectIt.effect(
978 "keeps browsing while DevTools owns the debugger and restores identity on close",
979 () =>
980 withManager((manager) =>
981 Effect.gen(function* () {
982 yield* manager.getBrowserSession("default");
983 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
984 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
985 callback: (response: Electron.BeforeSendResponse) => void,
986 ) => void;
987 const preview = makeFaviconWebContents();
988 let devToolsOpened = false;
989 Object.assign(preview.webContents, {
990 isDevToolsOpened: () => devToolsOpened,
991 openDevTools: () => {
992 devToolsOpened = true;
993 },
994 });
995 fromId.mockReturnValue(preview.webContents);
996 yield* manager.createTab("tab_devtools");
997 yield* manager.registerWebview("tab_devtools", 42);
998 yield* manager.prepareWebview(preview.webContents);
999 const command = (preview.webContents as Electron.WebContents).debugger
1000 .sendCommand as ReturnType<typeof vi.fn>;
1001 expect(
1002 command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"),
1003 ).toHaveLength(1);
1004 yield* manager.openDevTools("tab_devtools");
1005 const request = Promise.withResolvers<Electron.BeforeSendResponse>();
1006 intercept(
1007 {
1008 url: "https://example.com",
1009 resourceType: "mainFrame",
1010 webContents: preview.webContents,
1011 requestHeaders: {},
1012 },
1013 request.resolve,
1014 );
1015 expect((yield* Effect.promise(() => request.promise)).cancel).not.toBe(true);
1016 expect(
1017 command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"),
1018 ).toHaveLength(1);
1019 const restored = Promise.withResolvers<void>();
1020 command.mockImplementation(async (method: string) => {
1021 if (method === "Emulation.setUserAgentOverride") restored.resolve();
1022 });
1023 devToolsOpened = false;
1024 preview.listeners.get("devtools-closed")!();
1025 yield* Effect.promise(() => restored.promise);
1026 expect(
1027 command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"),
1028 ).toHaveLength(2);
1029 }),
1030 ),
1031 );
1032
1033 effectIt.effect("cancels a first navigation when Chrome identity preparation fails", () =>
1034 withManager((manager) =>
1035 Effect.gen(function* () {
1036 yield* manager.getBrowserSession("default");
1037 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
1038 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
1039 callback: (response: Electron.BeforeSendResponse) => void,
1040 ) => void;
1041 const preview = makeFaviconWebContents();
1042 const command = (preview.webContents as Electron.WebContents).debugger
1043 .sendCommand as ReturnType<typeof vi.fn>;
1044 command.mockRejectedValue(new Error("identity override failed"));
1045 const responseReady = Promise.withResolvers<Electron.BeforeSendResponse>();
1046 intercept(
1047 { url: "https://example.com", webContents: preview.webContents, requestHeaders: {} },
1048 responseReady.resolve,
1049 );
1050 expect(yield* Effect.promise(() => responseReady.promise)).toEqual({ cancel: true });
1051 }),
1052 ),
1053 );
1054
7981055 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>
7991056 withManager((manager) =>
8001057 Effect.gen(function* () {
......@@ -804,7 +1061,23 @@ describe("PreviewManager", () => {
8041061 yield* manager.registerWebview("tab_editing", 42);
8051062
8061063 const popup = makeFaviconWebContents({ id: 43 });
1064 const popupReady = Promise.withResolvers<void>();
1065 const popupCommands = (popup.webContents as Electron.WebContents).debugger
1066 .sendCommand as ReturnType<typeof vi.fn>;
1067 popupCommands.mockImplementation(async (method: string) => {
1068 if (method === "Emulation.setUserAgentOverride") popupReady.resolve();
1069 });
8071070 preview.listeners.get("did-create-window")!({ webContents: popup.webContents } as never);
1071 yield* Effect.promise(() => popupReady.promise);
1072 expect(popupCommands).toHaveBeenCalledWith(
1073 "Emulation.setUserAgentOverride",
1074 expect.objectContaining({
1075 userAgent: "Mozilla/5.0 Chrome/148.0.0.0 Safari/537.36",
1076 userAgentMetadata: expect.objectContaining({
1077 brands: expect.arrayContaining([{ brand: "Google Chrome", version: "148" }]),
1078 }),
1079 }),
1080 );
8081081 expect(
8091082 (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts,
8101083 ).toHaveBeenCalledWith(true);
......@@ -945,6 +1218,7 @@ describe("PreviewManager", () => {
9451218 id: 42,
9461219 isDestroyed: () => false,
9471220 getType: () => "webview",
1221 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
9481222 getURL: () => "about:blank",
9491223 getTitle: () => "",
9501224 isLoadingMainFrame: () => false,
......@@ -1038,6 +1312,7 @@ describe("PreviewManager", () => {
10381312 id: 42,
10391313 isDestroyed: () => destroyed,
10401314 getType: () => "webview",
1315 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
10411316 getURL: () => "http://localhost:3200/",
10421317 getTitle: () => "Preview",
10431318 isLoadingMainFrame: () => false,
......@@ -1541,6 +1816,7 @@ describe("PreviewManager", () => {
15411816 id: 42,
15421817 isDestroyed: () => false,
15431818 getType: () => "webview",
1819 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
15441820 getURL: () => url,
15451821 getTitle: () => "Example",
15461822 isLoadingMainFrame: () => false,
......@@ -1611,6 +1887,7 @@ describe("PreviewManager", () => {
16111887 id: 43,
16121888 isDestroyed: () => false,
16131889 getType: () => "webview",
1890 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
16141891 getURL: () => url,
16151892 getTitle: () => "Example",
16161893 isLoadingMainFrame: () => false,
......@@ -1652,6 +1929,7 @@ describe("PreviewManager", () => {
16521929 id: 42,
16531930 isDestroyed: () => false,
16541931 getType: () => "webview",
1932 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
16551933 getURL: () => "https://example.com",
16561934 getTitle: () => "Example",
16571935 isLoadingMainFrame: () => false,
......@@ -1699,6 +1977,7 @@ describe("PreviewManager", () => {
16991977 id: 42,
17001978 isDestroyed: () => false,
17011979 getType: () => "webview",
1980 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
17021981 getURL: () => "https://example.com",
17031982 getTitle: () => "Example",
17041983 isLoadingMainFrame: () => false,
......@@ -1755,6 +2034,7 @@ describe("PreviewManager", () => {
17552034 isDestroyed: () => false,
17562035 isDevToolsOpened: () => false,
17572036 getType: () => "webview",
2037 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
17582038 getURL: () => "https://example.com",
17592039 getTitle: () => "Example",
17602040 isLoadingMainFrame: () => false,
......@@ -1850,6 +2130,7 @@ describe("PreviewManager", () => {
18502130 return false;
18512131 },
18522132 getType: () => "webview",
2133 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
18532134 getURL: () => "http://localhost:5173/README.md",
18542135 getTitle: () => "README.md",
18552136 isLoadingMainFrame: () => true,
......@@ -1975,6 +2256,7 @@ describe("PreviewManager", () => {
19752256 isDestroyed: () => false,
19762257 isDevToolsOpened: () => false,
19772258 getType: () => "webview",
2259 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
19782260 getURL: () => "https://example.com",
19792261 getTitle: () => "Example",
19802262 isLoadingMainFrame: () => false,
......@@ -2318,6 +2600,7 @@ describe("PreviewManager", () => {
23182600 id: 42,
23192601 isDestroyed: () => false,
23202602 getType: () => "webview",
2603 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
23212604 getURL: () => url,
23222605 getTitle: () => "localhost:5733",
23232606 isLoadingMainFrame: () => loading,
......@@ -2433,6 +2716,7 @@ describe("PreviewManager", () => {
24332716 id: 42,
24342717 isDestroyed: () => false,
24352718 getType: () => "webview",
2719 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
24362720 getURL: () => "https://example.com:8443/path?query=value",
24372721 getTitle: () => "Example",
24382722 isLoadingMainFrame: () => false,
......@@ -3001,6 +3285,7 @@ describe("PreviewManager", () => {
30013285 ),
30023286 isDestroyed: () => false,
30033287 getType: () => "webview",
3288 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
30043289 getURL: () => `https://example.com/${id}`,
30053290 getTitle: () => `Example ${id}`,
30063291 isLoadingMainFrame: () => false,
......@@ -3463,6 +3748,7 @@ describe("PreviewManager", () => {
34633748 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
34643749 isDestroyed: () => false,
34653750 getType: () => "webview",
3751 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
34663752 getURL: () => "https://example.com",
34673753 getTitle: () => "Example",
34683754 isLoadingMainFrame: () => false,
......@@ -3962,6 +4248,7 @@ describe("PreviewManager", () => {
39624248 id: 42,
39634249 isDestroyed: () => false,
39644250 getType: () => "webview",
4251 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
39654252 getURL: () => "https://example.com",
39664253 getTitle: () => "Example",
39674254 isLoadingMainFrame: () => false,
......@@ -4108,6 +4395,7 @@ describe("PreviewManager", () => {
41084395 id: 42,
41094396 isDestroyed: () => false,
41104397 getType: () => "webview",
4398 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
41114399 getURL: () => "https://example.com",
41124400 getTitle: () => "Example",
41134401 isLoadingMainFrame: () => false,
......@@ -4250,6 +4538,7 @@ describe("PreviewManager", () => {
42504538 id: 42,
42514539 isDestroyed: () => false,
42524540 getType: () => "webview",
4541 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
42534542 getURL: () => "https://example.com",
42544543 getTitle: () => "Example",
42554544 isLoadingMainFrame: () => false,
......@@ -4335,6 +4624,7 @@ describe("PreviewManager", () => {
43354624 id: 42,
43364625 isDestroyed: () => false,
43374626 getType: () => "webview",
4627 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
43384628 getURL: () => "https://example.com",
43394629 getTitle: () => "Example",
43404630 isLoadingMainFrame: () => false,
apps/desktop/src/preview/Manager.ts+107-24
......@@ -27,6 +27,7 @@ import type {
2727} from "@t3tools/contracts";
2828import * as HostProcess from "@t3tools/shared/HostProcess";
2929import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
30import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification";
3031import { normalizePreviewUrl } from "@t3tools/shared/preview";
3132import {
3233 BrowserWindow,
......@@ -38,6 +39,7 @@ import {
3839 shell,
3940 webContents,
4041} from "electron";
42import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
4143import * as Cause from "effect/Cause";
4244import * as Clock from "effect/Clock";
4345import * as Context from "effect/Context";
......@@ -545,6 +547,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
545547 const fileSystem = yield* FileSystem.FileSystem;
546548 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
547549 const hostPlatform = yield* HostProcess.Platform;
550 const hostArchitecture = yield* HostProcess.Architecture;
548551 const path = yield* Path.Path;
549552 const crypto = yield* Crypto.Crypto;
550553 const parentScope = yield* Scope.Scope;
......@@ -1186,21 +1189,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11861189 wcDebugger.on("message", onMessage);
11871190 wcDebugger.attach("1.3");
11881191 });
1189 // Electron gives `<webview>` guests a transparent base background, and
1190 // Chromium only paints a dark canvas for dark color-scheme pages over an
1191 // opaque base. Without this, dark-scheme pages with no background of
1192 // their own (text/plain, e.g. .md files) render white text on white.
1193 // White matches the webview's existing white backing, so light pages look
1194 // the same; Chromium still swaps in its dark canvas for dark-scheme pages.
1195 // Sent first because a document that paints before it arrives keeps the
1196 // transparent base until its next load.
1197 yield* attemptPromise(
1198 { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },
1199 () =>
1200 wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", {
1201 color: { r: 255, g: 255, b: 255, a: 1 },
1202 }),
1203 );
1192 if (wc.getType() === "webview") {
1193 // Webview guests need an opaque base for Chromium's dark document canvas.
1194 yield* attemptPromise(
1195 { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },
1196 () =>
1197 wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", {
1198 color: { r: 255, g: 255, b: 255, a: 1 },
1199 }),
1200 );
1201 }
1202 const identity = browserChromeIdentity(wc.getUserAgent(), {
1203 platform: hostPlatform,
1204 arch: hostArchitecture,
1205 chromeVersion: process.versions.chrome,
1206 });
1207 if (identity) {
1208 yield* attemptPromise(
1209 { operation: "initializeDebugger.userAgent", webContentsId: wc.id },
1210 () => wcDebugger.sendCommand("Emulation.setUserAgentOverride", identity),
1211 );
1212 }
12041213 return [
12051214 control,
12061215 replaceMap(sessions, (copy) => {
......@@ -1523,6 +1532,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15231532 // otherwise spawn native windows without limit. Nothing in an OAuth flow
15241533 // opens a second popup, so the chain stops at the first one.
15251534 const windowCreated = (window: Electron.BrowserWindow): void => {
1535 const popup = window.webContents;
1536 popup.once("destroyed", () => runFork(detachControlSession(popup.id)));
1537 runFork(ensureControlSession(popup).pipe(Effect.ignoreCause({ log: true })));
15261538 window.webContents.setIgnoreMenuShortcuts(true);
15271539 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
15281540 window.webContents.on("before-input-event", (_event, input) => {
......@@ -3410,6 +3422,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
34103422 openDevTools,
34113423 pickElement,
34123424 prepareWebview,
3425 ensureControlSession,
34133426 reapplyZoom,
34143427 refresh,
34153428 registerWebview,
......@@ -3692,10 +3705,12 @@ export const make = Effect.gen(function* PreviewManagerMake() {
36923705 const browserSession = yield* BrowserSession.BrowserSession;
36933706 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
36943707 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
3695 const downloadSessions = new WeakSet<Electron.Session>();
3708 const preparedSessions = new WeakSet<Electron.Session>();
3709 const hostPlatform = yield* HostProcess.Platform;
3710 const hostArchitecture = yield* HostProcess.Architecture;
36963711 const fileSystem = yield* FileSystem.FileSystem;
36973712 const path = yield* Path.Path;
3698 const runForkDownload = Effect.runForkWith(yield* Effect.context<never>());
3713 const runFork = Effect.runForkWith(yield* Effect.context<never>());
36993714 /** Puts a copy in Downloads under a name that never replaces a file already there. */
37003715 const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* (
37013716 source: string,
......@@ -3715,11 +3730,78 @@ export const make = Effect.gen(function* PreviewManagerMake() {
37153730 return;
37163731 }
37173732 });
3718 // Server tabs save downloads where the server's engine reads them. Downloads
3719 // the person starts in a tab the server is not driving keep Electron's dialog.
3720 const placeServerDownloads = (session: Electron.Session) => {
3721 if (downloadSessions.has(session)) return;
3722 downloadSessions.add(session);
3733 const prepareBrowserSession = (session: Electron.Session) => {
3734 if (preparedSessions.has(session)) return;
3735 const identity = browserChromeIdentity(session.getUserAgent(), {
3736 platform: hostPlatform,
3737 arch: hostArchitecture,
3738 chromeVersion: process.versions.chrome,
3739 });
3740 if (identity) {
3741 const metadata = identity.userAgentMetadata;
3742 const formatBrands = (brands: typeof metadata.brands) =>
3743 brands
3744 .map(({ brand, version }) => `${JSON.stringify(brand)};v=${JSON.stringify(version)}`)
3745 .join(", ");
3746 const lowEntropyHints = {
3747 "sec-ch-ua": formatBrands(metadata.brands),
3748 "sec-ch-ua-mobile": metadata.mobile ? "?1" : "?0",
3749 "sec-ch-ua-platform": JSON.stringify(metadata.platform),
3750 };
3751 const hints: Record<string, string> = {
3752 ...lowEntropyHints,
3753 "sec-ch-ua-full-version": JSON.stringify(metadata.fullVersionList[0]?.version),
3754 "sec-ch-ua-full-version-list": formatBrands(metadata.fullVersionList),
3755 "sec-ch-ua-platform-version": JSON.stringify(metadata.platformVersion),
3756 "sec-ch-ua-arch": JSON.stringify(metadata.architecture),
3757 "sec-ch-ua-bitness": JSON.stringify(metadata.bitness),
3758 "sec-ch-ua-model": JSON.stringify(metadata.model),
3759 "sec-ch-ua-wow64": metadata.wow64 ? "?1" : "?0",
3760 };
3761 session.webRequest.onBeforeSendHeaders(
3762 { urls: ["http://*/*", "https://*/*"] },
3763 (details, callback) => {
3764 const contents = details.webContents;
3765 const prepare =
3766 contents && (contents.isDestroyed() || !contents.isDevToolsOpened())
3767 ? operations.ensureControlSession(contents).pipe(Effect.asVoid)
3768 : Effect.void;
3769 runFork(
3770 prepare.pipe(
3771 Effect.matchCause({
3772 onFailure: () => callback({ cancel: true }),
3773 onSuccess: () => {
3774 const url = new URL(details.url);
3775 const sendHints =
3776 url.protocol === "https:" ||
3777 isLocalLoopbackHost(url.hostname) ||
3778 normalizeHostname(url.hostname).endsWith(".localhost");
3779 // Chromium can snapshot these headers before the asynchronous CDP override completes.
3780 const headers = Object.fromEntries(
3781 Object.entries(details.requestHeaders)
3782 .filter(([name]) => name.toLowerCase() !== "user-agent")
3783 .map(([name, value]) => [
3784 name,
3785 sendHints ? (hints[name.toLowerCase()] ?? value) : value,
3786 ]),
3787 );
3788 headers["User-Agent"] = identity.userAgent;
3789 if (sendHints) {
3790 for (const [name, value] of Object.entries(lowEntropyHints)) {
3791 const existing = Object.keys(headers).find(
3792 (key) => key.toLowerCase() === name,
3793 );
3794 headers[existing ?? name] = value;
3795 }
3796 }
3797 callback({ requestHeaders: headers });
3798 },
3799 }),
3800 ),
3801 );
3802 },
3803 );
3804 }
37233805 session.on("will-download", (_event, item, source) => {
37243806 // Decided before the server's path is set: placing it consumes the guid.
37253807 const human = browserHost.humanStartedDownload(source);
......@@ -3727,7 +3809,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
37273809 // The server keeps its copy for agents; the person who clicked gets theirs.
37283810 item.once("done", (_doneEvent, state) => {
37293811 if (state !== "completed") return;
3730 runForkDownload(
3812 runFork(
37313813 copyToDownloads(item.getSavePath(), item.getFilename()).pipe(
37323814 Effect.catch((error) =>
37333815 Effect.logWarning("preview download copy failed", { cause: error }),
......@@ -3736,6 +3818,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
37363818 );
37373819 });
37383820 });
3821 preparedSessions.add(session);
37393822 };
37403823
37413824 const operations = yield* makeNativeOperations(
......@@ -3755,7 +3838,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
37553838 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
37563839 ),
37573840 );
3758 placeServerDownloads(session);
3841 prepareBrowserSession(session);
37593842 passkeys.installSessionHandlers(session);
37603843 return session;
37613844 },
apps/server/src/preview/ServerBrowserContexts.ts+2-44
......@@ -3,7 +3,7 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
33import { constVoid } from "effect/Function";
44import * as NodeFSP from "node:fs/promises";
55import * as NodeModule from "node:module";
6import * as NodeOS from "node:os";
6import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
77import * as NodePath from "node:path";
88import type { Browser, BrowserContext, CDPSession } from "playwright-core";
99
......@@ -24,48 +24,6 @@ interface Options {
2424 readonly onContextClose?: (context: BrowserContext) => void;
2525}
2626
27const UA_PLATFORMS: Partial<Record<NodeJS.Platform, string>> = {
28 darwin: "macOS",
29 linux: "Linux",
30 win32: "Windows",
31};
32
33/**
34 * The headless shell's identity as the Chrome it is built from: its user agent
35 * without `HeadlessChrome`, and client hints whose brands agree with it. Sites
36 * block a user agent that disagrees with `Sec-CH-UA`, so both change together.
37 */
38const chromeIdentity = (
39 headlessUserAgent: string,
40 host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string },
41) => {
42 const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent);
43 const fullVersion = version?.[1];
44 const major = version?.[2];
45 if (!version || !fullVersion || !major) return null;
46 // Chrome's GREASE brand and order for this major version, as the shell reports them.
47 const brands = (names: (version: string) => string) => [
48 { brand: "Chromium", version: names(major) },
49 { brand: "Google Chrome", version: names(major) },
50 { brand: "Not A(Brand", version: names("99") },
51 ];
52 return {
53 // Chrome reduces its user agent to the major version.
54 userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`),
55 userAgentMetadata: {
56 brands: brands((value) => value),
57 fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)),
58 platform: UA_PLATFORMS[host.platform] ?? "Unknown",
59 platformVersion: host.platform === "linux" ? host.release : "",
60 architecture: host.arch.startsWith("arm") ? "arm" : "x86",
61 bitness: host.arch.endsWith("64") ? "64" : "32",
62 model: "",
63 mobile: false,
64 wow64: false,
65 },
66 };
67};
68
6927/**
7028 * Presents a headless page as plain Chrome. Applies to the page, its frames,
7129 * and its workers; a popup gets its own call once it becomes a tab.
......@@ -75,7 +33,7 @@ export const presentAsChrome = async (
7533 host: { readonly platform: NodeJS.Platform; readonly arch: string },
7634) => {
7735 const { userAgent } = await cdp.send("Browser.getVersion");
78 const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() });
36 const identity = browserChromeIdentity(userAgent, host);
7937 if (identity) await cdp.send("Emulation.setUserAgentOverride", identity);
8038};
8139
apps/web/src/browser/BrowserSurfaceSlot.tsx+5-2
......@@ -34,6 +34,9 @@ export function BrowserSurfaceSlot(props: {
3434 const update = () => {
3535 const rect = element.getBoundingClientRect();
3636 const presentation = presentationRef.current;
37 const sheet = element.closest('[data-slot="sheet-viewport"]');
38 const sheetLayer = sheet ? Number(window.getComputedStyle(sheet).zIndex) : 0;
39 const zIndex = Math.max(presentation.zIndex, sheetLayer + 1);
3740 const presented = lease.present(
3841 {
3942 x: Math.round(rect.x),
......@@ -43,7 +46,7 @@ export function BrowserSurfaceSlot(props: {
4346 },
4447 presentation.visible && rect.width > 0 && rect.height > 0,
4548 presentation.cornerRadius,
46 presentation.zIndex,
49 zIndex,
4750 );
4851 if (presentation.visible && !presented) {
4952 lease.release();
......@@ -57,7 +60,7 @@ export function BrowserSurfaceSlot(props: {
5760 },
5861 rect.width > 0 && rect.height > 0,
5962 presentation.cornerRadius,
60 presentation.zIndex,
63 zIndex,
6164 );
6265 }
6366 };
apps/web/src/components/ChatView.tsx+2
......@@ -274,6 +274,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
274274import { addBrowserSurface } from "./preview/addBrowserSurface";
275275import { closePreviewSession } from "./preview/closePreviewSession";
276276import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
277import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator";
277278import { usePreviewSession } from "./preview/usePreviewSession";
278279import { subscribePreviewAction } from "./preview/previewActionBus";
279280import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
......@@ -11749,6 +11750,7 @@ export default function ChatView(props: ChatViewProps) {
1174911750 data-chat-composer-stack="true"
1175011751 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)"
1175111752 >
11753 <BrowserAudioIndicator />
1175211754 {isDraftHeroState ? (
1175311755 <div className="absolute inset-x-0 bottom-full">
1175411756 <div
apps/web/src/components/preview/BrowserAudioIndicator.test.tsx created+131
......@@ -0,0 +1,131 @@
1// @vitest-environment jsdom
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts";
4import { act } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const mocks = vi.hoisted(() => ({
9 navigate: vi.fn(async () => undefined),
10 mute: vi.fn(async (_tabId: string, _muted: boolean) => undefined),
11 open: vi.fn(),
12 toast: vi.fn(),
13}));
14vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mocks.navigate }));
15vi.mock("./previewBridge", () => ({ previewBridge: { setAudioMuted: mocks.mute } }));
16vi.mock("~/rightPanelStore", () => ({
17 useRightPanelStore: { getState: () => ({ openBrowser: mocks.open }) },
18}));
19vi.mock("~/components/ui/toast", () => ({ toastManager: { add: mocks.toast } }));
20
21import { BrowserAudioIndicator } from "./BrowserAudioIndicator";
22import { AppAtomRegistryProvider } from "~/rpc/atomRegistry";
23import {
24 applyPreviewDesktopState,
25 applyPreviewServerSnapshot,
26 readThreadPreviewState,
27 resetPreviewStateForTests,
28 type DesktopPreviewOverlay,
29} from "~/previewStateStore";
30import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
31
32const ref = scopeThreadRef(EnvironmentId.make("env-audio"), ThreadId.make("background-chat"));
33const snapshot: PreviewSessionSnapshot = {
34 threadId: ref.threadId,
35 tabId: "tab-audio",
36 navStatus: { _tag: "Success", url: "https://example.test/", title: "Music" },
37 canGoBack: false,
38 canGoForward: false,
39 updatedAt: "2026-10-14T22:00:00.000Z",
40};
41const overlay: DesktopPreviewOverlay = {
42 hasWebContents: true,
43 canGoBack: false,
44 canGoForward: false,
45 loading: false,
46 zoomFactor: 1,
47 pictureInPicture: false,
48 colorScheme: "system",
49 audioMuted: false,
50 audible: true,
51 controller: "none",
52 favicon: null,
53};
54let container: HTMLDivElement;
55let root: Root;
56
57beforeEach(async () => {
58 vi.clearAllMocks();
59 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
60 resetPreviewStateForTests();
61 container = document.createElement("div");
62 document.body.append(container);
63 root = createRoot(container);
64 await act(() =>
65 root.render(
66 <AppAtomRegistryProvider>
67 <BrowserAudioIndicator />
68 </AppAtomRegistryProvider>,
69 ),
70 );
71});
72afterEach(async () => {
73 await act(() => root.unmount());
74 container.remove();
75});
76
77async function startAudio() {
78 await act(() => {
79 applyPreviewServerSnapshot(ref, snapshot);
80 applyPreviewDesktopState(ref, snapshot.tabId, overlay);
81 });
82}
83
84it("shows background-chat audio and removes the warning when muted, stopped, or disconnected", async () => {
85 expect(container.textContent).toBe("");
86 await startAudio();
87 expect(container.querySelector('[role="status"]')?.textContent).toBe("Browser audio playing");
88 expect(container.textContent).toContain("Music");
89 for (const change of [{ audioMuted: true }, { audible: false }, { hasWebContents: false }]) {
90 await act(() => applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, ...change }));
91 expect(container.textContent).toBe("");
92 await act(() => applyPreviewDesktopState(ref, snapshot.tabId, overlay));
93 }
94});
95
96it("mutes the sounding tab using its scoped runtime identity", async () => {
97 await startAudio();
98 const runtimeTabId = previewRuntimeTabId(
99 ref,
100 readThreadPreviewState(ref).serverEpoch,
101 snapshot.tabId,
102 );
103 await act(async () =>
104 container.querySelector<HTMLButtonElement>('[aria-label="Mute Music"]')!.click(),
105 );
106 expect(mocks.mute).toHaveBeenCalledExactlyOnceWith(runtimeTabId, true);
107 await act(() => applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, audioMuted: true }));
108 expect(container.textContent).toBe("");
109});
110
111it("opens the source chat and browser tab", async () => {
112 await startAudio();
113 await act(async () =>
114 container.querySelector<HTMLButtonElement>('[aria-label="Open browser: Music"]')!.click(),
115 );
116 expect(mocks.navigate).toHaveBeenCalledOnce();
117 expect(mocks.open).toHaveBeenCalledExactlyOnceWith(ref, snapshot.tabId);
118});
119
120it("keeps the warning and enables retry after muting fails", async () => {
121 await startAudio();
122 mocks.mute.mockRejectedValueOnce(new Error("guest unavailable"));
123 const mute = container.querySelector<HTMLButtonElement>('[aria-label="Mute Music"]')!;
124 await act(async () => mute.click());
125 expect(mute.disabled).toBe(false);
126 expect(container.textContent).toContain("Browser audio playing");
127 expect(mocks.toast).toHaveBeenCalledExactlyOnceWith({
128 type: "error",
129 title: "Couldn’t mute browser audio",
130 });
131});
apps/web/src/components/preview/BrowserAudioIndicator.tsx created+94
......@@ -0,0 +1,94 @@
1import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
2import { useNavigate } from "@tanstack/react-router";
3import { Volume2Icon } from "lucide-react";
4import { useState } from "react";
5
6import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
7import { Button } from "~/components/ui/button";
8import { toastManager } from "~/components/ui/toast";
9import { useActivePreviewSessions } from "~/previewStateStore";
10import { useRightPanelStore } from "~/rightPanelStore";
11import { buildThreadRouteParams } from "~/threadRoutes";
12import { previewBridge } from "./previewBridge";
13
14/** Remains visible when a playing browser is behind another panel or chat. */
15export function BrowserAudioIndicator() {
16 const sessions = useActivePreviewSessions();
17 const navigate = useNavigate();
18 const [muting, setMuting] = useState<ReadonlySet<string>>(new Set());
19 const tabs = Object.entries(sessions).flatMap(([threadKey, state]) => {
20 const threadRef = parseScopedThreadKey(threadKey);
21 if (!threadRef) return [];
22 return Object.values(state.sessions).flatMap((snapshot) => {
23 const overlay = state.desktopByTabId[snapshot.tabId];
24 if (!overlay?.hasWebContents || !overlay.audible || overlay.audioMuted) return [];
25 const title =
26 snapshot.navStatus._tag === "Idle"
27 ? "Browser"
28 : snapshot.navStatus.title || snapshot.navStatus.url;
29 return [
30 {
31 threadRef,
32 tabId: snapshot.tabId,
33 runtimeTabId: previewRuntimeTabId(threadRef, state.serverEpoch, snapshot.tabId),
34 title,
35 },
36 ];
37 });
38 });
39
40 if (tabs.length === 0) return null;
41
42 return (
43 <section
44 aria-label="Browser audio"
45 className="mb-2 space-y-2 rounded-xl border border-destructive/50 bg-[color-mix(in_srgb,var(--destructive)_15%,var(--background))] p-3 text-destructive-foreground"
46 >
47 <div role="status" className="flex items-center gap-2 font-medium">
48 <Volume2Icon aria-hidden className="size-5 shrink-0 text-destructive" />
49 Browser audio playing
50 </div>
51 {tabs.map((tab) => (
52 <div key={tab.runtimeTabId} className="flex min-w-0 items-center gap-2">
53 <span className="min-w-0 flex-1 truncate text-sm">{tab.title}</span>
54 <Button
55 variant="outline"
56 size="sm"
57 aria-label={`Open browser: ${tab.title}`}
58 onClick={() => {
59 void navigate({
60 to: "/$environmentId/$threadId",
61 params: buildThreadRouteParams(tab.threadRef),
62 }).then(() => useRightPanelStore.getState().openBrowser(tab.threadRef, tab.tabId));
63 }}
64 >
65 Open browser
66 </Button>
67 <Button
68 variant="destructive"
69 size="sm"
70 aria-label={`Mute ${tab.title}`}
71 disabled={!previewBridge || muting.has(tab.runtimeTabId)}
72 onClick={async () => {
73 if (!previewBridge || muting.has(tab.runtimeTabId)) return;
74 setMuting((current) => new Set(current).add(tab.runtimeTabId));
75 try {
76 await previewBridge.setAudioMuted(tab.runtimeTabId, true);
77 } catch {
78 toastManager.add({ type: "error", title: "Couldn’t mute browser audio" });
79 } finally {
80 setMuting((current) => {
81 const next = new Set(current);
82 next.delete(tab.runtimeTabId);
83 return next;
84 });
85 }
86 }}
87 >
88 Mute
89 </Button>
90 </div>
91 ))}
92 </section>
93 );
94}
apps/web/src/previewStateStore.ts+3-5
......@@ -1,9 +1,8 @@
11/**
22 * Per-thread preview UI state.
33 *
4 * Each thread owns an independent atom. Most consumers read exactly one
5 * thread; the desktop browser host uses the aggregate session atom because it
6 * is the one place that must enumerate every live preview tab.
4 * Each thread owns an independent atom so ordinary readers avoid updates
5 * from unrelated tabs. Browser hosting and audio indicators share an aggregate.
76 */
87import { useAtomValue } from "@effect/atom-react";
98import { scopedThreadKey } from "@t3tools/client-runtime/environment";
......@@ -75,8 +74,7 @@ export const previewStateAtom = Atom.family((threadKey: string) =>
7574 ),
7675);
7776
78// Only the Electron browser host needs a cross-thread view. Keep that index
79// separate so thread-local readers never subscribe to unrelated previews.
77// Keep the global index separate so thread-local readers avoid unrelated previews.
8078interface ActivePreviewThreadIndex {
8179 readonly keys: ReadonlySet<string>;
8280}
packages/shared/package.json+4
......@@ -490,6 +490,10 @@
490490 "./PtyAdapter": {
491491 "types": "./src/PtyAdapter.ts",
492492 "import": "./src/PtyAdapter.ts"
493 },
494 "./browserChromeIdentity": {
495 "types": "./src/browserChromeIdentity.ts",
496 "import": "./src/browserChromeIdentity.ts"
493497 }
494498 },
495499 "scripts": {
packages/shared/src/browserChromeIdentity.test.ts created+43
......@@ -0,0 +1,43 @@
1import { describe, expect, it } from "vite-plus/test";
2import { browserChromeIdentity } from "./browserChromeIdentity.ts";
3
4const host = { platform: "darwin", arch: "arm64" } as const;
5
6describe("browserChromeIdentity", () => {
7 it("keeps HTTP and navigator identity consistent with current Chrome client hints", () => {
8 const identity = browserChromeIdentity(
9 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Preview)/0.0.45 Chrome/148.0.7734.19 Electron/42.0.0 Safari/537.36",
10 host,
11 );
12 expect(identity?.userAgent).toBe(
13 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
14 );
15 expect(identity?.userAgentMetadata).toMatchObject({
16 platform: "macOS",
17 architecture: "arm",
18 bitness: "64",
19 mobile: false,
20 brands: expect.arrayContaining([{ brand: "Google Chrome", version: "148" }]),
21 fullVersionList: expect.arrayContaining([
22 { brand: "Google Chrome", version: "148.0.7734.19" },
23 ]),
24 });
25 });
26
27 it("uses the same identity for headless pages and popup control sessions", () => {
28 const identity = browserChromeIdentity(
29 "Mozilla/5.0 HeadlessChrome/151.0.8000.20 Safari/537.36",
30 host,
31 );
32 expect(identity?.userAgent).toBe("Mozilla/5.0 Chrome/151.0.0.0 Safari/537.36");
33 const popup = browserChromeIdentity(identity!.userAgent, {
34 ...host,
35 chromeVersion: "151.0.8000.20",
36 });
37 expect(popup).toEqual(identity);
38 });
39
40 it("does not invent a Chrome version when Chromium reports none", () => {
41 expect(browserChromeIdentity("Electron", host)).toBeNull();
42 });
43});
packages/shared/src/browserChromeIdentity.ts created+45
......@@ -0,0 +1,45 @@
1const PLATFORMS: Partial<Record<NodeJS.Platform, string>> = {
2 darwin: "macOS",
3 linux: "Linux",
4 win32: "Windows",
5};
6
7/** HTTP, navigator and client hints describe the same bundled Chromium version. */
8export function browserChromeIdentity(
9 nativeUserAgent: string,
10 host: {
11 readonly platform: NodeJS.Platform;
12 readonly arch: string;
13 readonly chromeVersion?: string;
14 },
15) {
16 const version = /(?:Headless)?Chrome\/(\d[\d.]*)/.exec(nativeUserAgent);
17 const fullVersion = host.chromeVersion ?? version?.[1];
18 const major = fullVersion?.split(".")[0];
19 if (!version || !fullVersion || !major) return null;
20
21 const brands = [
22 { brand: "Chromium", version: major },
23 { brand: "Google Chrome", version: major },
24 { brand: "Not A(Brand", version: "99" },
25 ];
26 return {
27 userAgent: nativeUserAgent
28 .replace(/\s+(?:Electron|T3Code[^\s/]*)\/[^\s]+/gi, "")
29 .replace(version[0], `Chrome/${major}.0.0.0`),
30 userAgentMetadata: {
31 brands,
32 fullVersionList: brands.map(({ brand, version }) => ({
33 brand,
34 version: version === major ? fullVersion : `${version}.0.0.0`,
35 })),
36 platform: PLATFORMS[host.platform] ?? "Unknown",
37 platformVersion: "",
38 architecture: host.arch.startsWith("arm") ? "arm" : "x86",
39 bitness: host.arch.endsWith("64") ? "64" : "32",
40 model: "",
41 mobile: false,
42 wow64: false,
43 },
44 };
45}