[eric] passkey-not-supported dialog: intercept WebAuthn in agent browsers and surface a modal instead of letting the page hang on the passkey verifying spinner,

shim injected from main via contents.executeJavaScript (bypasses Trusted Types CSP), webview preload force-attached in will-attach-webview
This commit is contained in:
ciregenz
2026-04-16 15:20:29 -07:00
parent 828dfdb18b
commit bb57a2aa51
8 changed files with 268 additions and 7 deletions
+15 -1
View File
@@ -239,11 +239,16 @@ async def sync():
No-op when not in openswarm-pro mode. Best-effort: network failures are
swallowed — the caller still gets a 200 with whatever local state we
already had."""
# Lazy-import the PostHog helper so subscription/router doesn't pay the
# cost when analytics are disabled.
from backend.apps.analytics.collector import record as _record
settings_obj = load_settings()
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
mode = getattr(settings_obj, "connection_mode", "own_key")
if mode != "openswarm-pro" or not bearer:
_record("subscription.sync_ran", {"reason": "no_bearer"})
return {"ok": True, "synced": False, "connection_mode": mode}
try:
@@ -254,6 +259,7 @@ async def sync():
)
except httpx.HTTPError as e:
logger.debug("subscription/sync live fetch failed: %s", e)
_record("subscription.sync_ran", {"reason": "network"})
return {"ok": True, "synced": False, "reason": "network"}
# Same 401/402 handling as /status: if Stripe-side reconciliation proves
@@ -261,15 +267,18 @@ async def sync():
# reverts to own_key instead of hammering a useless token.
if r.status_code in (401, 402):
_clear_subscription(settings_obj)
reason = "revoked" if r.status_code == 401 else "expired"
_record("subscription.sync_ran", {"reason": reason})
return {
"ok": True,
"synced": False,
"connected": False,
"reason": "revoked" if r.status_code == 401 else "expired",
"reason": reason,
}
if r.status_code != 200:
logger.debug("subscription/sync got %s from cloud: %s", r.status_code, r.text[:200])
_record("subscription.sync_ran", {"reason": "upstream", "status_code": r.status_code})
return {"ok": True, "synced": False, "reason": "upstream"}
data = r.json()
@@ -287,6 +296,11 @@ async def sync():
)
_write_settings(settings_obj)
_sync_subscription_identity(settings_obj)
_record("subscription.sync_ran", {
"reason": "ok",
"synced": bool(data.get("synced")),
"plan": cloud_plan,
})
return {
"ok": True,
"synced": bool(data.get("synced")),
+68 -2
View File
@@ -283,9 +283,21 @@ function createWindow() {
mainWindow.loadFile(frontendPath);
}
mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, _params) => {
mainWindow.webContents.on('will-attach-webview', (_event, webPreferences, params) => {
webPreferences.plugins = true;
webPreferences.enableBlinkFeatures = 'EncryptedMedia';
// Force our webview preload to attach for every <webview>, unconditionally.
// The alternative (reading window.openswarm.getWebviewPreloadPath() in
// BrowserCard's React code at module-eval time) raced against the
// preload's async contextBridge exposure — the resulting attribute on
// the <webview> element ended up empty, so no preload ran and our
// passkey shim never loaded. Setting webPreferences.preload here runs
// on every attach and can't be out-raced. Absolute path (not file://)
// is what webPreferences expects.
webPreferences.preload = path.join(__dirname, 'webview-preload.js');
try {
console.log('[openswarm:attach-webview] forced preload=', webPreferences.preload, 'src=', params.src);
} catch (_) {}
});
mainWindow.webContents.on('will-navigate', (event, url) => {
@@ -528,7 +540,9 @@ app.on('web-contents-created', (_event, contents) => {
contents.on('console-message', (_e, level, message, line, sourceId) => {
if (message.includes('widevine') || message.includes('drm') ||
message.includes('license') || message.includes('MediaKeySession') ||
message.includes('EME') || message.includes('[drm-diag]') || level >= 2) {
message.includes('EME') || message.includes('[drm-diag]') ||
message.includes('openswarm') ||
level >= 2) {
const tag = ['LOG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG';
const src = sourceId ? sourceId.split('/').pop() : '';
console.log(`[webview:${tag}] ${message}${src ? ` (${src}:${line})` : ''}`);
@@ -564,7 +578,59 @@ app.on('web-contents-created', (_event, contents) => {
cdpQueueByWcId.delete(contents.id);
});
// 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 <script> injection from the
// webview preload was being blocked on accounts.google.com because of
// `require-trusted-types-for 'script'`). The shim overrides
// navigator.credentials so passkey calls reject cleanly and post a
// tagged message back; webview-preload.js listens and forwards to the
// embedder, which surfaces the "Passkeys aren't supported" dialog.
contents.on('dom-ready', () => {
contents.executeJavaScript(`
(function() {
if (window.__openswarm_passkey_shim__) return;
window.__openswarm_passkey_shim__ = true;
try {
console.warn('[openswarm:shim] main-world shim installing at', location.href);
var notify = function(kind) {
try { console.warn('[openswarm:shim] passkey intercepted:', kind); } catch (_) {}
try { window.postMessage({ __openswarm__: '__openswarm_passkey__' }, '*'); } catch (_) {}
};
var rejected = function() {
return Promise.reject(new DOMException(
'OpenSwarm does not support passkeys. Please use another sign-in method.',
'NotAllowedError'
));
};
if (navigator.credentials) {
var origGet = navigator.credentials.get && navigator.credentials.get.bind(navigator.credentials);
navigator.credentials.get = function(options) {
if (options && options.publicKey) {
if (options.mediation !== 'conditional') notify('get:' + (options.mediation || 'default'));
return rejected();
}
return origGet ? origGet(options) : Promise.reject(new DOMException('Not supported', 'NotSupportedError'));
};
var origCreate = navigator.credentials.create && navigator.credentials.create.bind(navigator.credentials);
navigator.credentials.create = function(options) {
if (options && options.publicKey) { notify('create'); return rejected(); }
return origCreate ? origCreate(options) : Promise.reject(new DOMException('Not supported', 'NotSupportedError'));
};
console.warn('[openswarm:shim] navigator.credentials patched');
}
if (window.PublicKeyCredential) {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = function() { return Promise.resolve(false); };
if (window.PublicKeyCredential.isConditionalMediationAvailable) {
window.PublicKeyCredential.isConditionalMediationAvailable = function() { return Promise.resolve(false); };
}
}
} catch (e) {
try { console.warn('[openswarm:shim] error:', e && e.message); } catch (_) {}
}
})();
`).catch(() => {});
const url = contents.getURL();
if (url.includes('spotify')) {
contents.executeJavaScript(`
+51
View File
@@ -6,6 +6,10 @@
'use strict';
// Diagnostic marker so we can confirm the preload actually attached to
// this webview. Surfaces via main.js's console-message listener.
try { console.warn('[openswarm:webview-preload] loaded for', window.location.href); } catch (_) {}
// Hide webdriver flag
Object.defineProperty(navigator, 'webdriver', {
get: () => false,
@@ -85,3 +89,50 @@ try {
// Fix console.debug detection (some sites use it as a breakpoint detector)
const noop = () => {};
if (!window.console.debug) window.console.debug = noop;
// ---------------------------------------------------------------------------
// Passkey / WebAuthn handling
//
// Electron webviews can't trigger the OS platform authenticator (Touch ID,
// Windows Hello) — see electron/electron#15404, #24573. Sites that offer
// "Sign in with passkey" either fail silently or loop (#41472 on LinkedIn).
//
// With contextIsolation on (the Electron default), any patches we make to
// navigator.credentials from this preload only apply in the ISOLATED world;
// the page's own JS runs in the MAIN world and sees the original API. We
// have to inject the shim via webFrame.executeJavaScript so it lands in
// the page's JS context, then bridge the event back out with a DOM
// CustomEvent that this isolated-world preload listens for and relays via
// ipcRenderer.sendToHost to the embedding <webview> element.
//
// Two-pronged shim (both evaluated in the main world):
// 1. Probe APIs (isUserVerifyingPlatformAuthenticatorAvailable,
// isConditionalMediationAvailable) return false so sites that check
// before rendering a passkey button fall back to passwords quietly.
// 2. credentials.get / credentials.create with publicKey options reject
// with a clean NotAllowedError AND dispatch the passkey event so the
// embedder can surface a dialog. Conditional mediation (silent
// autofill) is intercepted but doesn't fire the dialog — that's
// not a user click.
// ---------------------------------------------------------------------------
try {
const { ipcRenderer } = require('electron');
// The actual WebAuthn shim is injected by the MAIN process via
// contents.executeJavaScript on each 'dom-ready' (see electron/main.js).
// That path runs in the page's main world and bypasses Trusted Types
// CSP enforcement, which blocks our previous inline-<script> approach
// on sites like accounts.google.com.
//
// Our only job here is to act as the postMessage→IPC bridge: the main-
// world shim posts a tagged message, we relay it via sendToHost to the
// embedding <webview> element, which shows the "passkeys not supported"
// dialog.
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data && event.data.__openswarm__ === '__openswarm_passkey__') {
console.warn('[openswarm:webview-preload] passkey bridge → sendToHost');
try { ipcRenderer.sendToHost('passkey-detected', window.location.href); } catch (_) {}
}
});
} catch (_) {}
@@ -346,6 +346,11 @@ const OnboardingModal: React.FC = () => {
// system browser. The post-payment openswarm://auth deep link will
// dismiss this modal automatically via useDeepLink → fetchSettings.
if (providerId === 'openswarm-pro') {
trackEvent('subscription.subscribe_clicked', {
source: 'onboarding',
plan: 'pro',
billing_interval: 'monthly',
});
try {
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
method: 'POST',
@@ -354,6 +359,12 @@ const OnboardingModal: React.FC = () => {
});
if (r.ok) {
const { url } = await r.json();
if (url) {
trackEvent('subscription.checkout_opened', {
source: 'onboarding',
plan: 'pro',
});
}
const api = (window as any).openswarm;
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { trackEvent } from '@/shared/analytics';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -664,6 +665,15 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
// uses ("API Error: NNN …") and the raw JSON body.
const openswarmError = !isUser ? parseOpenSwarmError(rawText) : null;
// Fire subscription.rate_limit_hit exactly once per rate-limit error
// card mount. Dependency on (message.id, kind) ensures we don't re-fire
// on re-renders or content edits.
React.useEffect(() => {
if (openswarmError?.kind === 'cap') {
trackEvent('subscription.rate_limit_hit', { message_id: message.id });
}
}, [message.id, openswarmError?.kind]);
React.useEffect(() => {
if (editing) setEditText(rawText);
}, [editing, rawText]);
@@ -7,6 +7,10 @@ import InputBase from '@mui/material/InputBase';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Fade from '@mui/material/Fade';
import LanguageIcon from '@mui/icons-material/Language';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -75,10 +79,20 @@ const chromeUserAgent = navigator.userAgent
.replace(/\s*Electron\/\S+/, '')
.replace(/\s*OpenSwarm\/\S+/, '');
// Read from the sync exposure first (set at preload boot, always present
// by the time modules evaluate). Fall back to the async `openswarm` API
// for backward compatibility. If you see `<openswarm:webview-preload>`
// logs in the terminal, this attached; if you don't, it didn't.
const webviewPreloadPath: string | undefined = isElectron
? (window as any).openswarm?.getWebviewPreloadPath?.()
? ((window as any).__OPENSWARM_WEBVIEW_PRELOAD__
|| (window as any).openswarm?.getWebviewPreloadPath?.())
: undefined;
if (isElectron) {
// eslint-disable-next-line no-console
console.warn('[openswarm:card-module] webviewPreloadPath =', webviewPreloadPath);
}
type WebviewElement = BrowserWebview;
interface TabLocalState {
@@ -140,6 +154,11 @@ const BrowserCard: React.FC<Props> = ({
const lastAction = activity.lastAction;
const [tabLocalStates, setTabLocalStates] = useState<Record<string, TabLocalState>>({});
// Electron webviews can't trigger the OS platform authenticator (see
// webview-preload.js for the WebAuthn shim). When the preload catches a
// passkey call it sends `ipc-message` "passkey-detected"; we surface a
// modal so the user knows why the sign-in didn't work.
const [passkeyDialogOpen, setPasskeyDialogOpen] = useState(false);
const updateTabLocal = useCallback((tabId: string, update: Partial<TabLocalState>) => {
setTabLocalStates((prev) => {
const existing = prev[tabId] ?? { loading: false, canGoBack: false, canGoForward: false };
@@ -200,6 +219,14 @@ const BrowserCard: React.FC<Props> = ({
});
};
const onIpcMessage = (e: any) => {
// eslint-disable-next-line no-console
console.warn('[openswarm:card] webview ipc-message:', e?.channel, e?.args);
if (e?.channel === 'passkey-detected') {
setPasskeyDialogOpen(true);
}
};
const onTitleUpdate = () => {
dispatch(updateBrowserTabTitle({ browserId, tabId, title: wv.getTitle() }));
};
@@ -224,6 +251,7 @@ const BrowserCard: React.FC<Props> = ({
wv.addEventListener('did-start-loading', onLoadStart);
wv.addEventListener('did-stop-loading', onLoadStop);
wv.addEventListener('page-favicon-updated', onFaviconUpdate);
wv.addEventListener('ipc-message', onIpcMessage as any);
cleanups.push(() => {
unregisterWebview(browserId, tabId);
@@ -233,6 +261,7 @@ const BrowserCard: React.FC<Props> = ({
wv.removeEventListener('did-start-loading', onLoadStart);
wv.removeEventListener('did-stop-loading', onLoadStop);
wv.removeEventListener('page-favicon-updated', onFaviconUpdate);
wv.removeEventListener('ipc-message', onIpcMessage as any);
});
}
@@ -1034,7 +1063,47 @@ const BrowserCard: React.FC<Props> = ({
}}
/>
))
) : (
) : null}
<Dialog
open={passkeyDialogOpen}
onClose={() => setPasskeyDialogOpen(false)}
PaperProps={{
sx: {
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.lg}px`,
maxWidth: 420,
},
}}
>
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary, pb: 1 }}>
Passkeys aren't supported
</DialogTitle>
<DialogContent sx={{ pb: 1 }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, lineHeight: 1.5 }}>
Sorry — OpenSwarm doesn't support passkeys. Please sign in with a password or another method.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => setPasskeyDialogOpen(false)}
sx={{
textTransform: 'none',
fontSize: '0.82rem',
fontWeight: 600,
bgcolor: c.accent.primary,
color: '#fff',
borderRadius: `${c.radius.md}px`,
px: 2.25,
py: 0.6,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
}}
>
OK
</Button>
</DialogActions>
</Dialog>
{!isElectron && (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<iframe
src={activeUrl}
+41 -1
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { trackEvent } from '@/shared/analytics';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
@@ -158,6 +159,10 @@ const OpenSwarmProCard: React.FC = () => {
const dispatch = useAppDispatch();
const [status, setStatus] = useState<OpenSwarmProStatus | null>(null);
const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null);
// Track which usage thresholds we've already fired this session so the
// event doesn't spam PostHog every 30s while the counter hovers past
// the threshold. Reset implicitly on page unmount (settings close).
const firedUsageThresholds = useRef<Set<number>>(new Set());
const refresh = useCallback(async () => {
try {
@@ -175,6 +180,12 @@ const OpenSwarmProCard: React.FC = () => {
}, [refresh]);
const handleSubscribe = async () => {
trackEvent('subscription.subscribe_clicked', {
source: 'settings',
plan: 'pro',
billing_interval: 'monthly',
was_subscribed: !!status?.last_plan,
});
try {
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
method: 'POST',
@@ -183,6 +194,12 @@ const OpenSwarmProCard: React.FC = () => {
});
if (r.ok) {
const { url } = await r.json();
if (url) {
trackEvent('subscription.checkout_opened', {
source: 'settings',
plan: 'pro',
});
}
const api = (window as any).openswarm;
if (url && api?.openExternal) api.openExternal(url);
else if (url) window.open(url, '_blank');
@@ -193,6 +210,10 @@ const OpenSwarmProCard: React.FC = () => {
};
const handleManage = async () => {
trackEvent('subscription.manage_clicked', {
plan: status?.plan ?? null,
status: status?.status ?? null,
});
setBusy('manage');
try {
const r = await fetch(`${API_BASE}/subscription/portal`, { method: 'POST' });
@@ -217,6 +238,25 @@ const OpenSwarmProCard: React.FC = () => {
}
};
// Fire subscription.usage_warning exactly once per threshold per session
// when utilization crosses 80% / 90%. Placed before the early return so
// the hook chain stays stable.
useEffect(() => {
if (!status?.connected) return;
const rawPct = status.usage?.utilization ?? 0;
const current = Math.max(0, Math.min(100, Math.round(rawPct)));
for (const threshold of [80, 90] as const) {
if (current >= threshold && !firedUsageThresholds.current.has(threshold)) {
firedUsageThresholds.current.add(threshold);
trackEvent('subscription.usage_warning', {
plan: status.plan ?? null,
utilization: current,
threshold,
});
}
}
}, [status]);
// Loading state — don't flash a CTA that disappears on first fetch.
if (!status) return null;
File diff suppressed because one or more lines are too long