diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 9799d88fdc9cab22f0bfcd08f9fc32ad5c2edc60..8c25557b97858a661f83bd0cdc523de4e86cb985 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -122,12 +122,7 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A rewritten session UA — any variant, even ones that keep the Electron - // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so - // the guest must end up with Electron's native User-Agent. The mock applies - // setUserAgent calls, so this fails on any reintroduced rewrite while still - // permitting a harmless re-set of the unchanged native string. - it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => + it.effect("presents persistent preview sessions as the bundled Chrome", () => Effect.gen(function* () { // Electron's real UA shape: app token, then Chrome, then Electron, then // Safari — the token order and casing matter to any strip regex. @@ -151,12 +146,17 @@ describe("BrowserSession", () => { }); const browserSessions = yield* BrowserSession.BrowserSession; - const partition = yield* browserSessions.getPartition("scope-a"); yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("private", false); + yield* browserSessions.getSession("work", true, "profile"); - const browserSession = sessions.get(partition); - assert.isDefined(browserSession); - assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent); + assert.strictEqual(sessions.size, 3); + for (const browserSession of sessions.values()) { + assert.strictEqual( + browserSession.getUserAgent(), + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + ); + } }).pipe(Effect.provide(layer)), ); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 10e2a2575f387ccdecf85d3eaee1b79967dce575..1b00118ce70b19af56ad47b18c5d950494685666 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -9,6 +9,9 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ElectronDialog from "../electron/ElectronDialog.ts"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; @@ -214,6 +217,8 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; + const platform = yield* HostProcess.Platform; + const arch = yield* HostProcess.Architecture; const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // 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() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); - // The guest keeps Electron's native User-Agent. Rewriting it in any - // form — even variants that keep the Electron token — makes Cloudflare - // Turnstile fail its integrity check with error 600010 and recreate - // the challenge every few seconds, so logins behind it never complete - // (#5002). Re-setting the unchanged native string is harmless, so it - // is the rewritten string itself that trips the check. + const identity = browserChromeIdentity(browserSession.getUserAgent(), { platform, arch }); + if (identity) browserSession.setUserAgent(identity.userAgent); browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { if (permission === "openExternal") { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6d585a88641c62a17c9b68a23383bacbf21b2abe..07a1ee707f34b557394bd3bd61e3818fad197fac 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -226,7 +226,11 @@ const { fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), - previewSession: { on: vi.fn() }, + previewSession: { + on: vi.fn(), + getUserAgent: vi.fn(() => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36"), + webRequest: { onBeforeSendHeaders: vi.fn() }, + }, showItemInFolder: vi.fn(), webviewSend: vi.fn(), writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined), @@ -381,6 +385,7 @@ const makeTestPreviewWebContents = ( executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -486,6 +491,7 @@ const makeFaviconWebContents = (options?: { id: options?.id ?? 42, isDestroyed: () => destroyed, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => currentUrl, getTitle: () => "Preview", isLoadingMainFrame: () => loading, @@ -500,6 +506,9 @@ const makeFaviconWebContents = (options?: { on: vi.fn((event: string, listener: (...args: never[]) => void) => { listeners.set(event, listener); }), + once: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), off, ipc: { on: vi.fn(), off: vi.fn() }, send: webviewSend, @@ -592,6 +601,7 @@ describe("PreviewManager", () => { getFocusedWebContents.mockReturnValue(null); mkdir.mockClear(); previewSession.on.mockClear(); + previewSession.webRequest.onBeforeSendHeaders.mockClear(); writeFile.mockClear(); showItemInFolder.mockClear(); clipboardItemConstructor.mockClear(); @@ -795,6 +805,253 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + ["default", true, undefined], + ["work", true, "profile"], + ["incognito", false, "profile"], + ] as const)( + "holds the first %s request until its Chrome identity is ready", + ([profile, persistent, namespace]) => + withManager((manager) => + Effect.gen(function* () { + yield* manager.getBrowserSession(profile, persistent, namespace); + yield* manager.getBrowserSession(profile, persistent, namespace); + expect(previewSession.webRequest.onBeforeSendHeaders).toHaveBeenCalledTimes(1); + const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as ( + details: Partial, + callback: (response: Electron.BeforeSendResponse) => void, + ) => void; + for (const kind of ["webview", "window"]) { + const preview = makeFaviconWebContents({ id: kind === "webview" ? 42 : 43 }); + Object.assign(preview.webContents, { getType: () => kind }); + const overrideStarted = Promise.withResolvers(); + const overrideReady = Promise.withResolvers(); + const request = Promise.withResolvers(); + const send = vi.fn(request.resolve); + const headers = { + "User-Agent": "Electron/44.4.5", + Accept: "text/html", + Referer: "https://app.example/signin", + "Sec-CH-UA-Full-Version-List": '"Electron";v="44.4.5"', + }; + const command = (preview.webContents as Electron.WebContents).debugger + .sendCommand as ReturnType; + command.mockImplementation(async (method: string) => { + if (method === "Emulation.setUserAgentOverride") { + overrideStarted.resolve(); + await overrideReady.promise; + } + }); + intercept( + { + url: "http://127.0.0.1:3001/first", + resourceType: "mainFrame", + webContents: preview.webContents, + requestHeaders: headers, + }, + send, + ); + yield* Effect.promise(() => overrideStarted.promise); + expect(send).not.toHaveBeenCalled(); + overrideReady.resolve(); + const first = yield* Effect.promise(() => request.promise); + expect(send).toHaveBeenCalledTimes(1); + const override = command.mock.calls.find( + ([method]) => method === "Emulation.setUserAgentOverride", + )![1]; + expect(first.cancel).not.toBe(true); + expect(first.requestHeaders).toEqual( + expect.objectContaining({ + "User-Agent": override.userAgent, + "sec-ch-ua": expect.stringContaining('"Google Chrome";v="148"'), + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + Accept: headers.Accept, + Referer: headers.Referer, + "Sec-CH-UA-Full-Version-List": override.userAgentMetadata.fullVersionList + .map( + ({ brand, version }: { brand: string; version: string }) => + `${JSON.stringify(brand)};v=${JSON.stringify(version)}`, + ) + .join(", "), + }), + ); + expect(headers["User-Agent"]).toBe("Electron/44.4.5"); + yield* manager.prepareWebview(preview.webContents); + } + }), + ), + ); + + effectIt.effect( + "releases a native popup's first request without waiting for its document background", + () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.getBrowserSession("default"); + const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as ( + details: Partial, + callback: (response: Electron.BeforeSendResponse) => void, + ) => void; + const opener = makeFaviconWebContents(); + fromId.mockReturnValue(opener.webContents); + yield* manager.createTab("popup_opener"); + yield* manager.registerWebview("popup_opener", 42); + yield* manager.prepareWebview(opener.webContents); + const popup = makeFaviconWebContents({ id: 43 }); + Object.assign(popup.webContents, { getType: () => "window" }); + const identityStarted = Promise.withResolvers(); + const identityReady = Promise.withResolvers(); + const request = Promise.withResolvers(); + const send = vi.fn(request.resolve); + const command = (popup.webContents as Electron.WebContents).debugger + .sendCommand as ReturnType; + command.mockImplementation(async (method: string) => { + if (method === "Emulation.setDefaultBackgroundColorOverride") await request.promise; + if (method === "Emulation.setUserAgentOverride") { + identityStarted.resolve(); + await identityReady.promise; + } + }); + opener.listeners.get("did-create-window")!({ webContents: popup.webContents } as never); + intercept( + { + url: "http://127.0.0.1:3001/popup", + resourceType: "mainFrame", + webContents: popup.webContents, + requestHeaders: { Referer: "https://app.example/signin" }, + }, + send, + ); + yield* Effect.promise(() => identityStarted.promise); + expect(send).not.toHaveBeenCalled(); + identityReady.resolve(); + const released = yield* Effect.promise(() => request.promise); + expect(send).toHaveBeenCalledTimes(1); + expect(command).not.toHaveBeenCalledWith( + "Emulation.setDefaultBackgroundColorOverride", + expect.anything(), + ); + expect(released.requestHeaders).toEqual( + expect.objectContaining({ + "User-Agent": "Mozilla/5.0 Chrome/148.0.0.0 Safari/537.36", + "sec-ch-ua": expect.stringContaining('"Google Chrome";v="148"'), + Referer: "https://app.example/signin", + }), + ); + }), + ), + ); + + effectIt.effect("only supplies low entropy Chrome hints on trustworthy destinations", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.getBrowserSession("default"); + const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as ( + details: Partial, + callback: (response: Electron.BeforeSendResponse) => void, + ) => void; + for (const [url, trusted] of [ + ["https://example.com", true], + ["http://localhost:3001", true], + ["http://app.localhost:3001", true], + ["http://[::1]:3001", true], + ["http://127.0.0.42:3001", true], + ["http://100.65.180.100:3001", false], + ["http://example.com", false], + ] as const) { + const responseReady = Promise.withResolvers(); + intercept({ url, requestHeaders: { Accept: "text/html" } }, responseReady.resolve); + const response = yield* Effect.promise(() => responseReady.promise); + expect(response.requestHeaders?.["sec-ch-ua"]).toBe( + trusted + ? '"Chromium";v="148", "Google Chrome";v="148", "Not A(Brand";v="99"' + : undefined, + ); + expect(response.requestHeaders?.["sec-ch-ua-full-version-list"]).toBeUndefined(); + } + }), + ), + ); + + effectIt.effect( + "keeps browsing while DevTools owns the debugger and restores identity on close", + () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.getBrowserSession("default"); + const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as ( + details: Partial, + callback: (response: Electron.BeforeSendResponse) => void, + ) => void; + const preview = makeFaviconWebContents(); + let devToolsOpened = false; + Object.assign(preview.webContents, { + isDevToolsOpened: () => devToolsOpened, + openDevTools: () => { + devToolsOpened = true; + }, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_devtools"); + yield* manager.registerWebview("tab_devtools", 42); + yield* manager.prepareWebview(preview.webContents); + const command = (preview.webContents as Electron.WebContents).debugger + .sendCommand as ReturnType; + expect( + command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"), + ).toHaveLength(1); + yield* manager.openDevTools("tab_devtools"); + const request = Promise.withResolvers(); + intercept( + { + url: "https://example.com", + resourceType: "mainFrame", + webContents: preview.webContents, + requestHeaders: {}, + }, + request.resolve, + ); + expect((yield* Effect.promise(() => request.promise)).cancel).not.toBe(true); + expect( + command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"), + ).toHaveLength(1); + const restored = Promise.withResolvers(); + command.mockImplementation(async (method: string) => { + if (method === "Emulation.setUserAgentOverride") restored.resolve(); + }); + devToolsOpened = false; + preview.listeners.get("devtools-closed")!(); + yield* Effect.promise(() => restored.promise); + expect( + command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"), + ).toHaveLength(2); + }), + ), + ); + + effectIt.effect("cancels a first navigation when Chrome identity preparation fails", () => + withManager((manager) => + Effect.gen(function* () { + yield* manager.getBrowserSession("default"); + const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as ( + details: Partial, + callback: (response: Electron.BeforeSendResponse) => void, + ) => void; + const preview = makeFaviconWebContents(); + const command = (preview.webContents as Electron.WebContents).debugger + .sendCommand as ReturnType; + command.mockRejectedValue(new Error("identity override failed")); + const responseReady = Promise.withResolvers(); + intercept( + { url: "https://example.com", webContents: preview.webContents, requestHeaders: {} }, + responseReady.resolve, + ); + expect(yield* Effect.promise(() => responseReady.promise)).toEqual({ cancel: true }); + }), + ), + ); + effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () => withManager((manager) => Effect.gen(function* () { @@ -804,7 +1061,23 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_editing", 42); const popup = makeFaviconWebContents({ id: 43 }); + const popupReady = Promise.withResolvers(); + const popupCommands = (popup.webContents as Electron.WebContents).debugger + .sendCommand as ReturnType; + popupCommands.mockImplementation(async (method: string) => { + if (method === "Emulation.setUserAgentOverride") popupReady.resolve(); + }); preview.listeners.get("did-create-window")!({ webContents: popup.webContents } as never); + yield* Effect.promise(() => popupReady.promise); + expect(popupCommands).toHaveBeenCalledWith( + "Emulation.setUserAgentOverride", + expect.objectContaining({ + userAgent: "Mozilla/5.0 Chrome/148.0.0.0 Safari/537.36", + userAgentMetadata: expect.objectContaining({ + brands: expect.arrayContaining([{ brand: "Google Chrome", version: "148" }]), + }), + }), + ); expect( (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts, ).toHaveBeenCalledWith(true); @@ -945,6 +1218,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "about:blank", getTitle: () => "", isLoadingMainFrame: () => false, @@ -1038,6 +1312,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => destroyed, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "http://localhost:3200/", getTitle: () => "Preview", isLoadingMainFrame: () => false, @@ -1541,6 +1816,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => url, getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -1611,6 +1887,7 @@ describe("PreviewManager", () => { id: 43, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => url, getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -1652,6 +1929,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -1699,6 +1977,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -1755,6 +2034,7 @@ describe("PreviewManager", () => { isDestroyed: () => false, isDevToolsOpened: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -1850,6 +2130,7 @@ describe("PreviewManager", () => { return false; }, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "http://localhost:5173/README.md", getTitle: () => "README.md", isLoadingMainFrame: () => true, @@ -1975,6 +2256,7 @@ describe("PreviewManager", () => { isDestroyed: () => false, isDevToolsOpened: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -2318,6 +2600,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => url, getTitle: () => "localhost:5733", isLoadingMainFrame: () => loading, @@ -2433,6 +2716,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com:8443/path?query=value", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -3001,6 +3285,7 @@ describe("PreviewManager", () => { ), isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => `https://example.com/${id}`, getTitle: () => `Example ${id}`, isLoadingMainFrame: () => false, @@ -3463,6 +3748,7 @@ describe("PreviewManager", () => { executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -3962,6 +4248,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -4108,6 +4395,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -4250,6 +4538,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, @@ -4335,6 +4624,7 @@ describe("PreviewManager", () => { id: 42, isDestroyed: () => false, getType: () => "webview", + getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36", getURL: () => "https://example.com", getTitle: () => "Example", isLoadingMainFrame: () => false, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2d26ce591a93c6d1ecb7ce21e07b0b44af1e555a..0d3c44d31566eb986a48ff08f71022b54228e0bc 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -27,6 +27,7 @@ import type { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; +import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { BrowserWindow, @@ -38,6 +39,7 @@ import { shell, webContents, } from "electron"; +import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -545,6 +547,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const fileSystem = yield* FileSystem.FileSystem; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const hostPlatform = yield* HostProcess.Platform; + const hostArchitecture = yield* HostProcess.Architecture; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const parentScope = yield* Scope.Scope; @@ -1186,21 +1189,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("message", onMessage); wcDebugger.attach("1.3"); }); - // Electron gives `` guests a transparent base background, and - // Chromium only paints a dark canvas for dark color-scheme pages over an - // opaque base. Without this, dark-scheme pages with no background of - // their own (text/plain, e.g. .md files) render white text on white. - // White matches the webview's existing white backing, so light pages look - // the same; Chromium still swaps in its dark canvas for dark-scheme pages. - // Sent first because a document that paints before it arrives keeps the - // transparent base until its next load. - yield* attemptPromise( - { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, - () => - wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", { - color: { r: 255, g: 255, b: 255, a: 1 }, - }), - ); + if (wc.getType() === "webview") { + // Webview guests need an opaque base for Chromium's dark document canvas. + yield* attemptPromise( + { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, + () => + wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", { + color: { r: 255, g: 255, b: 255, a: 1 }, + }), + ); + } + const identity = browserChromeIdentity(wc.getUserAgent(), { + platform: hostPlatform, + arch: hostArchitecture, + chromeVersion: process.versions.chrome, + }); + if (identity) { + yield* attemptPromise( + { operation: "initializeDebugger.userAgent", webContentsId: wc.id }, + () => wcDebugger.sendCommand("Emulation.setUserAgentOverride", identity), + ); + } return [ control, replaceMap(sessions, (copy) => { @@ -1523,6 +1532,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // otherwise spawn native windows without limit. Nothing in an OAuth flow // opens a second popup, so the chain stops at the first one. const windowCreated = (window: Electron.BrowserWindow): void => { + const popup = window.webContents; + popup.once("destroyed", () => runFork(detachControlSession(popup.id))); + runFork(ensureControlSession(popup).pipe(Effect.ignoreCause({ log: true }))); window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("before-input-event", (_event, input) => { @@ -3410,6 +3422,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openDevTools, pickElement, prepareWebview, + ensureControlSession, reapplyZoom, refresh, registerWebview, @@ -3692,10 +3705,12 @@ export const make = Effect.gen(function* PreviewManagerMake() { const browserSession = yield* BrowserSession.BrowserSession; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const passkeys = yield* PreviewPasskeys.PreviewPasskeys; - const downloadSessions = new WeakSet(); + const preparedSessions = new WeakSet(); + const hostPlatform = yield* HostProcess.Platform; + const hostArchitecture = yield* HostProcess.Architecture; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const runForkDownload = Effect.runForkWith(yield* Effect.context()); + const runFork = Effect.runForkWith(yield* Effect.context()); /** Puts a copy in Downloads under a name that never replaces a file already there. */ const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* ( source: string, @@ -3715,11 +3730,78 @@ export const make = Effect.gen(function* PreviewManagerMake() { return; } }); - // Server tabs save downloads where the server's engine reads them. Downloads - // the person starts in a tab the server is not driving keep Electron's dialog. - const placeServerDownloads = (session: Electron.Session) => { - if (downloadSessions.has(session)) return; - downloadSessions.add(session); + const prepareBrowserSession = (session: Electron.Session) => { + if (preparedSessions.has(session)) return; + const identity = browserChromeIdentity(session.getUserAgent(), { + platform: hostPlatform, + arch: hostArchitecture, + chromeVersion: process.versions.chrome, + }); + if (identity) { + const metadata = identity.userAgentMetadata; + const formatBrands = (brands: typeof metadata.brands) => + brands + .map(({ brand, version }) => `${JSON.stringify(brand)};v=${JSON.stringify(version)}`) + .join(", "); + const lowEntropyHints = { + "sec-ch-ua": formatBrands(metadata.brands), + "sec-ch-ua-mobile": metadata.mobile ? "?1" : "?0", + "sec-ch-ua-platform": JSON.stringify(metadata.platform), + }; + const hints: Record = { + ...lowEntropyHints, + "sec-ch-ua-full-version": JSON.stringify(metadata.fullVersionList[0]?.version), + "sec-ch-ua-full-version-list": formatBrands(metadata.fullVersionList), + "sec-ch-ua-platform-version": JSON.stringify(metadata.platformVersion), + "sec-ch-ua-arch": JSON.stringify(metadata.architecture), + "sec-ch-ua-bitness": JSON.stringify(metadata.bitness), + "sec-ch-ua-model": JSON.stringify(metadata.model), + "sec-ch-ua-wow64": metadata.wow64 ? "?1" : "?0", + }; + session.webRequest.onBeforeSendHeaders( + { urls: ["http://*/*", "https://*/*"] }, + (details, callback) => { + const contents = details.webContents; + const prepare = + contents && (contents.isDestroyed() || !contents.isDevToolsOpened()) + ? operations.ensureControlSession(contents).pipe(Effect.asVoid) + : Effect.void; + runFork( + prepare.pipe( + Effect.matchCause({ + onFailure: () => callback({ cancel: true }), + onSuccess: () => { + const url = new URL(details.url); + const sendHints = + url.protocol === "https:" || + isLocalLoopbackHost(url.hostname) || + normalizeHostname(url.hostname).endsWith(".localhost"); + // Chromium can snapshot these headers before the asynchronous CDP override completes. + const headers = Object.fromEntries( + Object.entries(details.requestHeaders) + .filter(([name]) => name.toLowerCase() !== "user-agent") + .map(([name, value]) => [ + name, + sendHints ? (hints[name.toLowerCase()] ?? value) : value, + ]), + ); + headers["User-Agent"] = identity.userAgent; + if (sendHints) { + for (const [name, value] of Object.entries(lowEntropyHints)) { + const existing = Object.keys(headers).find( + (key) => key.toLowerCase() === name, + ); + headers[existing ?? name] = value; + } + } + callback({ requestHeaders: headers }); + }, + }), + ), + ); + }, + ); + } session.on("will-download", (_event, item, source) => { // Decided before the server's path is set: placing it consumes the guid. const human = browserHost.humanStartedDownload(source); @@ -3727,7 +3809,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { // The server keeps its copy for agents; the person who clicked gets theirs. item.once("done", (_doneEvent, state) => { if (state !== "completed") return; - runForkDownload( + runFork( copyToDownloads(item.getSavePath(), item.getFilename()).pipe( Effect.catch((error) => Effect.logWarning("preview download copy failed", { cause: error }), @@ -3736,6 +3818,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ); }); }); + preparedSessions.add(session); }; const operations = yield* makeNativeOperations( @@ -3755,7 +3838,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), ), ); - placeServerDownloads(session); + prepareBrowserSession(session); passkeys.installSessionHandlers(session); return session; }, diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 5f5c94b83071129c2712c7b8efc91fa150c6481e..e6582642cc8c00f944b2bab68f7fd90a64f4d168 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -3,7 +3,7 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; import { constVoid } from "effect/Function"; import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; -import * as NodeOS from "node:os"; +import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; import * as NodePath from "node:path"; import type { Browser, BrowserContext, CDPSession } from "playwright-core"; @@ -24,48 +24,6 @@ interface Options { readonly onContextClose?: (context: BrowserContext) => void; } -const UA_PLATFORMS: Partial> = { - darwin: "macOS", - linux: "Linux", - win32: "Windows", -}; - -/** - * The headless shell's identity as the Chrome it is built from: its user agent - * without `HeadlessChrome`, and client hints whose brands agree with it. Sites - * block a user agent that disagrees with `Sec-CH-UA`, so both change together. - */ -const chromeIdentity = ( - headlessUserAgent: string, - host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string }, -) => { - const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent); - const fullVersion = version?.[1]; - const major = version?.[2]; - if (!version || !fullVersion || !major) return null; - // Chrome's GREASE brand and order for this major version, as the shell reports them. - const brands = (names: (version: string) => string) => [ - { brand: "Chromium", version: names(major) }, - { brand: "Google Chrome", version: names(major) }, - { brand: "Not A(Brand", version: names("99") }, - ]; - return { - // Chrome reduces its user agent to the major version. - userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`), - userAgentMetadata: { - brands: brands((value) => value), - fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)), - platform: UA_PLATFORMS[host.platform] ?? "Unknown", - platformVersion: host.platform === "linux" ? host.release : "", - architecture: host.arch.startsWith("arm") ? "arm" : "x86", - bitness: host.arch.endsWith("64") ? "64" : "32", - model: "", - mobile: false, - wow64: false, - }, - }; -}; - /** * Presents a headless page as plain Chrome. Applies to the page, its frames, * and its workers; a popup gets its own call once it becomes a tab. @@ -75,7 +33,7 @@ export const presentAsChrome = async ( host: { readonly platform: NodeJS.Platform; readonly arch: string }, ) => { const { userAgent } = await cdp.send("Browser.getVersion"); - const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() }); + const identity = browserChromeIdentity(userAgent, host); if (identity) await cdp.send("Emulation.setUserAgentOverride", identity); }; diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 90246520588c32b1368372de4eaef6f16258faf7..e0f3ef8656d8efb755a219900f481642101ac88f 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -34,6 +34,9 @@ export function BrowserSurfaceSlot(props: { const update = () => { const rect = element.getBoundingClientRect(); const presentation = presentationRef.current; + const sheet = element.closest('[data-slot="sheet-viewport"]'); + const sheetLayer = sheet ? Number(window.getComputedStyle(sheet).zIndex) : 0; + const zIndex = Math.max(presentation.zIndex, sheetLayer + 1); const presented = lease.present( { x: Math.round(rect.x), @@ -43,7 +46,7 @@ export function BrowserSurfaceSlot(props: { }, presentation.visible && rect.width > 0 && rect.height > 0, presentation.cornerRadius, - presentation.zIndex, + zIndex, ); if (presentation.visible && !presented) { lease.release(); @@ -57,7 +60,7 @@ export function BrowserSurfaceSlot(props: { }, rect.width > 0 && rect.height > 0, presentation.cornerRadius, - presentation.zIndex, + zIndex, ); } }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 452b6800759b3a60b83b4562c9adc8ac3ce45e90..84b44a2788bff32d3109da9e514173e02caf06a9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -274,6 +274,7 @@ import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; +import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator"; import { usePreviewSession } from "./preview/usePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -11749,6 +11750,7 @@ export default function ChatView(props: ChatViewProps) { data-chat-composer-stack="true" className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)" > + {isDraftHeroState ? (
({ + navigate: vi.fn(async () => undefined), + mute: vi.fn(async (_tabId: string, _muted: boolean) => undefined), + open: vi.fn(), + toast: vi.fn(), +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mocks.navigate })); +vi.mock("./previewBridge", () => ({ previewBridge: { setAudioMuted: mocks.mute } })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openBrowser: mocks.open }) }, +})); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: mocks.toast } })); + +import { BrowserAudioIndicator } from "./BrowserAudioIndicator"; +import { AppAtomRegistryProvider } from "~/rpc/atomRegistry"; +import { + applyPreviewDesktopState, + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, + type DesktopPreviewOverlay, +} from "~/previewStateStore"; +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +const ref = scopeThreadRef(EnvironmentId.make("env-audio"), ThreadId.make("background-chat")); +const snapshot: PreviewSessionSnapshot = { + threadId: ref.threadId, + tabId: "tab-audio", + navStatus: { _tag: "Success", url: "https://example.test/", title: "Music" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-14T22:00:00.000Z", +}; +const overlay: DesktopPreviewOverlay = { + hasWebContents: true, + canGoBack: false, + canGoForward: false, + loading: false, + zoomFactor: 1, + pictureInPicture: false, + colorScheme: "system", + audioMuted: false, + audible: true, + controller: "none", + favicon: null, +}; +let container: HTMLDivElement; +let root: Root; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + resetPreviewStateForTests(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(() => + root.render( + + + , + ), + ); +}); +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); +}); + +async function startAudio() { + await act(() => { + applyPreviewServerSnapshot(ref, snapshot); + applyPreviewDesktopState(ref, snapshot.tabId, overlay); + }); +} + +it("shows background-chat audio and removes the warning when muted, stopped, or disconnected", async () => { + expect(container.textContent).toBe(""); + await startAudio(); + expect(container.querySelector('[role="status"]')?.textContent).toBe("Browser audio playing"); + expect(container.textContent).toContain("Music"); + for (const change of [{ audioMuted: true }, { audible: false }, { hasWebContents: false }]) { + await act(() => applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, ...change })); + expect(container.textContent).toBe(""); + await act(() => applyPreviewDesktopState(ref, snapshot.tabId, overlay)); + } +}); + +it("mutes the sounding tab using its scoped runtime identity", async () => { + await startAudio(); + const runtimeTabId = previewRuntimeTabId( + ref, + readThreadPreviewState(ref).serverEpoch, + snapshot.tabId, + ); + await act(async () => + container.querySelector('[aria-label="Mute Music"]')!.click(), + ); + expect(mocks.mute).toHaveBeenCalledExactlyOnceWith(runtimeTabId, true); + await act(() => applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, audioMuted: true })); + expect(container.textContent).toBe(""); +}); + +it("opens the source chat and browser tab", async () => { + await startAudio(); + await act(async () => + container.querySelector('[aria-label="Open browser: Music"]')!.click(), + ); + expect(mocks.navigate).toHaveBeenCalledOnce(); + expect(mocks.open).toHaveBeenCalledExactlyOnceWith(ref, snapshot.tabId); +}); + +it("keeps the warning and enables retry after muting fails", async () => { + await startAudio(); + mocks.mute.mockRejectedValueOnce(new Error("guest unavailable")); + const mute = container.querySelector('[aria-label="Mute Music"]')!; + await act(async () => mute.click()); + expect(mute.disabled).toBe(false); + expect(container.textContent).toContain("Browser audio playing"); + expect(mocks.toast).toHaveBeenCalledExactlyOnceWith({ + type: "error", + title: "Couldn’t mute browser audio", + }); +}); diff --git a/apps/web/src/components/preview/BrowserAudioIndicator.tsx b/apps/web/src/components/preview/BrowserAudioIndicator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6882e438df1d5d46d53ce04ecc060ec971e67952 --- /dev/null +++ b/apps/web/src/components/preview/BrowserAudioIndicator.tsx @@ -0,0 +1,94 @@ +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useNavigate } from "@tanstack/react-router"; +import { Volume2Icon } from "lucide-react"; +import { useState } from "react"; + +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { Button } from "~/components/ui/button"; +import { toastManager } from "~/components/ui/toast"; +import { useActivePreviewSessions } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { buildThreadRouteParams } from "~/threadRoutes"; +import { previewBridge } from "./previewBridge"; + +/** Remains visible when a playing browser is behind another panel or chat. */ +export function BrowserAudioIndicator() { + const sessions = useActivePreviewSessions(); + const navigate = useNavigate(); + const [muting, setMuting] = useState>(new Set()); + const tabs = Object.entries(sessions).flatMap(([threadKey, state]) => { + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef) return []; + return Object.values(state.sessions).flatMap((snapshot) => { + const overlay = state.desktopByTabId[snapshot.tabId]; + if (!overlay?.hasWebContents || !overlay.audible || overlay.audioMuted) return []; + const title = + snapshot.navStatus._tag === "Idle" + ? "Browser" + : snapshot.navStatus.title || snapshot.navStatus.url; + return [ + { + threadRef, + tabId: snapshot.tabId, + runtimeTabId: previewRuntimeTabId(threadRef, state.serverEpoch, snapshot.tabId), + title, + }, + ]; + }); + }); + + if (tabs.length === 0) return null; + + return ( +
+
+ + Browser audio playing +
+ {tabs.map((tab) => ( +
+ {tab.title} + + +
+ ))} +
+ ); +} diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index f257a8e38c61d08c37945f1dccb1f080835dca54..33684024f6d7bf3fe30823ee03b319c1c408ae48 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -1,9 +1,8 @@ /** * Per-thread preview UI state. * - * Each thread owns an independent atom. Most consumers read exactly one - * thread; the desktop browser host uses the aggregate session atom because it - * is the one place that must enumerate every live preview tab. + * Each thread owns an independent atom so ordinary readers avoid updates + * from unrelated tabs. Browser hosting and audio indicators share an aggregate. */ import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -75,8 +74,7 @@ export const previewStateAtom = Atom.family((threadKey: string) => ), ); -// Only the Electron browser host needs a cross-thread view. Keep that index -// separate so thread-local readers never subscribe to unrelated previews. +// Keep the global index separate so thread-local readers avoid unrelated previews. interface ActivePreviewThreadIndex { readonly keys: ReadonlySet; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 029aabda6c0498f32863e6bb9b874784494f5fc3..090c92c5a643c0178021dae9997e90575e3d5314 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -490,6 +490,10 @@ "./PtyAdapter": { "types": "./src/PtyAdapter.ts", "import": "./src/PtyAdapter.ts" + }, + "./browserChromeIdentity": { + "types": "./src/browserChromeIdentity.ts", + "import": "./src/browserChromeIdentity.ts" } }, "scripts": { diff --git a/packages/shared/src/browserChromeIdentity.test.ts b/packages/shared/src/browserChromeIdentity.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c98c30244a1db7d73dd362b70077a3390e651bc --- /dev/null +++ b/packages/shared/src/browserChromeIdentity.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; +import { browserChromeIdentity } from "./browserChromeIdentity.ts"; + +const host = { platform: "darwin", arch: "arm64" } as const; + +describe("browserChromeIdentity", () => { + it("keeps HTTP and navigator identity consistent with current Chrome client hints", () => { + const identity = browserChromeIdentity( + "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", + host, + ); + expect(identity?.userAgent).toBe( + "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", + ); + expect(identity?.userAgentMetadata).toMatchObject({ + platform: "macOS", + architecture: "arm", + bitness: "64", + mobile: false, + brands: expect.arrayContaining([{ brand: "Google Chrome", version: "148" }]), + fullVersionList: expect.arrayContaining([ + { brand: "Google Chrome", version: "148.0.7734.19" }, + ]), + }); + }); + + it("uses the same identity for headless pages and popup control sessions", () => { + const identity = browserChromeIdentity( + "Mozilla/5.0 HeadlessChrome/151.0.8000.20 Safari/537.36", + host, + ); + expect(identity?.userAgent).toBe("Mozilla/5.0 Chrome/151.0.0.0 Safari/537.36"); + const popup = browserChromeIdentity(identity!.userAgent, { + ...host, + chromeVersion: "151.0.8000.20", + }); + expect(popup).toEqual(identity); + }); + + it("does not invent a Chrome version when Chromium reports none", () => { + expect(browserChromeIdentity("Electron", host)).toBeNull(); + }); +}); diff --git a/packages/shared/src/browserChromeIdentity.ts b/packages/shared/src/browserChromeIdentity.ts new file mode 100644 index 0000000000000000000000000000000000000000..d131dab8621545f221828ef20082a315dfb463f9 --- /dev/null +++ b/packages/shared/src/browserChromeIdentity.ts @@ -0,0 +1,45 @@ +const PLATFORMS: Partial> = { + darwin: "macOS", + linux: "Linux", + win32: "Windows", +}; + +/** HTTP, navigator and client hints describe the same bundled Chromium version. */ +export function browserChromeIdentity( + nativeUserAgent: string, + host: { + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly chromeVersion?: string; + }, +) { + const version = /(?:Headless)?Chrome\/(\d[\d.]*)/.exec(nativeUserAgent); + const fullVersion = host.chromeVersion ?? version?.[1]; + const major = fullVersion?.split(".")[0]; + if (!version || !fullVersion || !major) return null; + + const brands = [ + { brand: "Chromium", version: major }, + { brand: "Google Chrome", version: major }, + { brand: "Not A(Brand", version: "99" }, + ]; + return { + userAgent: nativeUserAgent + .replace(/\s+(?:Electron|T3Code[^\s/]*)\/[^\s]+/gi, "") + .replace(version[0], `Chrome/${major}.0.0.0`), + userAgentMetadata: { + brands, + fullVersionList: brands.map(({ brand, version }) => ({ + brand, + version: version === major ? fullVersion : `${version}.0.0.0`, + })), + platform: PLATFORMS[host.platform] ?? "Unknown", + platformVersion: "", + architecture: host.arch.startsWith("arm") ? "arm" : "x86", + bitness: host.arch.endsWith("64") ? "64" : "32", + model: "", + mobile: false, + wow64: false, + }, + }; +}