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", () => {...@@ -122,12 +122,7 @@ describe("BrowserSession", () => {
122 }).pipe(Effect.provide(layer)),122 }).pipe(Effect.provide(layer)),
123 );123 );
124124
125 // A rewritten session UA — any variant, even ones that keep the Electron125 it.effect("presents persistent preview sessions as the bundled Chrome", () =>
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", () =>
131 Effect.gen(function* () {126 Effect.gen(function* () {
132 // Electron's real UA shape: app token, then Chrome, then Electron, then127 // Electron's real UA shape: app token, then Chrome, then Electron, then
133 // Safari — the token order and casing matter to any strip regex.128 // Safari — the token order and casing matter to any strip regex.
...@@ -151,12 +146,17 @@ describe("BrowserSession", () => {...@@ -151,12 +146,17 @@ describe("BrowserSession", () => {
151 });146 });
152147
153 const browserSessions = yield* BrowserSession.BrowserSession;148 const browserSessions = yield* BrowserSession.BrowserSession;
154 const partition = yield* browserSessions.getPartition("scope-a");
155 yield* browserSessions.getSession("scope-a");149 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);153 assert.strictEqual(sessions.size, 3);
158 assert.isDefined(browserSession);154 for (const browserSession of sessions.values()) {
159 assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent);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 }
160 }).pipe(Effect.provide(layer)),160 }).pipe(Effect.provide(layer)),
161 );161 );
162162
apps/desktop/src/preview/BrowserSession.ts+7-6
...@@ -9,6 +9,9 @@ import * as PlatformError from "effect/PlatformError";...@@ -9,6 +9,9 @@ import * as PlatformError from "effect/PlatformError";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import * as SynchronizedRef from "effect/SynchronizedRef";10import * as SynchronizedRef from "effect/SynchronizedRef";
1111
12import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14
12import * as ElectronDialog from "../electron/ElectronDialog.ts";15import * as ElectronDialog from "../electron/ElectronDialog.ts";
1316
14const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";17const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
...@@ -214,6 +217,8 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined...@@ -214,6 +217,8 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined
214/** @public Service construction is part of the canonical Effect module API. */217/** @public Service construction is part of the canonical Effect module API. */
215export const make = Effect.gen(function* BrowserSessionMake() {218export const make = Effect.gen(function* BrowserSessionMake() {
216 const crypto = yield* Crypto.Crypto;219 const crypto = yield* Crypto.Crypto;
220 const platform = yield* HostProcess.Platform;
221 const arch = yield* HostProcess.Architecture;
217 const electronDialog = yield* ElectronDialog.ElectronDialog;222 const electronDialog = yield* ElectronDialog.ElectronDialog;
218 const runFork = Effect.runForkWith(yield* Effect.context<never>());223 const runFork = Effect.runForkWith(yield* Effect.context<never>());
219 // One prompt at a time, so a page cannot stack dialogs by looping a deep link.224 // 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() {...@@ -297,12 +302,8 @@ export const make = Effect.gen(function* BrowserSessionMake() {
297 return Effect.try({302 return Effect.try({
298 try: () => {303 try: () => {
299 const browserSession = session.fromPartition(partition);304 const browserSession = session.fromPartition(partition);
300 // The guest keeps Electron's native User-Agent. Rewriting it in any305 const identity = browserChromeIdentity(browserSession.getUserAgent(), { platform, arch });
301 // form — even variants that keep the Electron token — makes Cloudflare306 if (identity) browserSession.setUserAgent(identity.userAgent);
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.
306 browserSession.setPermissionRequestHandler(307 browserSession.setPermissionRequestHandler(
307 (webContents, permission, callback, details) => {308 (webContents, permission, callback, details) => {
308 if (permission === "openExternal") {309 if (permission === "openExternal") {
apps/desktop/src/preview/Manager.test.ts+291-1
...@@ -226,7 +226,11 @@ const {...@@ -226,7 +226,11 @@ const {
226 fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null),226 fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null),
227 getFocusedWebContents: vi.fn(() => null),227 getFocusedWebContents: vi.fn(() => null),
228 mkdir: vi.fn((_path: string) => undefined),228 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 },
230 showItemInFolder: vi.fn(),234 showItemInFolder: vi.fn(),
231 webviewSend: vi.fn(),235 webviewSend: vi.fn(),
232 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),236 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),
...@@ -381,6 +385,7 @@ const makeTestPreviewWebContents = (...@@ -381,6 +385,7 @@ const makeTestPreviewWebContents = (
381 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),385 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
382 isDestroyed: () => false,386 isDestroyed: () => false,
383 getType: () => "webview",387 getType: () => "webview",
388 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
384 getURL: () => "https://example.com",389 getURL: () => "https://example.com",
385 getTitle: () => "Example",390 getTitle: () => "Example",
386 isLoadingMainFrame: () => false,391 isLoadingMainFrame: () => false,
...@@ -486,6 +491,7 @@ const makeFaviconWebContents = (options?: {...@@ -486,6 +491,7 @@ const makeFaviconWebContents = (options?: {
486 id: options?.id ?? 42,491 id: options?.id ?? 42,
487 isDestroyed: () => destroyed,492 isDestroyed: () => destroyed,
488 getType: () => "webview",493 getType: () => "webview",
494 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
489 getURL: () => currentUrl,495 getURL: () => currentUrl,
490 getTitle: () => "Preview",496 getTitle: () => "Preview",
491 isLoadingMainFrame: () => loading,497 isLoadingMainFrame: () => loading,
...@@ -500,6 +506,9 @@ const makeFaviconWebContents = (options?: {...@@ -500,6 +506,9 @@ const makeFaviconWebContents = (options?: {
500 on: vi.fn((event: string, listener: (...args: never[]) => void) => {506 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
501 listeners.set(event, listener);507 listeners.set(event, listener);
502 }),508 }),
509 once: vi.fn((event: string, listener: (...args: never[]) => void) => {
510 listeners.set(event, listener);
511 }),
503 off,512 off,
504 ipc: { on: vi.fn(), off: vi.fn() },513 ipc: { on: vi.fn(), off: vi.fn() },
505 send: webviewSend,514 send: webviewSend,
...@@ -592,6 +601,7 @@ describe("PreviewManager", () => {...@@ -592,6 +601,7 @@ describe("PreviewManager", () => {
592 getFocusedWebContents.mockReturnValue(null);601 getFocusedWebContents.mockReturnValue(null);
593 mkdir.mockClear();602 mkdir.mockClear();
594 previewSession.on.mockClear();603 previewSession.on.mockClear();
604 previewSession.webRequest.onBeforeSendHeaders.mockClear();
595 writeFile.mockClear();605 writeFile.mockClear();
596 showItemInFolder.mockClear();606 showItemInFolder.mockClear();
597 clipboardItemConstructor.mockClear();607 clipboardItemConstructor.mockClear();
...@@ -795,6 +805,253 @@ describe("PreviewManager", () => {...@@ -795,6 +805,253 @@ describe("PreviewManager", () => {
795 ),805 ),
796 );806 );
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
798 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>1055 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>
799 withManager((manager) =>1056 withManager((manager) =>
800 Effect.gen(function* () {1057 Effect.gen(function* () {
...@@ -804,7 +1061,23 @@ describe("PreviewManager", () => {...@@ -804,7 +1061,23 @@ describe("PreviewManager", () => {
804 yield* manager.registerWebview("tab_editing", 42);1061 yield* manager.registerWebview("tab_editing", 42);
8051062
806 const popup = makeFaviconWebContents({ id: 43 });1063 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 });
807 preview.listeners.get("did-create-window")!({ webContents: popup.webContents } as never);1070 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 );
808 expect(1081 expect(
809 (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts,1082 (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts,
810 ).toHaveBeenCalledWith(true);1083 ).toHaveBeenCalledWith(true);
...@@ -945,6 +1218,7 @@ describe("PreviewManager", () => {...@@ -945,6 +1218,7 @@ describe("PreviewManager", () => {
945 id: 42,1218 id: 42,
946 isDestroyed: () => false,1219 isDestroyed: () => false,
947 getType: () => "webview",1220 getType: () => "webview",
1221 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
948 getURL: () => "about:blank",1222 getURL: () => "about:blank",
949 getTitle: () => "",1223 getTitle: () => "",
950 isLoadingMainFrame: () => false,1224 isLoadingMainFrame: () => false,
...@@ -1038,6 +1312,7 @@ describe("PreviewManager", () => {...@@ -1038,6 +1312,7 @@ describe("PreviewManager", () => {
1038 id: 42,1312 id: 42,
1039 isDestroyed: () => destroyed,1313 isDestroyed: () => destroyed,
1040 getType: () => "webview",1314 getType: () => "webview",
1315 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1041 getURL: () => "http://localhost:3200/",1316 getURL: () => "http://localhost:3200/",
1042 getTitle: () => "Preview",1317 getTitle: () => "Preview",
1043 isLoadingMainFrame: () => false,1318 isLoadingMainFrame: () => false,
...@@ -1541,6 +1816,7 @@ describe("PreviewManager", () => {...@@ -1541,6 +1816,7 @@ describe("PreviewManager", () => {
1541 id: 42,1816 id: 42,
1542 isDestroyed: () => false,1817 isDestroyed: () => false,
1543 getType: () => "webview",1818 getType: () => "webview",
1819 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1544 getURL: () => url,1820 getURL: () => url,
1545 getTitle: () => "Example",1821 getTitle: () => "Example",
1546 isLoadingMainFrame: () => false,1822 isLoadingMainFrame: () => false,
...@@ -1611,6 +1887,7 @@ describe("PreviewManager", () => {...@@ -1611,6 +1887,7 @@ describe("PreviewManager", () => {
1611 id: 43,1887 id: 43,
1612 isDestroyed: () => false,1888 isDestroyed: () => false,
1613 getType: () => "webview",1889 getType: () => "webview",
1890 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1614 getURL: () => url,1891 getURL: () => url,
1615 getTitle: () => "Example",1892 getTitle: () => "Example",
1616 isLoadingMainFrame: () => false,1893 isLoadingMainFrame: () => false,
...@@ -1652,6 +1929,7 @@ describe("PreviewManager", () => {...@@ -1652,6 +1929,7 @@ describe("PreviewManager", () => {
1652 id: 42,1929 id: 42,
1653 isDestroyed: () => false,1930 isDestroyed: () => false,
1654 getType: () => "webview",1931 getType: () => "webview",
1932 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1655 getURL: () => "https://example.com",1933 getURL: () => "https://example.com",
1656 getTitle: () => "Example",1934 getTitle: () => "Example",
1657 isLoadingMainFrame: () => false,1935 isLoadingMainFrame: () => false,
...@@ -1699,6 +1977,7 @@ describe("PreviewManager", () => {...@@ -1699,6 +1977,7 @@ describe("PreviewManager", () => {
1699 id: 42,1977 id: 42,
1700 isDestroyed: () => false,1978 isDestroyed: () => false,
1701 getType: () => "webview",1979 getType: () => "webview",
1980 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1702 getURL: () => "https://example.com",1981 getURL: () => "https://example.com",
1703 getTitle: () => "Example",1982 getTitle: () => "Example",
1704 isLoadingMainFrame: () => false,1983 isLoadingMainFrame: () => false,
...@@ -1755,6 +2034,7 @@ describe("PreviewManager", () => {...@@ -1755,6 +2034,7 @@ describe("PreviewManager", () => {
1755 isDestroyed: () => false,2034 isDestroyed: () => false,
1756 isDevToolsOpened: () => false,2035 isDevToolsOpened: () => false,
1757 getType: () => "webview",2036 getType: () => "webview",
2037 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1758 getURL: () => "https://example.com",2038 getURL: () => "https://example.com",
1759 getTitle: () => "Example",2039 getTitle: () => "Example",
1760 isLoadingMainFrame: () => false,2040 isLoadingMainFrame: () => false,
...@@ -1850,6 +2130,7 @@ describe("PreviewManager", () => {...@@ -1850,6 +2130,7 @@ describe("PreviewManager", () => {
1850 return false;2130 return false;
1851 },2131 },
1852 getType: () => "webview",2132 getType: () => "webview",
2133 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1853 getURL: () => "http://localhost:5173/README.md",2134 getURL: () => "http://localhost:5173/README.md",
1854 getTitle: () => "README.md",2135 getTitle: () => "README.md",
1855 isLoadingMainFrame: () => true,2136 isLoadingMainFrame: () => true,
...@@ -1975,6 +2256,7 @@ describe("PreviewManager", () => {...@@ -1975,6 +2256,7 @@ describe("PreviewManager", () => {
1975 isDestroyed: () => false,2256 isDestroyed: () => false,
1976 isDevToolsOpened: () => false,2257 isDevToolsOpened: () => false,
1977 getType: () => "webview",2258 getType: () => "webview",
2259 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1978 getURL: () => "https://example.com",2260 getURL: () => "https://example.com",
1979 getTitle: () => "Example",2261 getTitle: () => "Example",
1980 isLoadingMainFrame: () => false,2262 isLoadingMainFrame: () => false,
...@@ -2318,6 +2600,7 @@ describe("PreviewManager", () => {...@@ -2318,6 +2600,7 @@ describe("PreviewManager", () => {
2318 id: 42,2600 id: 42,
2319 isDestroyed: () => false,2601 isDestroyed: () => false,
2320 getType: () => "webview",2602 getType: () => "webview",
2603 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
2321 getURL: () => url,2604 getURL: () => url,
2322 getTitle: () => "localhost:5733",2605 getTitle: () => "localhost:5733",
2323 isLoadingMainFrame: () => loading,2606 isLoadingMainFrame: () => loading,
...@@ -2433,6 +2716,7 @@ describe("PreviewManager", () => {...@@ -2433,6 +2716,7 @@ describe("PreviewManager", () => {
2433 id: 42,2716 id: 42,
2434 isDestroyed: () => false,2717 isDestroyed: () => false,
2435 getType: () => "webview",2718 getType: () => "webview",
2719 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
2436 getURL: () => "https://example.com:8443/path?query=value",2720 getURL: () => "https://example.com:8443/path?query=value",
2437 getTitle: () => "Example",2721 getTitle: () => "Example",
2438 isLoadingMainFrame: () => false,2722 isLoadingMainFrame: () => false,
...@@ -3001,6 +3285,7 @@ describe("PreviewManager", () => {...@@ -3001,6 +3285,7 @@ describe("PreviewManager", () => {
3001 ),3285 ),
3002 isDestroyed: () => false,3286 isDestroyed: () => false,
3003 getType: () => "webview",3287 getType: () => "webview",
3288 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
3004 getURL: () => `https://example.com/${id}`,3289 getURL: () => `https://example.com/${id}`,
3005 getTitle: () => `Example ${id}`,3290 getTitle: () => `Example ${id}`,
3006 isLoadingMainFrame: () => false,3291 isLoadingMainFrame: () => false,
...@@ -3463,6 +3748,7 @@ describe("PreviewManager", () => {...@@ -3463,6 +3748,7 @@ describe("PreviewManager", () => {
3463 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),3748 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
3464 isDestroyed: () => false,3749 isDestroyed: () => false,
3465 getType: () => "webview",3750 getType: () => "webview",
3751 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
3466 getURL: () => "https://example.com",3752 getURL: () => "https://example.com",
3467 getTitle: () => "Example",3753 getTitle: () => "Example",
3468 isLoadingMainFrame: () => false,3754 isLoadingMainFrame: () => false,
...@@ -3962,6 +4248,7 @@ describe("PreviewManager", () => {...@@ -3962,6 +4248,7 @@ describe("PreviewManager", () => {
3962 id: 42,4248 id: 42,
3963 isDestroyed: () => false,4249 isDestroyed: () => false,
3964 getType: () => "webview",4250 getType: () => "webview",
4251 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
3965 getURL: () => "https://example.com",4252 getURL: () => "https://example.com",
3966 getTitle: () => "Example",4253 getTitle: () => "Example",
3967 isLoadingMainFrame: () => false,4254 isLoadingMainFrame: () => false,
...@@ -4108,6 +4395,7 @@ describe("PreviewManager", () => {...@@ -4108,6 +4395,7 @@ describe("PreviewManager", () => {
4108 id: 42,4395 id: 42,
4109 isDestroyed: () => false,4396 isDestroyed: () => false,
4110 getType: () => "webview",4397 getType: () => "webview",
4398 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
4111 getURL: () => "https://example.com",4399 getURL: () => "https://example.com",
4112 getTitle: () => "Example",4400 getTitle: () => "Example",
4113 isLoadingMainFrame: () => false,4401 isLoadingMainFrame: () => false,
...@@ -4250,6 +4538,7 @@ describe("PreviewManager", () => {...@@ -4250,6 +4538,7 @@ describe("PreviewManager", () => {
4250 id: 42,4538 id: 42,
4251 isDestroyed: () => false,4539 isDestroyed: () => false,
4252 getType: () => "webview",4540 getType: () => "webview",
4541 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
4253 getURL: () => "https://example.com",4542 getURL: () => "https://example.com",
4254 getTitle: () => "Example",4543 getTitle: () => "Example",
4255 isLoadingMainFrame: () => false,4544 isLoadingMainFrame: () => false,
...@@ -4335,6 +4624,7 @@ describe("PreviewManager", () => {...@@ -4335,6 +4624,7 @@ describe("PreviewManager", () => {
4335 id: 42,4624 id: 42,
4336 isDestroyed: () => false,4625 isDestroyed: () => false,
4337 getType: () => "webview",4626 getType: () => "webview",
4627 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
4338 getURL: () => "https://example.com",4628 getURL: () => "https://example.com",
4339 getTitle: () => "Example",4629 getTitle: () => "Example",
4340 isLoadingMainFrame: () => false,4630 isLoadingMainFrame: () => false,
apps/desktop/src/preview/Manager.ts+107-24
...@@ -27,6 +27,7 @@ import type {...@@ -27,6 +27,7 @@ import type {
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import * as HostProcess from "@t3tools/shared/HostProcess";28import * as HostProcess from "@t3tools/shared/HostProcess";
29import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";29import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
30import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification";
30import { normalizePreviewUrl } from "@t3tools/shared/preview";31import { normalizePreviewUrl } from "@t3tools/shared/preview";
31import {32import {
32 BrowserWindow,33 BrowserWindow,
...@@ -38,6 +39,7 @@ import {...@@ -38,6 +39,7 @@ import {
38 shell,39 shell,
39 webContents,40 webContents,
40} from "electron";41} from "electron";
42import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
41import * as Cause from "effect/Cause";43import * as Cause from "effect/Cause";
42import * as Clock from "effect/Clock";44import * as Clock from "effect/Clock";
43import * as Context from "effect/Context";45import * as Context from "effect/Context";
...@@ -545,6 +547,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -545,6 +547,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
545 const fileSystem = yield* FileSystem.FileSystem;547 const fileSystem = yield* FileSystem.FileSystem;
546 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;548 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
547 const hostPlatform = yield* HostProcess.Platform;549 const hostPlatform = yield* HostProcess.Platform;
550 const hostArchitecture = yield* HostProcess.Architecture;
548 const path = yield* Path.Path;551 const path = yield* Path.Path;
549 const crypto = yield* Crypto.Crypto;552 const crypto = yield* Crypto.Crypto;
550 const parentScope = yield* Scope.Scope;553 const parentScope = yield* Scope.Scope;
...@@ -1186,21 +1189,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1186,21 +1189,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1186 wcDebugger.on("message", onMessage);1189 wcDebugger.on("message", onMessage);
1187 wcDebugger.attach("1.3");1190 wcDebugger.attach("1.3");
1188 });1191 });
1189 // Electron gives `<webview>` guests a transparent base background, and1192 if (wc.getType() === "webview") {
1190 // Chromium only paints a dark canvas for dark color-scheme pages over an1193 // Webview guests need an opaque base for Chromium's dark document canvas.
1191 // opaque base. Without this, dark-scheme pages with no background of1194 yield* attemptPromise(
1192 // their own (text/plain, e.g. .md files) render white text on white.1195 { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },
1193 // White matches the webview's existing white backing, so light pages look1196 () =>
1194 // the same; Chromium still swaps in its dark canvas for dark-scheme pages.1197 wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", {
1195 // Sent first because a document that paints before it arrives keeps the1198 color: { r: 255, g: 255, b: 255, a: 1 },
1196 // transparent base until its next load.1199 }),
1197 yield* attemptPromise(1200 );
1198 { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },1201 }
1199 () =>1202 const identity = browserChromeIdentity(wc.getUserAgent(), {
1200 wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", {1203 platform: hostPlatform,
1201 color: { r: 255, g: 255, b: 255, a: 1 },1204 arch: hostArchitecture,
1202 }),1205 chromeVersion: process.versions.chrome,
1203 );1206 });
1207 if (identity) {
1208 yield* attemptPromise(
1209 { operation: "initializeDebugger.userAgent", webContentsId: wc.id },
1210 () => wcDebugger.sendCommand("Emulation.setUserAgentOverride", identity),
1211 );
1212 }
1204 return [1213 return [
1205 control,1214 control,
1206 replaceMap(sessions, (copy) => {1215 replaceMap(sessions, (copy) => {
...@@ -1523,6 +1532,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1523,6 +1532,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1523 // otherwise spawn native windows without limit. Nothing in an OAuth flow1532 // otherwise spawn native windows without limit. Nothing in an OAuth flow
1524 // opens a second popup, so the chain stops at the first one.1533 // opens a second popup, so the chain stops at the first one.
1525 const windowCreated = (window: Electron.BrowserWindow): void => {1534 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 })));
1526 window.webContents.setIgnoreMenuShortcuts(true);1538 window.webContents.setIgnoreMenuShortcuts(true);
1527 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));1539 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
1528 window.webContents.on("before-input-event", (_event, input) => {1540 window.webContents.on("before-input-event", (_event, input) => {
...@@ -3410,6 +3422,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3410,6 +3422,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3410 openDevTools,3422 openDevTools,
3411 pickElement,3423 pickElement,
3412 prepareWebview,3424 prepareWebview,
3425 ensureControlSession,
3413 reapplyZoom,3426 reapplyZoom,
3414 refresh,3427 refresh,
3415 registerWebview,3428 registerWebview,
...@@ -3692,10 +3705,12 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3692,10 +3705,12 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3692 const browserSession = yield* BrowserSession.BrowserSession;3705 const browserSession = yield* BrowserSession.BrowserSession;
3693 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;3706 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
3694 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;3707 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;
3696 const fileSystem = yield* FileSystem.FileSystem;3711 const fileSystem = yield* FileSystem.FileSystem;
3697 const path = yield* Path.Path;3712 const path = yield* Path.Path;
3698 const runForkDownload = Effect.runForkWith(yield* Effect.context<never>());3713 const runFork = Effect.runForkWith(yield* Effect.context<never>());
3699 /** Puts a copy in Downloads under a name that never replaces a file already there. */3714 /** Puts a copy in Downloads under a name that never replaces a file already there. */
3700 const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* (3715 const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* (
3701 source: string,3716 source: string,
...@@ -3715,11 +3730,78 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3715,11 +3730,78 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3715 return;3730 return;
3716 }3731 }
3717 });3732 });
3718 // Server tabs save downloads where the server's engine reads them. Downloads3733 const prepareBrowserSession = (session: Electron.Session) => {
3719 // the person starts in a tab the server is not driving keep Electron's dialog.3734 if (preparedSessions.has(session)) return;
3720 const placeServerDownloads = (session: Electron.Session) => {3735 const identity = browserChromeIdentity(session.getUserAgent(), {
3721 if (downloadSessions.has(session)) return;3736 platform: hostPlatform,
3722 downloadSessions.add(session);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 }
3723 session.on("will-download", (_event, item, source) => {3805 session.on("will-download", (_event, item, source) => {
3724 // Decided before the server's path is set: placing it consumes the guid.3806 // Decided before the server's path is set: placing it consumes the guid.
3725 const human = browserHost.humanStartedDownload(source);3807 const human = browserHost.humanStartedDownload(source);
...@@ -3727,7 +3809,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3727,7 +3809,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3727 // The server keeps its copy for agents; the person who clicked gets theirs.3809 // The server keeps its copy for agents; the person who clicked gets theirs.
3728 item.once("done", (_doneEvent, state) => {3810 item.once("done", (_doneEvent, state) => {
3729 if (state !== "completed") return;3811 if (state !== "completed") return;
3730 runForkDownload(3812 runFork(
3731 copyToDownloads(item.getSavePath(), item.getFilename()).pipe(3813 copyToDownloads(item.getSavePath(), item.getFilename()).pipe(
3732 Effect.catch((error) =>3814 Effect.catch((error) =>
3733 Effect.logWarning("preview download copy failed", { cause: error }),3815 Effect.logWarning("preview download copy failed", { cause: error }),
...@@ -3736,6 +3818,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3736,6 +3818,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3736 );3818 );
3737 });3819 });
3738 });3820 });
3821 preparedSessions.add(session);
3739 };3822 };
37403823
3741 const operations = yield* makeNativeOperations(3824 const operations = yield* makeNativeOperations(
...@@ -3755,7 +3838,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3755,7 +3838,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3755 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),3838 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
3756 ),3839 ),
3757 );3840 );
3758 placeServerDownloads(session);3841 prepareBrowserSession(session);
3759 passkeys.installSessionHandlers(session);3842 passkeys.installSessionHandlers(session);
3760 return session;3843 return session;
3761 },3844 },
apps/server/src/preview/ServerBrowserContexts.ts+2-44
...@@ -3,7 +3,7 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";...@@ -3,7 +3,7 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
3import { constVoid } from "effect/Function";3import { constVoid } from "effect/Function";
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import * as NodeModule from "node:module";5import * as NodeModule from "node:module";
6import * as NodeOS from "node:os";6import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
7import * as NodePath from "node:path";7import * as NodePath from "node:path";
8import type { Browser, BrowserContext, CDPSession } from "playwright-core";8import type { Browser, BrowserContext, CDPSession } from "playwright-core";
99
...@@ -24,48 +24,6 @@ interface Options {...@@ -24,48 +24,6 @@ interface Options {
24 readonly onContextClose?: (context: BrowserContext) => void;24 readonly onContextClose?: (context: BrowserContext) => void;
25}25}
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
69/**27/**
70 * Presents a headless page as plain Chrome. Applies to the page, its frames,28 * Presents a headless page as plain Chrome. Applies to the page, its frames,
71 * and its workers; a popup gets its own call once it becomes a tab.29 * and its workers; a popup gets its own call once it becomes a tab.
...@@ -75,7 +33,7 @@ export const presentAsChrome = async (...@@ -75,7 +33,7 @@ export const presentAsChrome = async (
75 host: { readonly platform: NodeJS.Platform; readonly arch: string },33 host: { readonly platform: NodeJS.Platform; readonly arch: string },
76) => {34) => {
77 const { userAgent } = await cdp.send("Browser.getVersion");35 const { userAgent } = await cdp.send("Browser.getVersion");
78 const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() });36 const identity = browserChromeIdentity(userAgent, host);
79 if (identity) await cdp.send("Emulation.setUserAgentOverride", identity);37 if (identity) await cdp.send("Emulation.setUserAgentOverride", identity);
80};38};
8139
apps/web/src/browser/BrowserSurfaceSlot.tsx+5-2
...@@ -34,6 +34,9 @@ export function BrowserSurfaceSlot(props: {...@@ -34,6 +34,9 @@ export function BrowserSurfaceSlot(props: {
34 const update = () => {34 const update = () => {
35 const rect = element.getBoundingClientRect();35 const rect = element.getBoundingClientRect();
36 const presentation = presentationRef.current;36 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);
37 const presented = lease.present(40 const presented = lease.present(
38 {41 {
39 x: Math.round(rect.x),42 x: Math.round(rect.x),
...@@ -43,7 +46,7 @@ export function BrowserSurfaceSlot(props: {...@@ -43,7 +46,7 @@ export function BrowserSurfaceSlot(props: {
43 },46 },
44 presentation.visible && rect.width > 0 && rect.height > 0,47 presentation.visible && rect.width > 0 && rect.height > 0,
45 presentation.cornerRadius,48 presentation.cornerRadius,
46 presentation.zIndex,49 zIndex,
47 );50 );
48 if (presentation.visible && !presented) {51 if (presentation.visible && !presented) {
49 lease.release();52 lease.release();
...@@ -57,7 +60,7 @@ export function BrowserSurfaceSlot(props: {...@@ -57,7 +60,7 @@ export function BrowserSurfaceSlot(props: {
57 },60 },
58 rect.width > 0 && rect.height > 0,61 rect.width > 0 && rect.height > 0,
59 presentation.cornerRadius,62 presentation.cornerRadius,
60 presentation.zIndex,63 zIndex,
61 );64 );
62 }65 }
63 };66 };
apps/web/src/components/ChatView.tsx+2
...@@ -274,6 +274,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";...@@ -274,6 +274,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
274import { addBrowserSurface } from "./preview/addBrowserSurface";274import { addBrowserSurface } from "./preview/addBrowserSurface";
275import { closePreviewSession } from "./preview/closePreviewSession";275import { closePreviewSession } from "./preview/closePreviewSession";
276import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";276import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
277import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator";
277import { usePreviewSession } from "./preview/usePreviewSession";278import { usePreviewSession } from "./preview/usePreviewSession";
278import { subscribePreviewAction } from "./preview/previewActionBus";279import { subscribePreviewAction } from "./preview/previewActionBus";
279import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";280import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
...@@ -11749,6 +11750,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11749,6 +11750,7 @@ export default function ChatView(props: ChatViewProps) {
11749 data-chat-composer-stack="true"11750 data-chat-composer-stack="true"
11750 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)"11751 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)"
11751 >11752 >
11753 <BrowserAudioIndicator />
11752 {isDraftHeroState ? (11754 {isDraftHeroState ? (
11753 <div className="absolute inset-x-0 bottom-full">11755 <div className="absolute inset-x-0 bottom-full">
11754 <div11756 <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 @@...@@ -1,9 +1,8 @@
1/**1/**
2 * Per-thread preview UI state.2 * Per-thread preview UI state.
3 *3 *
4 * Each thread owns an independent atom. Most consumers read exactly one4 * Each thread owns an independent atom so ordinary readers avoid updates
5 * thread; the desktop browser host uses the aggregate session atom because it5 * from unrelated tabs. Browser hosting and audio indicators share an aggregate.
6 * is the one place that must enumerate every live preview tab.
7 */6 */
8import { useAtomValue } from "@effect/atom-react";7import { useAtomValue } from "@effect/atom-react";
9import { scopedThreadKey } from "@t3tools/client-runtime/environment";8import { scopedThreadKey } from "@t3tools/client-runtime/environment";
...@@ -75,8 +74,7 @@ export const previewStateAtom = Atom.family((threadKey: string) =>...@@ -75,8 +74,7 @@ export const previewStateAtom = Atom.family((threadKey: string) =>
75 ),74 ),
76);75);
7776
78// Only the Electron browser host needs a cross-thread view. Keep that index77// Keep the global index separate so thread-local readers avoid unrelated previews.
79// separate so thread-local readers never subscribe to unrelated previews.
80interface ActivePreviewThreadIndex {78interface ActivePreviewThreadIndex {
81 readonly keys: ReadonlySet<string>;79 readonly keys: ReadonlySet<string>;
82}80}
packages/shared/package.json+4
...@@ -490,6 +490,10 @@...@@ -490,6 +490,10 @@
490 "./PtyAdapter": {490 "./PtyAdapter": {
491 "types": "./src/PtyAdapter.ts",491 "types": "./src/PtyAdapter.ts",
492 "import": "./src/PtyAdapter.ts"492 "import": "./src/PtyAdapter.ts"
493 },
494 "./browserChromeIdentity": {
495 "types": "./src/browserChromeIdentity.ts",
496 "import": "./src/browserChromeIdentity.ts"
493 }497 }
494 },498 },
495 "scripts": {499 "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}