| author | |
| committer | |
| log | 96a2bb7293207bd038d5eb5754b664d9b1c58e5c |
| tree | 4c5bc27786db32385249bc98f5cc79282ea54a53 |
| parent | 4b5a0105eece1c3aa7f02739122464260a9c5def |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol13 files changed, 744 insertions(+), 92 deletions(-)
apps/desktop/src/preview/BrowserSession.test.ts+10-10| ... | ... | @@ -122,12 +122,7 @@ describe("BrowserSession", () => { |
| 122 | 122 | }).pipe(Effect.provide(layer)), |
| 123 | 123 | ); |
| 124 | 124 | |
| 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", () => | |
| 131 | 126 | Effect.gen(function* () { |
| 132 | 127 | // Electron's real UA shape: app token, then Chrome, then Electron, then |
| 133 | 128 | // Safari — the token order and casing matter to any strip regex. |
| ... | ... | @@ -151,12 +146,17 @@ describe("BrowserSession", () => { |
| 151 | 146 | }); |
| 152 | 147 | |
| 153 | 148 | const browserSessions = yield* BrowserSession.BrowserSession; |
| 154 | const partition = yield* browserSessions.getPartition("scope-a"); | |
| 155 | 149 | yield* browserSessions.getSession("scope-a"); |
| 150 | yield* browserSessions.getSession("private", false); | |
| 151 | yield* browserSessions.getSession("work", true, "profile"); | |
| 156 | 152 | |
| 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 | } | |
| 160 | 160 | }).pipe(Effect.provide(layer)), |
| 161 | 161 | ); |
| 162 | 162 |
apps/desktop/src/preview/BrowserSession.ts+7-6| ... | ... | @@ -9,6 +9,9 @@ import * as PlatformError from "effect/PlatformError"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 11 | 11 | |
| 12 | import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; | |
| 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 14 | ||
| 12 | 15 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 13 | 16 | |
| 14 | 17 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; |
| ... | ... | @@ -214,6 +217,8 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined |
| 214 | 217 | /** @public Service construction is part of the canonical Effect module API. */ |
| 215 | 218 | export const make = Effect.gen(function* BrowserSessionMake() { |
| 216 | 219 | const crypto = yield* Crypto.Crypto; |
| 220 | const platform = yield* HostProcess.Platform; | |
| 221 | const arch = yield* HostProcess.Architecture; | |
| 217 | 222 | const electronDialog = yield* ElectronDialog.ElectronDialog; |
| 218 | 223 | const runFork = Effect.runForkWith(yield* Effect.context<never>()); |
| 219 | 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 | 302 | return Effect.try({ |
| 298 | 303 | try: () => { |
| 299 | 304 | 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); | |
| 306 | 307 | browserSession.setPermissionRequestHandler( |
| 307 | 308 | (webContents, permission, callback, details) => { |
| 308 | 309 | if (permission === "openExternal") { |
apps/desktop/src/preview/Manager.test.ts+291-1| ... | ... | @@ -226,7 +226,11 @@ const { |
| 226 | 226 | fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), |
| 227 | 227 | getFocusedWebContents: vi.fn(() => null), |
| 228 | 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 | 234 | showItemInFolder: vi.fn(), |
| 231 | 235 | webviewSend: vi.fn(), |
| 232 | 236 | writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), |
| ... | ... | @@ -381,6 +385,7 @@ const makeTestPreviewWebContents = ( |
| 381 | 385 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), |
| 382 | 386 | isDestroyed: () => false, |
| 383 | 387 | getType: () => "webview", |
| 388 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 384 | 389 | getURL: () => "https://example.com", |
| 385 | 390 | getTitle: () => "Example", |
| 386 | 391 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -486,6 +491,7 @@ const makeFaviconWebContents = (options?: { |
| 486 | 491 | id: options?.id ?? 42, |
| 487 | 492 | isDestroyed: () => destroyed, |
| 488 | 493 | getType: () => "webview", |
| 494 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 489 | 495 | getURL: () => currentUrl, |
| 490 | 496 | getTitle: () => "Preview", |
| 491 | 497 | isLoadingMainFrame: () => loading, |
| ... | ... | @@ -500,6 +506,9 @@ const makeFaviconWebContents = (options?: { |
| 500 | 506 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { |
| 501 | 507 | listeners.set(event, listener); |
| 502 | 508 | }), |
| 509 | once: vi.fn((event: string, listener: (...args: never[]) => void) => { | |
| 510 | listeners.set(event, listener); | |
| 511 | }), | |
| 503 | 512 | off, |
| 504 | 513 | ipc: { on: vi.fn(), off: vi.fn() }, |
| 505 | 514 | send: webviewSend, |
| ... | ... | @@ -592,6 +601,7 @@ describe("PreviewManager", () => { |
| 592 | 601 | getFocusedWebContents.mockReturnValue(null); |
| 593 | 602 | mkdir.mockClear(); |
| 594 | 603 | previewSession.on.mockClear(); |
| 604 | previewSession.webRequest.onBeforeSendHeaders.mockClear(); | |
| 595 | 605 | writeFile.mockClear(); |
| 596 | 606 | showItemInFolder.mockClear(); |
| 597 | 607 | clipboardItemConstructor.mockClear(); |
| ... | ... | @@ -795,6 +805,253 @@ describe("PreviewManager", () => { |
| 795 | 805 | ), |
| 796 | 806 | ); |
| 797 | 807 | |
| 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 | 1055 | effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () => |
| 799 | 1056 | withManager((manager) => |
| 800 | 1057 | Effect.gen(function* () { |
| ... | ... | @@ -804,7 +1061,23 @@ describe("PreviewManager", () => { |
| 804 | 1061 | yield* manager.registerWebview("tab_editing", 42); |
| 805 | 1062 | |
| 806 | 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 | 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 | 1081 | expect( |
| 809 | 1082 | (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts, |
| 810 | 1083 | ).toHaveBeenCalledWith(true); |
| ... | ... | @@ -945,6 +1218,7 @@ describe("PreviewManager", () => { |
| 945 | 1218 | id: 42, |
| 946 | 1219 | isDestroyed: () => false, |
| 947 | 1220 | getType: () => "webview", |
| 1221 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 948 | 1222 | getURL: () => "about:blank", |
| 949 | 1223 | getTitle: () => "", |
| 950 | 1224 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -1038,6 +1312,7 @@ describe("PreviewManager", () => { |
| 1038 | 1312 | id: 42, |
| 1039 | 1313 | isDestroyed: () => destroyed, |
| 1040 | 1314 | getType: () => "webview", |
| 1315 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1041 | 1316 | getURL: () => "http://localhost:3200/", |
| 1042 | 1317 | getTitle: () => "Preview", |
| 1043 | 1318 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -1541,6 +1816,7 @@ describe("PreviewManager", () => { |
| 1541 | 1816 | id: 42, |
| 1542 | 1817 | isDestroyed: () => false, |
| 1543 | 1818 | getType: () => "webview", |
| 1819 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1544 | 1820 | getURL: () => url, |
| 1545 | 1821 | getTitle: () => "Example", |
| 1546 | 1822 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -1611,6 +1887,7 @@ describe("PreviewManager", () => { |
| 1611 | 1887 | id: 43, |
| 1612 | 1888 | isDestroyed: () => false, |
| 1613 | 1889 | getType: () => "webview", |
| 1890 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1614 | 1891 | getURL: () => url, |
| 1615 | 1892 | getTitle: () => "Example", |
| 1616 | 1893 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -1652,6 +1929,7 @@ describe("PreviewManager", () => { |
| 1652 | 1929 | id: 42, |
| 1653 | 1930 | isDestroyed: () => false, |
| 1654 | 1931 | getType: () => "webview", |
| 1932 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1655 | 1933 | getURL: () => "https://example.com", |
| 1656 | 1934 | getTitle: () => "Example", |
| 1657 | 1935 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -1699,6 +1977,7 @@ describe("PreviewManager", () => { |
| 1699 | 1977 | id: 42, |
| 1700 | 1978 | isDestroyed: () => false, |
| 1701 | 1979 | getType: () => "webview", |
| 1980 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1702 | 1981 | getURL: () => "https://example.com", |
| 1703 | 1982 | getTitle: () => "Example", |
| 1704 | 1983 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -1755,6 +2034,7 @@ describe("PreviewManager", () => { |
| 1755 | 2034 | isDestroyed: () => false, |
| 1756 | 2035 | isDevToolsOpened: () => false, |
| 1757 | 2036 | getType: () => "webview", |
| 2037 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1758 | 2038 | getURL: () => "https://example.com", |
| 1759 | 2039 | getTitle: () => "Example", |
| 1760 | 2040 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -1850,6 +2130,7 @@ describe("PreviewManager", () => { |
| 1850 | 2130 | return false; |
| 1851 | 2131 | }, |
| 1852 | 2132 | getType: () => "webview", |
| 2133 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1853 | 2134 | getURL: () => "http://localhost:5173/README.md", |
| 1854 | 2135 | getTitle: () => "README.md", |
| 1855 | 2136 | isLoadingMainFrame: () => true, |
| ... | ... | @@ -1975,6 +2256,7 @@ describe("PreviewManager", () => { |
| 1975 | 2256 | isDestroyed: () => false, |
| 1976 | 2257 | isDevToolsOpened: () => false, |
| 1977 | 2258 | getType: () => "webview", |
| 2259 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 1978 | 2260 | getURL: () => "https://example.com", |
| 1979 | 2261 | getTitle: () => "Example", |
| 1980 | 2262 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -2318,6 +2600,7 @@ describe("PreviewManager", () => { |
| 2318 | 2600 | id: 42, |
| 2319 | 2601 | isDestroyed: () => false, |
| 2320 | 2602 | getType: () => "webview", |
| 2603 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 2321 | 2604 | getURL: () => url, |
| 2322 | 2605 | getTitle: () => "localhost:5733", |
| 2323 | 2606 | isLoadingMainFrame: () => loading, |
| ... | ... | @@ -2433,6 +2716,7 @@ describe("PreviewManager", () => { |
| 2433 | 2716 | id: 42, |
| 2434 | 2717 | isDestroyed: () => false, |
| 2435 | 2718 | getType: () => "webview", |
| 2719 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 2436 | 2720 | getURL: () => "https://example.com:8443/path?query=value", |
| 2437 | 2721 | getTitle: () => "Example", |
| 2438 | 2722 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -3001,6 +3285,7 @@ describe("PreviewManager", () => { |
| 3001 | 3285 | ), |
| 3002 | 3286 | isDestroyed: () => false, |
| 3003 | 3287 | getType: () => "webview", |
| 3288 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 3004 | 3289 | getURL: () => `https://example.com/${id}`, |
| 3005 | 3290 | getTitle: () => `Example ${id}`, |
| 3006 | 3291 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -3463,6 +3748,7 @@ describe("PreviewManager", () => { |
| 3463 | 3748 | executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), |
| 3464 | 3749 | isDestroyed: () => false, |
| 3465 | 3750 | getType: () => "webview", |
| 3751 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 3466 | 3752 | getURL: () => "https://example.com", |
| 3467 | 3753 | getTitle: () => "Example", |
| 3468 | 3754 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -3962,6 +4248,7 @@ describe("PreviewManager", () => { |
| 3962 | 4248 | id: 42, |
| 3963 | 4249 | isDestroyed: () => false, |
| 3964 | 4250 | getType: () => "webview", |
| 4251 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 3965 | 4252 | getURL: () => "https://example.com", |
| 3966 | 4253 | getTitle: () => "Example", |
| 3967 | 4254 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -4108,6 +4395,7 @@ describe("PreviewManager", () => { |
| 4108 | 4395 | id: 42, |
| 4109 | 4396 | isDestroyed: () => false, |
| 4110 | 4397 | getType: () => "webview", |
| 4398 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 4111 | 4399 | getURL: () => "https://example.com", |
| 4112 | 4400 | getTitle: () => "Example", |
| 4113 | 4401 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -4250,6 +4538,7 @@ describe("PreviewManager", () => { |
| 4250 | 4538 | id: 42, |
| 4251 | 4539 | isDestroyed: () => false, |
| 4252 | 4540 | getType: () => "webview", |
| 4541 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 4253 | 4542 | getURL: () => "https://example.com", |
| 4254 | 4543 | getTitle: () => "Example", |
| 4255 | 4544 | isLoadingMainFrame: () => false, |
| ... | ... | @@ -4335,6 +4624,7 @@ describe("PreviewManager", () => { |
| 4335 | 4624 | id: 42, |
| 4336 | 4625 | isDestroyed: () => false, |
| 4337 | 4626 | getType: () => "webview", |
| 4627 | getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", | |
| 4338 | 4628 | getURL: () => "https://example.com", |
| 4339 | 4629 | getTitle: () => "Example", |
| 4340 | 4630 | isLoadingMainFrame: () => false, |
apps/desktop/src/preview/Manager.ts+107-24| ... | ... | @@ -27,6 +27,7 @@ import type { |
| 27 | 27 | } from "@t3tools/contracts"; |
| 28 | 28 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 29 | 29 | import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; |
| 30 | import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification"; | |
| 30 | 31 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 31 | 32 | import { |
| 32 | 33 | BrowserWindow, |
| ... | ... | @@ -38,6 +39,7 @@ import { |
| 38 | 39 | shell, |
| 39 | 40 | webContents, |
| 40 | 41 | } from "electron"; |
| 42 | import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; | |
| 41 | 43 | import * as Cause from "effect/Cause"; |
| 42 | 44 | import * as Clock from "effect/Clock"; |
| 43 | 45 | import * as Context from "effect/Context"; |
| ... | ... | @@ -545,6 +547,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 545 | 547 | const fileSystem = yield* FileSystem.FileSystem; |
| 546 | 548 | const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; |
| 547 | 549 | const hostPlatform = yield* HostProcess.Platform; |
| 550 | const hostArchitecture = yield* HostProcess.Architecture; | |
| 548 | 551 | const path = yield* Path.Path; |
| 549 | 552 | const crypto = yield* Crypto.Crypto; |
| 550 | 553 | const parentScope = yield* Scope.Scope; |
| ... | ... | @@ -1186,21 +1189,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1186 | 1189 | wcDebugger.on("message", onMessage); |
| 1187 | 1190 | wcDebugger.attach("1.3"); |
| 1188 | 1191 | }); |
| 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 | } | |
| 1204 | 1213 | return [ |
| 1205 | 1214 | control, |
| 1206 | 1215 | replaceMap(sessions, (copy) => { |
| ... | ... | @@ -1523,6 +1532,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1523 | 1532 | // otherwise spawn native windows without limit. Nothing in an OAuth flow |
| 1524 | 1533 | // opens a second popup, so the chain stops at the first one. |
| 1525 | 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 | 1538 | window.webContents.setIgnoreMenuShortcuts(true); |
| 1527 | 1539 | window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); |
| 1528 | 1540 | window.webContents.on("before-input-event", (_event, input) => { |
| ... | ... | @@ -3410,6 +3422,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 3410 | 3422 | openDevTools, |
| 3411 | 3423 | pickElement, |
| 3412 | 3424 | prepareWebview, |
| 3425 | ensureControlSession, | |
| 3413 | 3426 | reapplyZoom, |
| 3414 | 3427 | refresh, |
| 3415 | 3428 | registerWebview, |
| ... | ... | @@ -3692,10 +3705,12 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3692 | 3705 | const browserSession = yield* BrowserSession.BrowserSession; |
| 3693 | 3706 | const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; |
| 3694 | 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 | 3711 | const fileSystem = yield* FileSystem.FileSystem; |
| 3697 | 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 | 3714 | /** Puts a copy in Downloads under a name that never replaces a file already there. */ |
| 3700 | 3715 | const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* ( |
| 3701 | 3716 | source: string, |
| ... | ... | @@ -3715,11 +3730,78 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3715 | 3730 | return; |
| 3716 | 3731 | } |
| 3717 | 3732 | }); |
| 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 | } | |
| 3723 | 3805 | session.on("will-download", (_event, item, source) => { |
| 3724 | 3806 | // Decided before the server's path is set: placing it consumes the guid. |
| 3725 | 3807 | const human = browserHost.humanStartedDownload(source); |
| ... | ... | @@ -3727,7 +3809,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3727 | 3809 | // The server keeps its copy for agents; the person who clicked gets theirs. |
| 3728 | 3810 | item.once("done", (_doneEvent, state) => { |
| 3729 | 3811 | if (state !== "completed") return; |
| 3730 | runForkDownload( | |
| 3812 | runFork( | |
| 3731 | 3813 | copyToDownloads(item.getSavePath(), item.getFilename()).pipe( |
| 3732 | 3814 | Effect.catch((error) => |
| 3733 | 3815 | Effect.logWarning("preview download copy failed", { cause: error }), |
| ... | ... | @@ -3736,6 +3818,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3736 | 3818 | ); |
| 3737 | 3819 | }); |
| 3738 | 3820 | }); |
| 3821 | preparedSessions.add(session); | |
| 3739 | 3822 | }; |
| 3740 | 3823 | |
| 3741 | 3824 | const operations = yield* makeNativeOperations( |
| ... | ... | @@ -3755,7 +3838,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3755 | 3838 | (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), |
| 3756 | 3839 | ), |
| 3757 | 3840 | ); |
| 3758 | placeServerDownloads(session); | |
| 3841 | prepareBrowserSession(session); | |
| 3759 | 3842 | passkeys.installSessionHandlers(session); |
| 3760 | 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 | 3 | import { constVoid } from "effect/Function"; |
| 4 | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | 5 | import * as NodeModule from "node:module"; |
| 6 | import * as NodeOS from "node:os"; | |
| 6 | import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; | |
| 7 | 7 | import * as NodePath from "node:path"; |
| 8 | 8 | import type { Browser, BrowserContext, CDPSession } from "playwright-core"; |
| 9 | 9 | |
| ... | ... | @@ -24,48 +24,6 @@ interface Options { |
| 24 | 24 | readonly onContextClose?: (context: BrowserContext) => void; |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | const 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 | */ | |
| 38 | const 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 | 28 | * Presents a headless page as plain Chrome. Applies to the page, its frames, |
| 71 | 29 | * and its workers; a popup gets its own call once it becomes a tab. |
| ... | ... | @@ -75,7 +33,7 @@ export const presentAsChrome = async ( |
| 75 | 33 | host: { readonly platform: NodeJS.Platform; readonly arch: string }, |
| 76 | 34 | ) => { |
| 77 | 35 | const { userAgent } = await cdp.send("Browser.getVersion"); |
| 78 | const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() }); | |
| 36 | const identity = browserChromeIdentity(userAgent, host); | |
| 79 | 37 | if (identity) await cdp.send("Emulation.setUserAgentOverride", identity); |
| 80 | 38 | }; |
| 81 | 39 |
apps/web/src/browser/BrowserSurfaceSlot.tsx+5-2| ... | ... | @@ -34,6 +34,9 @@ export function BrowserSurfaceSlot(props: { |
| 34 | 34 | const update = () => { |
| 35 | 35 | const rect = element.getBoundingClientRect(); |
| 36 | 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 | 40 | const presented = lease.present( |
| 38 | 41 | { |
| 39 | 42 | x: Math.round(rect.x), |
| ... | ... | @@ -43,7 +46,7 @@ export function BrowserSurfaceSlot(props: { |
| 43 | 46 | }, |
| 44 | 47 | presentation.visible && rect.width > 0 && rect.height > 0, |
| 45 | 48 | presentation.cornerRadius, |
| 46 | presentation.zIndex, | |
| 49 | zIndex, | |
| 47 | 50 | ); |
| 48 | 51 | if (presentation.visible && !presented) { |
| 49 | 52 | lease.release(); |
| ... | ... | @@ -57,7 +60,7 @@ export function BrowserSurfaceSlot(props: { |
| 57 | 60 | }, |
| 58 | 61 | rect.width > 0 && rect.height > 0, |
| 59 | 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 | 274 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 275 | 275 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 276 | 276 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| 277 | import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator"; | |
| 277 | 278 | import { usePreviewSession } from "./preview/usePreviewSession"; |
| 278 | 279 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 279 | 280 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| ... | ... | @@ -11749,6 +11750,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11749 | 11750 | data-chat-composer-stack="true" |
| 11750 | 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 | 11754 | {isDraftHeroState ? ( |
| 11753 | 11755 | <div className="absolute inset-x-0 bottom-full"> |
| 11754 | 11756 | <div |
apps/web/src/components/preview/BrowserAudioIndicator.test.tsx created+131| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | |
| 3 | import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 4 | import { act } from "react"; | |
| 5 | import { createRoot, type Root } from "react-dom/client"; | |
| 6 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | const 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 | })); | |
| 14 | vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mocks.navigate })); | |
| 15 | vi.mock("./previewBridge", () => ({ previewBridge: { setAudioMuted: mocks.mute } })); | |
| 16 | vi.mock("~/rightPanelStore", () => ({ | |
| 17 | useRightPanelStore: { getState: () => ({ openBrowser: mocks.open }) }, | |
| 18 | })); | |
| 19 | vi.mock("~/components/ui/toast", () => ({ toastManager: { add: mocks.toast } })); | |
| 20 | ||
| 21 | import { BrowserAudioIndicator } from "./BrowserAudioIndicator"; | |
| 22 | import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; | |
| 23 | import { | |
| 24 | applyPreviewDesktopState, | |
| 25 | applyPreviewServerSnapshot, | |
| 26 | readThreadPreviewState, | |
| 27 | resetPreviewStateForTests, | |
| 28 | type DesktopPreviewOverlay, | |
| 29 | } from "~/previewStateStore"; | |
| 30 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | |
| 31 | ||
| 32 | const ref = scopeThreadRef(EnvironmentId.make("env-audio"), ThreadId.make("background-chat")); | |
| 33 | const 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 | }; | |
| 41 | const 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 | }; | |
| 54 | let container: HTMLDivElement; | |
| 55 | let root: Root; | |
| 56 | ||
| 57 | beforeEach(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 | }); | |
| 72 | afterEach(async () => { | |
| 73 | await act(() => root.unmount()); | |
| 74 | container.remove(); | |
| 75 | }); | |
| 76 | ||
| 77 | async function startAudio() { | |
| 78 | await act(() => { | |
| 79 | applyPreviewServerSnapshot(ref, snapshot); | |
| 80 | applyPreviewDesktopState(ref, snapshot.tabId, overlay); | |
| 81 | }); | |
| 82 | } | |
| 83 | ||
| 84 | it("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 | ||
| 96 | it("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 | ||
| 111 | it("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 | ||
| 120 | it("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 @@ |
| 1 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 2 | import { useNavigate } from "@tanstack/react-router"; | |
| 3 | import { Volume2Icon } from "lucide-react"; | |
| 4 | import { useState } from "react"; | |
| 5 | ||
| 6 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | |
| 7 | import { Button } from "~/components/ui/button"; | |
| 8 | import { toastManager } from "~/components/ui/toast"; | |
| 9 | import { useActivePreviewSessions } from "~/previewStateStore"; | |
| 10 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 11 | import { buildThreadRouteParams } from "~/threadRoutes"; | |
| 12 | import { previewBridge } from "./previewBridge"; | |
| 13 | ||
| 14 | /** Remains visible when a playing browser is behind another panel or chat. */ | |
| 15 | export 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 | 1 | /** |
| 2 | 2 | * Per-thread preview UI state. |
| 3 | 3 | * |
| 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. | |
| 7 | 6 | */ |
| 8 | 7 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | 8 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| ... | ... | @@ -75,8 +74,7 @@ export const previewStateAtom = Atom.family((threadKey: string) => |
| 75 | 74 | ), |
| 76 | 75 | ); |
| 77 | 76 | |
| 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. | |
| 80 | 78 | interface ActivePreviewThreadIndex { |
| 81 | 79 | readonly keys: ReadonlySet<string>; |
| 82 | 80 | } |
packages/shared/package.json+4| ... | ... | @@ -490,6 +490,10 @@ |
| 490 | 490 | "./PtyAdapter": { |
| 491 | 491 | "types": "./src/PtyAdapter.ts", |
| 492 | 492 | "import": "./src/PtyAdapter.ts" |
| 493 | }, | |
| 494 | "./browserChromeIdentity": { | |
| 495 | "types": "./src/browserChromeIdentity.ts", | |
| 496 | "import": "./src/browserChromeIdentity.ts" | |
| 493 | 497 | } |
| 494 | 498 | }, |
| 495 | 499 | "scripts": { |
packages/shared/src/browserChromeIdentity.test.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { browserChromeIdentity } from "./browserChromeIdentity.ts"; | |
| 3 | ||
| 4 | const host = { platform: "darwin", arch: "arm64" } as const; | |
| 5 | ||
| 6 | describe("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 @@ |
| 1 | const 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. */ | |
| 8 | export 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 | } |