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..05ec2670bb9 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, useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { stopBrowserRecording, useActiveBrowserRecordingTabId } from "./browserRecording"; @@ -43,6 +44,14 @@ export function HostedBrowserWebview(props: { const { threadRef, tabId, initialUrl, viewport, zoomFactor } = props; const config = usePreviewWebviewConfig(threadRef.environmentId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); + // 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); @@ -174,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, @@ -206,6 +215,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..9b8e6501a37 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,27 @@ export function PreviewMoreMenu({ void bridge.clearCache().catch(() => undefined)}> Clear cache + + { + const enabled = Boolean(checked); + updateClientSettings({ previewDisableWebSecurity: enabled }); + toastManager.add( + stackedThreadToast({ + type: "info", + 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.", + }), + ); + }} + > + Disable browser WebSecurity + ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..be164005e0a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,6 +72,7 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + previewDisableWebSecurity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -559,6 +560,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + previewDisableWebSecurity: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),