From 670667b77659aa6b295bbd7f612262a452d0ce89 Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:51:05 +0200 Subject: [PATCH 1/5] Add preview browser toggle to disable web security (CORS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-machine client setting, previewDisableWebSecurity (default off), surfaced as a checkbox in the preview browser's three-dot menu. When enabled, preview tags render Electron's disablewebsecurity attribute, turning off the same-origin policy inside the guest page — the embedded-browser equivalent of Chrome's --disable-web-security. Applies to preview tabs opened after toggling. --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/browser/HostedBrowserWebview.tsx | 7 ++++ .../components/preview/PreviewMoreMenu.tsx | 32 ++++++++++++++++++- packages/contracts/src/settings.ts | 7 ++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..d8db331d785 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], providerModelPreferences: {}, + previewDisableWebSecurity: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index a72da49c6fa..90a67190bc1 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { getClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; @@ -43,6 +44,8 @@ export function HostedBrowserWebview(props: { const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); + // Frozen per webview: Electron reads the attribute only at guest attach. + const [disableWebSecurity] = useState(() => getClientSettings().previewDisableWebSecurity); const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); @@ -206,6 +209,10 @@ export function HostedBrowserWebview(props: { partition={config.partition} webpreferences={config.webPreferences} {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} + {...(disableWebSecurity + ? // string, not boolean: React omits boolean values on this unknown attribute + { disablewebsecurity: "true" as unknown as boolean } + : {})} data-preview-tab={tabId} data-preview-viewport-mode={effectiveViewport._tag} data-preview-viewport-key={browserViewportSettingKey(effectiveViewport)} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 13ddcf57e9e..7790ea1c9b1 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -3,8 +3,17 @@ import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; import { Button } from "~/components/ui/button"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuCheckboxItem, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "~/components/ui/menu"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { previewBridge } from "./previewBridge"; @@ -37,6 +46,8 @@ export function PreviewMoreMenu({ deviceToolbarVisible, onToggleDeviceToolbar, }: Props) { + const disableWebSecurity = useClientSettings((settings) => settings.previewDisableWebSecurity); + const updateClientSettings = useUpdateClientSettings(); if (!previewBridge) return null; const bridge = previewBridge; const tabDisabled = !tabId || !hasWebContents; @@ -127,6 +138,25 @@ export function PreviewMoreMenu({ void bridge.clearCache().catch(() => undefined)}> Clear cache + + { + const enabled = Boolean(checked); + updateClientSettings({ previewDisableWebSecurity: enabled }); + toastManager.add( + stackedThreadToast({ + type: "info", + title: enabled ? "Web security disabled" : "Web security enabled", + description: enabled + ? "CORS is off for preview tabs opened from now on. Reopen this tab to apply." + : "Applies to preview tabs opened from now on. Reopen this tab to apply.", + }), + ); + }} + > + Disable web security (CORS) + ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..440ff50020f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,6 +72,12 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Renders preview tags with Electron's `disablewebsecurity` + // attribute, turning off the same-origin policy (CORS) inside the guest + // page — the embedded-browser equivalent of Chrome's + // `--disable-web-security`. Local development escape hatch; applies to + // webviews created after the change. + previewDisableWebSecurity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -559,6 +565,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + previewDisableWebSecurity: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 905e58000031b8df81096170d7831b431736ae6b Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:54:56 +0200 Subject: [PATCH 2/5] Drop schema comment and rename toggle to 'Disable browser WebSecurity' --- apps/web/src/components/preview/PreviewMoreMenu.tsx | 4 ++-- packages/contracts/src/settings.ts | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 7790ea1c9b1..cb59e0d327c 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -147,7 +147,7 @@ export function PreviewMoreMenu({ toastManager.add( stackedThreadToast({ type: "info", - title: enabled ? "Web security disabled" : "Web security enabled", + title: enabled ? "Browser WebSecurity disabled" : "Browser WebSecurity enabled", description: enabled ? "CORS is off for preview tabs opened from now on. Reopen this tab to apply." : "Applies to preview tabs opened from now on. Reopen this tab to apply.", @@ -155,7 +155,7 @@ export function PreviewMoreMenu({ ); }} > - Disable web security (CORS) + Disable browser WebSecurity diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 440ff50020f..be164005e0a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,11 +72,6 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), - // Renders preview tags with Electron's `disablewebsecurity` - // attribute, turning off the same-origin policy (CORS) inside the guest - // page — the embedded-browser equivalent of Chrome's - // `--disable-web-security`. Local development escape hatch; applies to - // webviews created after the change. previewDisableWebSecurity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), From b0042adfe1e2be3ec5bde0457ceb5a71b807859f Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:47:00 +0200 Subject: [PATCH 3/5] Fix hydration race in preview web-security capture Gate the frozen disableWebSecurity value on client-settings hydration so a preview webview mounted during startup/session-restore reads the persisted setting instead of the default false snapshot. Subscribing via useClientSettingsHydrated both triggers hydration and re-renders on completion; the webview render is held until the value is frozen. --- apps/web/src/browser/HostedBrowserWebview.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 90a67190bc1..05ec2670bb9 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; -import { getClientSettings } from "~/hooks/useSettings"; +import { getClientSettings, useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; @@ -44,8 +44,14 @@ export function HostedBrowserWebview(props: { const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); - // Frozen per webview: Electron reads the attribute only at guest attach. - const [disableWebSecurity] = useState(() => getClientSettings().previewDisableWebSecurity); + // Freeze once hydration lands so a startup/session-restore webview reads the + // persisted value, not the default `false` snapshot. Electron reads the + // attribute only at guest attach, so the value stays frozen afterwards. + const settingsHydrated = useClientSettingsHydrated(); + const [disableWebSecurity, setDisableWebSecurity] = useState(null); + if (disableWebSecurity === null && settingsHydrated) { + setDisableWebSecurity(getClientSettings().previewDisableWebSecurity); + } const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); const webviewRef = useRef(null); @@ -177,7 +183,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [tabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!config || disableWebSecurity === null) return null; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, From 07e77550d3afaaeb0a310728bfee8c35cbedf361 Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:55:35 +0200 Subject: [PATCH 4/5] Render preview WebSecurity toggle as a visible switch Use MenuCheckboxItem's switch variant so the toggle shows an always-visible on/off switch instead of a checkmark-only-when-checked row, and keep the menu open on toggle so it animates in place. --- apps/web/src/components/preview/PreviewMoreMenu.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index cb59e0d327c..9b8e6501a37 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -140,6 +140,8 @@ export function PreviewMoreMenu({ { const enabled = Boolean(checked); From 29125ea63591d8a1f88cb192d745f70461004daf Mon Sep 17 00:00:00 2001 From: Karabach <12261380+akarabach@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:42:06 +0200 Subject: [PATCH 5/5] Re-register preview webview when hydration gate opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hydration gate delays the mount until previewDisableWebSecurity is captured. The registration effect keyed only on [config, tabId], so when the webview mounted on a later hydration-triggered render (config already resolved), registerWebview and its did-attach/dom-ready listeners never attached — breaking navigation and the desktop overlay for that tab. Add disableWebSecurity to the effect deps so it re-runs when the element finally mounts. --- apps/web/src/browser/HostedBrowserWebview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 05ec2670bb9..2b00baa7621 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -119,7 +119,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); }; - }, [config, tabId]); + }, [config, tabId, disableWebSecurity]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect;