| 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,12 +122,7 @@ describe("BrowserSession", () => { |
| 122 | }).pipe(Effect.provide(layer)), | 122 | }).pipe(Effect.provide(layer)), |
| 123 | ); | 123 | ); |
| 124 | 124 | ||
| 125 | // A rewritten session UA — any variant, even ones that keep the Electron | 125 | 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, then | 127 | // 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 | }); |
| 152 | 147 | ||
| 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"); | ||
| 156 | 152 | ||
| 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 | ); |
| 162 | 162 |
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"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 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 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | 15 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 13 | 16 | ||
| 14 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; | 17 | const 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. */ |
| 215 | export const make = Effect.gen(function* BrowserSessionMake() { | 218 | export 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 any | 305 | const identity = browserChromeIdentity(browserSession.getUserAgent(), { platform, arch }); |
| 301 | // form — even variants that keep the Electron token — makes Cloudflare | 306 | 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 | ); |
| 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 | 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); |
| 805 | 1062 | ||
| 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"; |
| 28 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 28 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 29 | import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; | 29 | import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; |
| 30 | import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification"; | ||
| 30 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 31 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 31 | import { | 32 | import { |
| 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"; |
| 42 | import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; | ||
| 41 | import * as Cause from "effect/Cause"; | 43 | import * as Cause from "effect/Cause"; |
| 42 | import * as Clock from "effect/Clock"; | 44 | import * as Clock from "effect/Clock"; |
| 43 | import * as Context from "effect/Context"; | 45 | import * 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, and | 1192 | if (wc.getType() === "webview") { |
| 1190 | // Chromium only paints a dark canvas for dark color-scheme pages over an | 1193 | // Webview guests need an opaque base for Chromium's dark document canvas. |
| 1191 | // opaque base. Without this, dark-scheme pages with no background of | 1194 | 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 look | 1196 | () => |
| 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 the | 1198 | 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 flow | 1532 | // 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. Downloads | 3733 | 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 | }; |
| 3740 | 3823 | ||
| 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"; |
| 3 | import { constVoid } from "effect/Function"; | 3 | import { constVoid } from "effect/Function"; |
| 4 | import * as NodeFSP from "node:fs/promises"; | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeModule from "node:module"; | 5 | import * as NodeModule from "node:module"; |
| 6 | import * as NodeOS from "node:os"; | 6 | import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; |
| 7 | import * as NodePath from "node:path"; | 7 | import * as NodePath from "node:path"; |
| 8 | import type { Browser, BrowserContext, CDPSession } from "playwright-core"; | 8 | import type { Browser, BrowserContext, CDPSession } from "playwright-core"; |
| 9 | 9 | ||
| ... | @@ -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 | } |
| 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 | * 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 | }; |
| 81 | 39 |
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"; |
| 274 | import { addBrowserSurface } from "./preview/addBrowserSurface"; | 274 | import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 275 | import { closePreviewSession } from "./preview/closePreviewSession"; | 275 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 276 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | 276 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| 277 | import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator"; | ||
| 277 | import { usePreviewSession } from "./preview/usePreviewSession"; | 278 | import { usePreviewSession } from "./preview/usePreviewSession"; |
| 278 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 279 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 279 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | 280 | import { 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 | <div | 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,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 one | 4 | * Each thread owns an independent atom so ordinary readers avoid updates |
| 5 | * thread; the desktop browser host uses the aggregate session atom because it | 5 | * 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 | */ |
| 8 | import { useAtomValue } from "@effect/atom-react"; | 7 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 8 | import { 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 | ); |
| 77 | 76 | ||
| 78 | // Only the Electron browser host needs a cross-thread view. Keep that index | 77 | // Keep the global index separate so thread-local readers avoid unrelated previews. |
| 79 | // separate so thread-local readers never subscribe to unrelated previews. | ||
| 80 | interface ActivePreviewThreadIndex { | 78 | interface 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 @@ | ||
| 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 | } | ||