From 1215cfec50155cf90866aaadd7655d73d8628361 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 11 Aug 2026 02:54:40 -0700 Subject: [PATCH] [eric] browser: detect Electron by the preload bridge not the UA string, so the Electron token can be stripped app-wide and cross-origin sign-in iframes stop reading as an embedded browser (ENG-238) --- electron/main.js | 9 +++++++++ frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx | 3 ++- .../src/app/pages/Dashboard/cards/DashboardViewCard.tsx | 7 ++++--- .../Dashboard/hooks/interaction/useWebviewSuspend.ts | 9 +++++---- frontend/src/app/pages/Views/ViewPreview.tsx | 7 ++++--- frontend/src/shared/isElectron.ts | 8 ++++++++ 6 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 frontend/src/shared/isElectron.ts diff --git a/electron/main.js b/electron/main.js index e13cd9a1..502115ea 100644 --- a/electron/main.js +++ b/electron/main.js @@ -507,6 +507,15 @@ app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); // taking the app down with it. Fails quiet: at worst a brief compositor blip. app.commandLine.appendSwitch('disable-gpu-process-crash-limit'); +// A cross-origin sign-in iframe (Google's GSI button) reads navigator.userAgent in its OWN JS, and +// an out-of-process frame gets that value from the app-wide fallback, NOT the webview `useragent` +// attribute or any session override (both miss OOPIFs, measured). With the Electron token present +// Google refuses sign-in as an "embedded browser" (ENG-238). Strip only the Electron token here so +// the openswarm product token stays. Safe because the frontend now detects Electron via the +// window.openswarm preload bridge, not this string (see frontend isElectron.ts); nothing reads the +// UA for "Electron" anymore, and the backend never gated on it. +try { app.userAgentFallback = app.userAgentFallback.replace(/\s*Electron\/\S+/i, ''); } catch (_) { /* older Electron */ } + let mainWindow = null; let backendProcess = null; let backendRespawns = 0; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 1810ac86..37008172 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { isElectron as detectElectron } from '@/shared/isElectron'; import { requestWebviewAttachSlot, releaseWebviewAttachSlot } from './webviewAttachQueue'; import { createPortal } from 'react-dom'; import { subscribeLiveDrag } from '../hooks/interaction/liveDragChannel'; @@ -154,7 +155,7 @@ function markWindowsWebviewSurvived(): void { } const isWindows = navigator.userAgent.includes('Windows'); -const inElectron = navigator.userAgent.includes('Electron'); +const inElectron = detectElectron(); const isElectron = inElectron && (!isWindows || windowsWebviewEnabled()); // Keep the openswarm/ product token: Google's sign-in flags a BARE Chrome UA as not-genuine-Chrome and blocks it ("browser may not be secure"), but tolerates a UA carrying a product token. Only the Electron token must go (that one Google hard-blocks). diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 0057300e..52d016a3 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { isElectron } from '@/shared/isElectron'; import Box from '@mui/material/Box'; import Fade from '@mui/material/Fade'; import Typography from '@mui/material/Typography'; @@ -55,7 +56,7 @@ const MIN_H = 200; // has no login/scroll state worth keeping, so we just unmount the webview when the card is off-screen or // too small to read, and remount (reload) on return. Asymmetric: resume instantly, suspend after a beat // so panning past a card doesn't reload it. -const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron'); +const inElectron = isElectron(); const APP_PREVIEW_MIN_PX = 260; // below this on-screen width the live page is indistinguishable from a still const APP_PREVIEW_MARGIN_PX = 400; // resume once the card is within this of the viewport const APP_SUSPEND_SETTLE_MS = 1200; @@ -223,7 +224,7 @@ const DashboardViewCard: React.FC = ({ want = false; // reveal-parked: never boot until the first click clears the defer } else if (alwaysLive) { // Actively used (selected, interacting, agent-driven, tiled, fullscreen): pinned, never capped. - if (isElectron) requestAppSlot(cardKey, 0, true); + if (inElectron) requestAppSlot(cardKey, 0, true); want = true; } else { const now = getCanvasState(); @@ -241,7 +242,7 @@ const DashboardViewCard: React.FC = ({ const vh = vpH / now.zoom + 2 * m; onscreen = cardX < vx + vw && cardX + cardWidth > vx && cardY < vy + vh && cardY + cardHeight > vy; } - if (!onscreen || !isElectron) { + if (!onscreen || !inElectron) { want = onscreen; // non-Electron previews are cheap iframes, no renderer to cap } else { // On-screen but passive: go live only if the hard cap has a slot; closest-to-center wins it. diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index e2d83ab1..ca551a10 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { isElectron } from '@/shared/isElectron'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { store } from '@/shared/state/store'; import { @@ -15,7 +16,7 @@ import { guestBudgetHasRoom, wireBrowserLiveCounter } from '@/shared/appWebviewB import { useAppHidden } from './useAppHidden'; import { cardIntersectsViewport, distFromCenter, type Viewport } from './suspendGeometry'; -const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron'); +const inElectron = isElectron(); // Feed the global guest budget the live-browser count, so apps and browsers share ONE ceiling. wireBrowserLiveCounter(() => { @@ -128,12 +129,12 @@ export function useWebviewSuspend( const vpRef = useRef({ panX, panY, zoom, vpW: 1200, vpH: 800 }); // Hidden long enough = the user left; park every idle renderer, working agents keep theirs. - const appHidden = useAppHidden(isElectron); + const appHidden = useAppHidden(inElectron); // Window resize changes the viewport without touching pan/zoom/cards; tick so the evaluation below reruns, or a shrunken window never suspends anything. const [resizeTick, setResizeTick] = useState(0); useEffect(() => { - if (!isElectron) return; + if (!inElectron) return; let t: ReturnType | null = null; const onResize = () => { if (t) clearTimeout(t); @@ -147,7 +148,7 @@ export function useWebviewSuspend( }, []); useEffect(() => { - if (!isElectron) return; + if (!inElectron) return; const el = viewportRef.current; vpRef.current = { panX, panY, zoom, diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index f09aba78..62f336a7 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -1,4 +1,5 @@ import React, { useRef, useEffect, useMemo, useCallback, forwardRef, useImperativeHandle, useState } from 'react'; +import { isElectron } from '@/shared/isElectron'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { Skeleton } from '@/app/components/feedback/Loading'; @@ -12,7 +13,7 @@ import RunInDesktopMessage from '@/app/components/RunInDesktopMessage'; import { registerWebview, unregisterWebview, setActiveTab, type BrowserWebview } from '@/shared/browserRegistry'; // In Electron use to escape iframe restrictions (popups, mic/camera, WebAuthn, cookied fetch); a srcdoc app still uses an iframe (data:text/html breaks webview same-origin); outside Electron we show a launch-correctly message. -const isElectron = navigator.userAgent.includes('Electron'); +const inElectron = isElectron(); // Card previews render small; downscale + JPEG so thumbnails don't bloat the output JSON or every list fetch. const THUMB_WIDTH = 600; @@ -207,7 +208,7 @@ const ViewPreview = forwardRef(({ }, [serveUrl, frontendCode, inputData, backendResult]); // Webview only when we have a real serveUrl; data:text/html for srcdoc breaks same-origin in the Electron sandbox. - const useWebview = isElectron && !!iframeSrc; + const useWebview = inElectron && !!iframeSrc; // Webview's contentDocument is null from the host (separate renderer process); element selection skips it (known regression). useEffect(() => { @@ -473,7 +474,7 @@ const ViewPreview = forwardRef(({ ...style, }} /> - ) : isElectron ? ( + ) : inElectron ? ( // In Electron but not useWebview = a srcdoc app (no serveUrl); the iframe is REQUIRED here (data:text/html breaks webview same-origin), not a fallback. Non-Electron falls through to the launch-correctly message.