diff --git a/electron/package.json b/electron/package.json index c00234fb..6d85b525 100644 --- a/electron/package.json +++ b/electron/package.json @@ -31,6 +31,14 @@ "electron-builder-squirrel-windows": "^26.8.1" }, "build": { + "protocols": [ + { + "name": "OpenSwarm", + "schemes": [ + "openswarm" + ] + } + ], "appId": "com.clusterlabs.openswarm", "productName": "OpenSwarm", "afterPack": "./build/after-pack.js", diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx index 0bbce3b5..d6c9d3c1 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatSignIn.tsx @@ -5,7 +5,7 @@ import EmailIcon from '@mui/icons-material/Email'; import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchSettings } from '@/shared/state/settingsSlice'; -import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; +import { OPENSWARM_DEFAULT_PROXY_URL, getBackendPort } from '@/shared/config'; import { report } from '@/shared/serviceClient'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import SignInDialog from '@/app/components/overlays/SignInDialog'; @@ -17,6 +17,8 @@ import { googleStartUrl } from '@/shared/googleStartUrl'; // setup are tied to a real account. Google hands off through the external browser and lands out-of-band // (the cloud page POSTs the bearer to the local backend), so we poll settings until user_id appears. // Email reuses the proven SignInDialog (magic-link 6-digit code) on top of the beat. +const GOOGLE_STALL_MS = 60_000; + const BeatSignIn: React.FC<{ c: ClaudeTokens; onNext: () => void; @@ -28,6 +30,7 @@ const BeatSignIn: React.FC<{ const proxyUrl = useAppSelector((s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL); const installId = useAppSelector((s) => s.settings.data.installation_id ?? ''); const [waitingGoogle, setWaitingGoogle] = useState(false); + const [stalled, setStalled] = useState(false); const [notReady, setNotReady] = useState(false); const [emailOpen, setEmailOpen] = useState(false); const signedIn = !!userId; @@ -38,11 +41,17 @@ const BeatSignIn: React.FC<{ return () => window.clearInterval(id); }, [signedIn, waitingGoogle, dispatch]); + // The hand-off can die silently (browser blocked the localhost POST, wrong port, the tab was closed), and "Waiting for your browser" forever was the whole bug report. Past a minute, say so, offer the retry, and record it. + useEffect(() => { + if (signedIn || !waitingGoogle || stalled) return undefined; + const id = window.setTimeout(() => { setStalled(true); report('signin', 'google_handoff_stalled'); }, GOOGLE_STALL_MS); + return () => window.clearTimeout(id); + }, [signedIn, waitingGoogle, stalled]); + const onGoogle = (): void => { if (signedIn) return; - report('signin', 'google_clicked'); - const localPort = (window as unknown as { __OPENSWARM_PORT__?: number }).__OPENSWARM_PORT__ || 8324; - const startUrl = googleStartUrl(proxyUrl, installId, localPort); + report('signin', stalled ? 'google_retry_clicked' : 'google_clicked'); + const startUrl = googleStartUrl(proxyUrl, installId, getBackendPort()); // First run is exactly when settings may not have landed yet, so this beat is the likeliest // place to catch a not-ready install. Say so on the row instead of opening a broken page. if (!startUrl) { setNotReady(true); return; } @@ -50,11 +59,12 @@ const BeatSignIn: React.FC<{ const api = (window as unknown as { openswarm?: { openExternal?: (u: string) => void } }).openswarm; if (api?.openExternal) api.openExternal(startUrl); else window.open(startUrl, '_blank'); + setStalled(false); setWaitingGoogle(true); }; const rows: Array<{ id: string; name: string; icon: React.ReactNode; onClick: () => void; hint?: string }> = [ - { id: 'google', name: 'Continue with Google', icon: , onClick: onGoogle, hint: notReady ? 'Still starting up, try again in a second' : (waitingGoogle && !signedIn ? 'Waiting for your browser...' : undefined) }, + { id: 'google', name: 'Continue with Google', icon: , onClick: onGoogle, hint: notReady ? 'Still starting up, try again in a second' : stalled ? 'Still waiting. Click to open the sign-in page again' : (waitingGoogle && !signedIn ? 'Waiting for your browser...' : undefined) }, { id: 'email', name: 'Continue with email', icon: , onClick: () => { if (!signedIn) setEmailOpen(true); } }, ]; diff --git a/frontend/src/app/components/overlays/SignInDialog.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx index 096a20dc..70e60839 100644 --- a/frontend/src/app/components/overlays/SignInDialog.tsx +++ b/frontend/src/app/components/overlays/SignInDialog.tsx @@ -17,7 +17,7 @@ import CloseIcon from '@mui/icons-material/Close'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { activateSignin, fetchSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; +import { OPENSWARM_DEFAULT_PROXY_URL, getBackendPort } from '@/shared/config'; import { report } from '@/shared/serviceClient'; import { googleStartUrl } from '@/shared/googleStartUrl'; @@ -50,7 +50,7 @@ export default function SignInDialog({ onClose, initialStage = 'choose', mandato const onGoogle = () => { report('signin', 'google_clicked'); - const localPort = (window as any).__OPENSWARM_PORT__ || 8324; + const localPort = getBackendPort(); const startUrl = googleStartUrl(cloudBase, installId, localPort); if (!startUrl) { setErrMsg('Still finishing startup. Give it a second and try again.'); @@ -116,7 +116,7 @@ export default function SignInDialog({ onClose, initialStage = 'choose', mandato setBusy(true); try { report('signin', 'email_verify_submitted'); - const localPort = (window as any).__OPENSWARM_PORT__ || 8324; + const localPort = getBackendPort(); const res = await fetch(`${cloudBase}/api/auth/email/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx index 31b45ea1..f5cf086e 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx @@ -5,7 +5,7 @@ import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { signOut } from '@/shared/state/settingsSlice'; -import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; +import { OPENSWARM_DEFAULT_PROXY_URL, getBackendPort } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import SignInDialog from '@/app/components/overlays/SignInDialog'; import { googleStartUrl } from '@/shared/googleStartUrl'; @@ -46,7 +46,7 @@ const AccountCard: React.FC = () => { const onSignIn = () => { // Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424). - const localPort = (window as any).__OPENSWARM_PORT__ || 8324; + const localPort = getBackendPort(); const startUrl = googleStartUrl(proxyUrl, installId, localPort); // No error surface on this card, so the honest move is to do nothing visible rather than send // them to a black cloud error page. Settings land within a second and the click then works. diff --git a/frontend/src/shared/config.ts b/frontend/src/shared/config.ts index 10f00feb..ab30fb10 100644 --- a/frontend/src/shared/config.ts +++ b/frontend/src/shared/config.ts @@ -10,6 +10,13 @@ const port = 8324; const host = window.location.hostname || 'localhost'; +/** The backend port as of NOW. Prefer this over the boot-time snapshot when the number leaves the app (the Google sign-in hand-off POSTs the bearer back to it): preload captures `__OPENSWARM_PORT__` once, and a slow first-run port scan leaves it null, which used to send the cloud to a bare 8324 nobody was listening on. */ +export function getBackendPort(): number { + const ow = _w.openswarm; + const live = ow && typeof ow.getBackendPortLive === 'function' ? ow.getBackendPortLive() : null; + return typeof live === 'number' && live > 0 ? live : port; +} + export const API_BASE = `http://${host}:${port}/api`; export const WS_BASE = `ws://${host}:${port}`; // Must match openswarm-cloud's PUBLIC_BASE_URL (fly.toml) and the Google OAuth redirect URI.