diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index e49a7258..756f9c2b 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -2008,6 +2008,7 @@ async def p_create_browser_card(dashboard_id: str, url: str, parent_session_id: width=1280, height=800, spawned_by=parent_session_id, + dashboard_id=dashboard_id, ) dashboard.layout.browser_cards[browser_id] = card dashboard.updated_at = datetime.now() diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py index 0cc905a3..b48b776a 100644 --- a/backend/apps/agents/manager/session/SessionLifecycle.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -250,7 +250,13 @@ class SessionLifecycle(AgentManagerProtocol): if sid in seen: continue if data.get("mode") == "browser-agent" and data.get("parent_session_id") == parent_session_id: - results.append(data) + # Validate + model_dump like the in-memory branch above; a raw legacy dict that predates a field (e.g. pending_approvals) would ship half-shaped and crash the renderer. + try: + sess = AgentSession(**data) + except Exception: + logger.warning(f"get_browser_agent_children: skipping unloadable session {sid}", exc_info=True) + continue + results.append(sess.model_dump(mode="json")) return results diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 926f4f28..5a6e7fa7 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -40,6 +40,8 @@ class BrowserCardPosition(BaseModel): spawned_by: Optional[str] = None # When the agent leaves the deliverable on the page (a video playing, a page to read), it sets this so the frontend's auto-close on parent finish skips the card and the browser stays put. keep_open: bool = False + # The dashboard this card calls home. Persisted so the home survives a save; without it the card reloads untagged and renders on EVERY dashboard (the cross-dashboard bleed). + dashboard_id: Optional[str] = None class NotePosition(BaseModel): diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index d65173b6..5d449f07 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -376,6 +376,8 @@ class AppRuntime: env = {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"} # Hand the workspace's backend/run.sh the exact interpreter we're running on. In the packaged build that's the bundled standalone Python, so a fresh machine with no system `python3` still works; in dev it's whatever launched uvicorn. OPENSWARM_NODE_PATH already rides in via os.environ (set by the Electron shell) for run.sh's Node resolution. env["OPENSWARM_PYTHON"] = sys.executable + # Force npm to skip dependency lifecycle scripts for every install run.sh triggers. An imported app's package.json is untrusted (it brings its own run.sh, so we can't gate the flag there); a malicious dep's postinstall would otherwise run arbitrary code on the host the moment its preview boots. Vite/esbuild get their platform binary via optionalDependencies, not a script, so this doesn't break the build. + env["npm_config_ignore_scripts"] = "true" return env async def stop(self) -> None: diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 24363fa2..8df78412 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -23,18 +23,17 @@ def p_resolve_npm() -> list[str] | None: node_path = os.environ.get("OPENSWARM_NODE_PATH") if node_path and os.path.exists(node_path): node_dir = os.path.dirname(node_path) + # Prefer invoking npm-cli.js through our bundled node so this doesn't depend on a system node for the shim's shebang. Second entry is the canonical Mac-dist layout (lib/node_modules/npm); first is the Windows layout (node_modules/npm beside node.exe). + for cli in ( + os.path.join(node_dir, "node_modules", "npm", "bin", "npm-cli.js"), + os.path.join(os.path.dirname(node_dir), "lib", "node_modules", "npm", "bin", "npm-cli.js"), + ): + if os.path.exists(cli): + return [node_path, cli] for shim in ("npm.cmd", "npm"): cand = os.path.join(node_dir, shim) if os.path.exists(cand): return [cand] - # node.exe with no sibling npm: invoke npm-cli.js directly via node. - for rel in ( - os.path.join("node_modules", "npm", "bin", "npm-cli.js"), - os.path.join(node_dir, "node_modules", "npm", "bin", "npm-cli.js"), - ): - cli = rel if os.path.isabs(rel) else os.path.join(node_dir, rel) - if os.path.exists(cli): - return [node_path, cli] for name in ("npm.cmd", "npm") if sys.platform == "win32" else ("npm",): found = shutil.which(name) if found: @@ -304,7 +303,7 @@ def ensure_warm_cache() -> str | None: tmpl_pkg = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package.json") tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json") shutil.copyfile(tmpl_pkg, os.path.join(cache_dir, "package.json")) - base_flags = ["--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"] + base_flags = ["--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error", "--ignore-scripts"] npm = p_resolve_npm() if npm is None: logger.info("webapp-template: no npm available; skipping warm cache (workspace will install on first run)") @@ -374,13 +373,21 @@ def p_try_link_dir(src: str, target: str) -> bool: return False -def p_link_node_modules(workspace_dir: str) -> None: +def link_node_modules(workspace_dir: str) -> None: """After copytree, point the workspace's frontend/node_modules at the warm-cache directory. Safe fallback; if the cache isn't ready, the workspace's run.sh will fall through to its own install path.""" cache_modules = ensure_warm_cache() if not cache_modules: return + # The warm cache holds the TEMPLATE's deps; only link it when this workspace's package.json matches, else run.sh sees vite present, skips install, and the app's custom deps are missing. On mismatch (a customized import) leave node_modules absent so run.sh installs the app's real deps. + pkg_path = os.path.join(workspace_dir, "frontend", "package.json") + try: + with open(pkg_path, "rb") as fh: + if hashlib.sha256(fh.read()).hexdigest()[:12] != warm_cache_digest(): + return + except OSError: + return target = os.path.join(workspace_dir, "frontend", "node_modules") if os.path.islink(target): try: @@ -570,7 +577,7 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No dirs_exist_ok=True, ) # Symlink the workspace's frontend/node_modules at the warm cache so `npm install` can be skipped entirely by the workspace run.sh. - p_link_node_modules(workspace_dir) + link_node_modules(workspace_dir) env_path = os.path.join(workspace_dir, ".env") env_example_path = os.path.join(workspace_dir, ".env.example") src_example = os.path.join(WEBAPP_TEMPLATE_DIR, ".env.example") diff --git a/backend/apps/outputs/workspace_io.py b/backend/apps/outputs/workspace_io.py index 1f4e502d..8db3cf09 100644 --- a/backend/apps/outputs/workspace_io.py +++ b/backend/apps/outputs/workspace_io.py @@ -75,6 +75,12 @@ WALK_SKIP_DIRS = frozenset({ ".ruff_cache", }) +# OS/editor junk files that ride along in a workspace but are noise in an export. +WALK_SKIP_FILES = frozenset({ + ".DS_Store", + "Thumbs.db", +}) + # Poll-payload guard, NOT an editor limit: the endpoint re-serializes the whole tree every 2 s, so a multi-MB bundle would peg the backend; 2 MB clears real hand-authored apps but traps minified bundles/sourcemaps, which are reported out-of-band (the `truncated` map) and NEVER stubbed, so a placeholder can't round-trip back into storage and destroy the real source. P_WALK_MAX_FILE_BYTES = 2 * 1024 * 1024 @@ -93,6 +99,8 @@ def walk_directory(folder: str) -> tuple[dict[str, str], dict[str, int]]: # Mutate `dirs` in place; that's how os.walk skips a subtree. Doing it here means we never even stat the children, so a 10k-file `.venv/` costs ~one stat (on the dir itself) instead of 10k. dirs[:] = [d for d in dirs if d not in WALK_SKIP_DIRS] for fname in filenames: + if fname in WALK_SKIP_FILES: + continue full_path = os.path.join(root, fname) # Normalize to forward-slash keys so the frontend's `path.split('/')` and `.startsWith(prefix)` checks work the same on Windows (where os.sep is '\\') as on macOS. Without this, every workspace file came back as `backend\\app.py` on Windows and the file tree silently mis-parsed. rel_path = os.path.relpath(full_path, folder).replace(os.sep, "/") diff --git a/backend/apps/swarm/entities/apps.py b/backend/apps/swarm/entities/apps.py index 21c09357..024c37ad 100644 --- a/backend/apps/swarm/entities/apps.py +++ b/backend/apps/swarm/entities/apps.py @@ -12,7 +12,7 @@ import socket from uuid import uuid4 from backend.apps.outputs.models import Output -from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS, save, load_output +from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS, WALK_SKIP_FILES, save, load_output from backend.config.paths import OUTPUTS_DIR, OUTPUTS_WORKSPACE_DIR from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable @@ -57,6 +57,8 @@ class AppExportable: # .env is install-specific (absolute paths + port); .env.example travels instead. if fn == ".env": continue + if fn in WALK_SKIP_FILES: + continue full = os.path.join(root, fn) if os.path.islink(full): continue @@ -146,6 +148,7 @@ def p_localize_env(folder: str) -> None: from backend.apps.outputs.view_builder_templates import ( DEBUGGER_PATH, TEMPLATE_BACKEND_PATH, + link_node_modules, patch_env_port, warm_venv_dir, ) @@ -158,3 +161,8 @@ def p_localize_env(folder: str) -> None: patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", warm_venv_dir()) except Exception: pass + # Imported apps arrive WITHOUT node_modules (export drops the warm-cache symlink), so relink it here like seed does; without it the first runtime boot npm-installs while the preview races onto a not-yet-bound port, so the app stays blank until a full restart. + try: + link_node_modules(folder) + except Exception: + pass diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py index d39198d1..357970cc 100644 --- a/backend/apps/swarm/entities/dashboards.py +++ b/backend/apps/swarm/entities/dashboards.py @@ -93,6 +93,8 @@ class DashboardExportable: nbid = "browser-" + uuid4().hex[:10] c = dict(card) c["browser_id"] = nbid + # Re-stamp the home dashboard, else the card keeps the source's id and the anti-bleed render guard (DashboardCardLayer keepAliveHidden) hides it on the imported dashboard. + c["dashboard_id"] = new_did spawn = c.get("spawned_by") c["spawned_by"] = remap.local(spawn) if spawn else None browser_cards[nbid] = c diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 4c1b9b6f..410991f8 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -389,7 +389,7 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch): "ABID": {"output_id": "ABID", "parent_session_id": "SBID"}, "ABID2": {"output_id": "ABID2", "parent_session_id": "GONE"}, }, - "browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID"}}, + "browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID", "dashboard_id": "OLD_DASH"}}, "expanded_session_ids": ["SBID", "ORPHAN"], }} remap.assign("ABID2", "newapp2") @@ -399,6 +399,8 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch): assert L["view_cards"]["newapp"]["parent_session_id"] == "newsess" assert L["view_cards"]["newapp2"]["parent_session_id"] is None # parent not in bundle assert list(L["browser_cards"].values())[0]["spawned_by"] == "newsess" + # The card's home dashboard must be re-stamped to the new id, else the anti-bleed render guard hides it on the import. + assert list(L["browser_cards"].values())[0]["dashboard_id"] == did assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped diff --git a/electron/main.js b/electron/main.js index ac4470a7..1e29d4c6 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,5 +1,8 @@ const { app, components, BrowserWindow, ipcMain, shell, session, dialog, crashReporter, powerMonitor } = require('electron'); +// Browser cards live in their own persistent partition so cookies/localStorage/IndexedDB survive reload + quit (Discord etc. stay logged in) and site data stays isolated from the app's defaultSession. The "clear browsing data" wipe nukes only this partition. MUST match BROWSER_PARTITION in frontend BrowserCard.tsx. +const BROWSER_PARTITION = 'persist:openswarm-browser'; + // E2E flag: when OPENSWARM_E2E=1, append a Chromium command-line switch the // renderer reads at startup to set window.__OPENSWARM_E2E__ = true BEFORE any // page script parses, so the production-build store-on-window gate fires @@ -1719,43 +1722,68 @@ app.whenReady().then(async () => { try { app.dock.setIcon(iconPath); } catch (_) {} } - session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => { - const allowed = [ - 'media', 'mediaKeySystem', 'protected-media-identifier', - 'geolocation', 'notifications', 'midi', 'midiSysex', - 'clipboard-read', 'clipboard-sanitized-write', - 'pointerLock', 'fullscreen', 'idle-detection', - ]; - console.log('Permission request:', permission, '->', allowed.includes(permission) ? 'granted' : 'denied'); - callback(allowed.includes(permission)); - }); - session.defaultSession.setPermissionCheckHandler((_wc, permission) => { - const allowed = [ - 'media', 'mediaKeySystem', 'protected-media-identifier', - 'clipboard-read', 'clipboard-sanitized-write', - 'pointerLock', 'fullscreen', 'idle-detection', - ]; - return allowed.includes(permission); - }); + // Same permission grants + iframe header-strip on BOTH the app's defaultSession and the browser-card partition. A named partition is a separate session, so without re-applying these, browser cards lose camera/mic prompts and the ability to embed sites that send X-Frame-Options. + const configureBrowsingSession = (ses) => { + ses.setPermissionRequestHandler((_wc, permission, callback) => { + const allowed = [ + 'media', 'mediaKeySystem', 'protected-media-identifier', + 'geolocation', 'notifications', 'midi', 'midiSysex', + 'clipboard-read', 'clipboard-sanitized-write', + 'pointerLock', 'fullscreen', 'idle-detection', + ]; + console.log('Permission request:', permission, '->', allowed.includes(permission) ? 'granted' : 'denied'); + callback(allowed.includes(permission)); + }); + ses.setPermissionCheckHandler((_wc, permission) => { + const allowed = [ + 'media', 'mediaKeySystem', 'protected-media-identifier', + 'clipboard-read', 'clipboard-sanitized-write', + 'pointerLock', 'fullscreen', 'idle-detection', + ]; + return allowed.includes(permission); + }); - // Strip X-Frame-Options and CSP frame-ancestors directives on iframe subframe loads so the Windows BrowserCard iframe fallback (used because tag commit segfaults on Chromium 144 + this Electron 40 CastLabs build) can render sites that normally refuse to be embedded. Scoped to types:['sub_frame'] so OAuth popups, the main app frame, deep-link redirects, and DRM license fetches keep their security headers intact. urls filter limits to http/https so file:// loads of the bundled frontend are untouched. - session.defaultSession.webRequest.onHeadersReceived( - // Electron's webRequest type name for iframes is 'subFrame' (camelCase), not the Chrome-extension 'sub_frame' — passing the wrong name throws "Invalid type sub_frame" synchronously which becomes an unhandledRejection and prevents the app from booting. - { urls: ['http://*/*', 'https://*/*'], types: ['subFrame'] }, + // Strip X-Frame-Options and CSP frame-ancestors directives on iframe subframe loads so the Windows BrowserCard iframe fallback (used because tag commit segfaults on Chromium 144 + this Electron 40 CastLabs build) can render sites that normally refuse to be embedded. Scoped to types:['sub_frame'] so OAuth popups, the main app frame, deep-link redirects, and DRM license fetches keep their security headers intact. urls filter limits to http/https so file:// loads of the bundled frontend are untouched. + ses.webRequest.onHeadersReceived( + // Electron's webRequest type name for iframes is 'subFrame' (camelCase), not the Chrome-extension 'sub_frame' — passing the wrong name throws "Invalid type sub_frame" synchronously which becomes an unhandledRejection and prevents the app from booting. + { urls: ['http://*/*', 'https://*/*'], types: ['subFrame'] }, + (details, callback) => { + const headers = { ...(details.responseHeaders || {}) }; + for (const k of Object.keys(headers)) { + const lk = k.toLowerCase(); + if (lk === 'x-frame-options') { + delete headers[k]; + } else if (lk === 'content-security-policy' || lk === 'content-security-policy-report-only') { + const cleaned = (headers[k] || []) + .map((v) => v.split(';').filter((d) => !/^\s*frame-ancestors\b/i.test(d)).join(';').trim()) + .filter(Boolean); + if (cleaned.length) headers[k] = cleaned; else delete headers[k]; + } + } + callback({ responseHeaders: headers }); + }, + ); + }; + configureBrowsingSession(session.defaultSession); + configureBrowsingSession(session.fromPartition(BROWSER_PARTITION)); + + // Add a "Google Chrome" brand to the browser partition's sec-ch-ua request hints so they match the navigator.userAgentData patch injected on dom-ready and the spoofed Chrome UA string; a Chrome UA paired with Chromium-only hints is the embedded-app tell aggressive anti-bot (Cloudflare) flags on a real human. Scoped to the browser partition, the app's own file:// + localhost traffic is untouched. + const addGoogleChromeBrand = (value) => { + if (typeof value !== 'string' || value.includes('"Google Chrome"')) return value; + const m = value.match(/"Chromium";v="([^"]+)"/); + return m ? `${value}, "Google Chrome";v="${m[1]}"` : value; + }; + session.fromPartition(BROWSER_PARTITION).webRequest.onBeforeSendHeaders( + { urls: ['http://*/*', 'https://*/*'] }, (details, callback) => { - const headers = { ...(details.responseHeaders || {}) }; + const headers = { ...(details.requestHeaders || {}) }; for (const k of Object.keys(headers)) { const lk = k.toLowerCase(); - if (lk === 'x-frame-options') { - delete headers[k]; - } else if (lk === 'content-security-policy' || lk === 'content-security-policy-report-only') { - const cleaned = (headers[k] || []) - .map((v) => v.split(';').filter((d) => !/^\s*frame-ancestors\b/i.test(d)).join(';').trim()) - .filter(Boolean); - if (cleaned.length) headers[k] = cleaned; else delete headers[k]; + if (lk === 'sec-ch-ua' || lk === 'sec-ch-ua-full-version-list') { + headers[k] = addGoogleChromeBrand(headers[k]); } } - callback({ responseHeaders: headers }); + callback({ requestHeaders: headers }); }, ); @@ -1951,6 +1979,39 @@ function swallowCloseWindowShortcut(event, input) { } } +// Cmd/Ctrl+R: the default menu's Reload accelerator reloads the WHOLE app even when a browser webview is focused (the "Ctrl+R reloads OpenSwarm, not the browser" complaint). preventDefault kills that accelerator (same electron#19279 path as Cmd+W, dispatched against whichever webContents is focused, hence both main window AND guests); the renderer then reloads the last-interacted browser, or the app if none. Shift+R (force reload) is left alone. +function routeReloadShortcut(event, input) { + if (input.type !== 'keyDown') return; + if (!(input.meta || input.control) || input.shift || input.alt) return; + if ((input.key || '').toLowerCase() !== 'r') return; + event.preventDefault(); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:reload-shortcut'); + } catch (_) {} +} + +// In-page browser shortcuts (zoom, find, tab-cycle) for a focused guest. Keydowns inside a +// guest never reach the host renderer, so we catch them here and forward the intent + the guest's +// webContents id so the renderer can target that exact browser. Attached to guests ONLY: on the host +// the renderer's own keydown handles canvas-vs-browser, and intercepting there would eat canvas zoom. +function routeBrowserShortcut(event, input, webContentsId) { + if (input.type !== 'keyDown' || input.alt) return; + const mod = input.meta || input.control; + const key = (input.key || '').toLowerCase(); + let action = null; + if (mod && !input.shift && (key === '=' || key === '+')) action = 'zoom-in'; + else if (mod && !input.shift && key === '-') action = 'zoom-out'; + else if (mod && !input.shift && key === '0') action = 'zoom-reset'; + else if (mod && !input.shift && key === 'f') action = 'find'; + else if (mod && input.shift && key === 't') action = 'reopen-closed'; + else if (input.control && !input.meta && key === 'tab') action = input.shift ? 'tab-prev' : 'tab-next'; + if (!action) return; + event.preventDefault(); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('openswarm:browser-shortcut', { action, webContentsId }); + } catch (_) {} +} + app.on('web-contents-created', (_event, contents) => { // Block Cmd+W from closing the main window, whether the window chrome or one of // its embedded webviews has focus. OAuth popups (their own 'window' contents, @@ -1958,6 +2019,11 @@ app.on('web-contents-created', (_event, contents) => { // still Cmd+W them shut. if (isCreatingMainWindow || contents.getType() === 'webview') { contents.on('before-input-event', swallowCloseWindowShortcut); + contents.on('before-input-event', routeReloadShortcut); + } + if (contents.getType() === 'webview') { + const wcId = contents.id; + contents.on('before-input-event', (event, input) => routeBrowserShortcut(event, input, wcId)); } // Override the user-agent on popup BrowserWindows (i.e. anything created @@ -1993,7 +2059,7 @@ app.on('web-contents-created', (_event, contents) => { contents.setWindowOpenHandler(({ url, disposition }) => { if (disposition === 'foreground-tab' || disposition === 'background-tab') { if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('webview-new-window', url, contents.id); + mainWindow.webContents.send('webview-new-window', url, contents.id, disposition); } return { action: 'deny' }; } @@ -2143,6 +2209,54 @@ app.on('web-contents-created', (_event, contents) => { try { contents.reload(); } catch { /* nothing more we can do from here */ } }); + // Match navigator.userAgentData to the spoofed Chrome UA + the browser-partition sec-ch-ua header rewrite so the page world agrees with the headers; contextIsolation hides the preload, so this page-world patch is injected here. A Chrome UA with Chromium-only hints is the embedded-app tell that aggressive anti-bot (Cloudflare) flags on a real human. + contents.on('dom-ready', () => { + contents.executeJavaScript(` + (function(){ + try { + var orig = navigator.userAgentData; + if (!orig || !Array.isArray(orig.brands) || orig.brands.some(function(b){ return b.brand === 'Google Chrome'; })) return; + var addChrome = function(list){ + if (!Array.isArray(list) || list.some(function(b){ return b.brand === 'Google Chrome'; })) return list; + var ch = list.find(function(b){ return b.brand === 'Chromium'; }); + return ch ? list.concat([{ brand: 'Google Chrome', version: ch.version }]) : list; + }; + var brands = addChrome(orig.brands); + var patched = { + brands: brands, + mobile: orig.mobile, + platform: orig.platform, + getHighEntropyValues: function(h){ return orig.getHighEntropyValues(h).then(function(v){ if (v && Array.isArray(v.fullVersionList)) v.fullVersionList = addChrome(v.fullVersionList); return v; }); }, + toJSON: function(){ return { brands: brands, mobile: orig.mobile, platform: orig.platform }; }, + }; + Object.defineProperty(navigator, 'userAgentData', { get: function(){ return patched; }, configurable: true }); + } catch (e) {} + })(); + `).catch(() => {}); + }); + + // Force the guest's PAGE WORLD to always report visible/foregrounded. When a kept-alive browser card sits on another dashboard it's parked off-screen; the page-visibility API then reads hidden, so a real-time app (Discord) backgrounds itself, drops its gateway socket, and on return can't resume the session -> "please log in again". The webview-preload patches this too but only in the isolated world (contextIsolation), so the page's OWN code never sees it; injecting here in the main world is what actually keeps Discord logged in while hidden. document.hasFocus is forced true for the same reason; visibilitychange/freeze/pagehide are swallowed so nothing downstream reacts to a backgrounding that, to us, never happens. + contents.on('dom-ready', () => { + contents.executeJavaScript(` + (function(){ + try { + if (window.__openswarm_vis__) return; window.__openswarm_vis__ = true; + var def = function(o, k, v){ try { Object.defineProperty(o, k, { get: function(){ return v; }, configurable: true }); } catch(e){} }; + def(document, 'hidden', false); + def(document, 'visibilityState', 'visible'); + def(document, 'webkitHidden', false); + def(document, 'webkitVisibilityState', 'visible'); + try { document.hasFocus = function(){ return true; }; } catch(e){} + var swallow = function(e){ e.stopImmediatePropagation(); }; + ['visibilitychange','webkitvisibilitychange','freeze','pagehide'].forEach(function(t){ + window.addEventListener(t, swallow, true); + document.addEventListener(t, swallow, true); + }); + } catch (e) {} + })(); + `).catch(() => {}); + }); + // WebAuthn/passkey shim. Injected on every dom-ready in the main world // via executeJavaScript (which uses V8's direct evaluation path and // bypasses Trusted Types CSP — inline