[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)

This commit is contained in:
ciregenz
2026-08-11 02:54:40 -07:00
parent c4ded9a3ab
commit 1215cfec50
6 changed files with 32 additions and 11 deletions
+9
View File
@@ -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;
@@ -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/<ver> 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).
@@ -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<Props> = ({
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<Props> = ({
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.
@@ -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<Viewport>({ 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<typeof setTimeout> | 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,
+4 -3
View File
@@ -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 <webview> 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<ViewPreviewHandle, Props>(({
}, [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<ViewPreviewHandle, Props>(({
...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.
<iframe
ref={iframeRef}
+8
View File
@@ -0,0 +1,8 @@
// Are we running inside the Electron shell? Detect it by the preload bridge, NOT by sniffing
// navigator.userAgent for "Electron": the UA token is deliberately stripped in the browser
// partition (and can be stripped app-wide) so sign-in walls stop seeing an embedded browser, which
// would make a UA-string check silently wrong. window.openswarm is exposed synchronously in preload
// before the first frontend frame, so it is a reliable structural signal (ENG-238).
export function isElectron(): boolean {
return typeof window !== 'undefined' && !!(window as unknown as { openswarm?: unknown }).openswarm;
}