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.