diff --git a/backend/auth.py b/backend/auth.py index 5b4e41a8..465f9713 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -88,15 +88,17 @@ _AUTH_EXEMPT_EXACT = { # Google/Anthropic/etc). The `state` query param is already a # one-time nonce validated against `_pending_oauth`. "/api/subscriptions/callback", - # Electron's boot handshake pings these before it has a token — - # the HTTP port is up before the token file is readable in some - # races. Safe to expose since they don't return any session data. - "/api/health", "/api/version", } # Path prefixes that never require auth. Trailing slash optional. _AUTH_EXEMPT_PREFIX = ( + # Electron's boot handshake polls /api/health/check before it has a + # token (the HTTP port is up before main.js calls loadAuthToken()). + # Use a prefix so /api/health/check — and any future sub-route — is + # covered without re-introducing the bootstrap deadlock that an + # exact "/api/health" match caused. + "/api/health", # FastAPI's default health/docs/schema surface (packaged app never # ships /docs, but be defensive). "/docs", diff --git a/backend/mcp-bundles/reddit-mcp-buddy.js b/backend/mcp-bundles/reddit-mcp-buddy.js index d50ba6d4..81e9149e 100644 --- a/backend/mcp-bundles/reddit-mcp-buddy.js +++ b/backend/mcp-bundles/reddit-mcp-buddy.js @@ -3943,7 +3943,7 @@ var require_core = __commonJS({ constructor(opts = {}) { this.schemas = {}; this.refs = {}; - this.formats = {}; + this.formats = /* @__PURE__ */ Object.create(null); this._compilations = /* @__PURE__ */ new Set(); this._loading = {}; this._cache = /* @__PURE__ */ new Map(); diff --git a/backend/npm-servers/notionhq-notion-mcp-server/package-lock.json b/backend/npm-servers/notionhq-notion-mcp-server/package-lock.json index 7b3550bb..abdf5252 100644 --- a/backend/npm-servers/notionhq-notion-mcp-server/package-lock.json +++ b/backend/npm-servers/notionhq-notion-mcp-server/package-lock.json @@ -404,9 +404,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -497,9 +497,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -510,7 +510,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -520,6 +520,21 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -929,9 +944,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -1182,9 +1197,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", + "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/backend/npm-servers/notionhq-notion-mcp-server/package.json b/backend/npm-servers/notionhq-notion-mcp-server/package.json index d1b7e7d5..f3463e9b 100644 --- a/backend/npm-servers/notionhq-notion-mcp-server/package.json +++ b/backend/npm-servers/notionhq-notion-mcp-server/package.json @@ -1,6 +1,7 @@ { "name": "notionhq-notion-mcp-server", "version": "1.0.0", + "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" @@ -11,6 +12,5 @@ "type": "commonjs", "dependencies": { "@notionhq/notion-mcp-server": "^2.2.1" - }, - "description": "" + } } diff --git a/backend/npm-servers/softeria-ms-365-mcp-server/package-lock.json b/backend/npm-servers/softeria-ms-365-mcp-server/package-lock.json index 9e0508ee..03bed81a 100644 --- a/backend/npm-servers/softeria-ms-365-mcp-server/package-lock.json +++ b/backend/npm-servers/softeria-ms-365-mcp-server/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@softeria/ms-365-mcp-server": "^0.85.0" + "@softeria/ms-365-mcp-server": "^0.85.1" } }, "node_modules/@azure-rest/core-client": { @@ -177,13 +177,13 @@ } }, "node_modules/@azure/identity/node_modules/@azure/msal-node": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.3.tgz", - "integrity": "sha512-LqT8mRZpEils9zGR9eW+Ljqifh2aMA99UF/X0jxIKDYZeHr6onlHwhVP4xHCeLhh55BI63JCbdf1iWJbMh1mPw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.4.tgz", + "integrity": "sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==", "license": "MIT", "optional": true, "dependencies": { - "@azure/msal-common": "16.5.0", + "@azure/msal-common": "16.5.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, @@ -284,22 +284,22 @@ } }, "node_modules/@azure/msal-browser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.7.0.tgz", - "integrity": "sha512-uYbJ0YarxkVGWEq814BysJry/IPvpDNkVKmc2bMZp4G+igUQkJ5nlFirycwPGUeA9ICLQqCxqExCA1Z1E07bPA==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.8.0.tgz", + "integrity": "sha512-X7IZV77bN56l7sbLjkcbQJX1t3U4tgxqztDr/XFbUcUfKk+z2FavcLgKP+OYUNj0wl/pEEtV9lldW9siY8BuHQ==", "license": "MIT", "optional": true, "dependencies": { - "@azure/msal-common": "16.5.0" + "@azure/msal-common": "16.5.1" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.0.tgz", - "integrity": "sha512-i3eS/5pmxDbIU/mLMENs88Qg3k6XxqJytJy6PpB7L1tCBjdXHJDadCD3Hu1TyTooe7iQo7CYqbocgL/l/8u90g==", + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.1.tgz", + "integrity": "sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==", "license": "MIT", "optional": true, "engines": { @@ -412,9 +412,9 @@ } }, "node_modules/@softeria/ms-365-mcp-server": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/@softeria/ms-365-mcp-server/-/ms-365-mcp-server-0.85.0.tgz", - "integrity": "sha512-QKzuaCFQEApBWLXbMAwoM+ptTvwxggCCPkFGWhKn1OlY9CO+IPxX9rTRIkOvsTUg6L3KpnKnzyeiAhet5gI4uA==", + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@softeria/ms-365-mcp-server/-/ms-365-mcp-server-0.85.1.tgz", + "integrity": "sha512-G7tF8rQ6D8yu5H935wLLTANGIHVk7f9zU7iWMLqozthPoW4jRaKjCi9ZYaLtNx4HXv+zI3i7neJwvh6RuMkyew==", "license": "MIT", "dependencies": { "@azure/msal-node": "^3.8.0", @@ -492,9 +492,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1098,9 +1098,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -1285,9 +1285,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", + "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/backend/npm-servers/softeria-ms-365-mcp-server/package.json b/backend/npm-servers/softeria-ms-365-mcp-server/package.json index 47a38a40..bacc1c22 100644 --- a/backend/npm-servers/softeria-ms-365-mcp-server/package.json +++ b/backend/npm-servers/softeria-ms-365-mcp-server/package.json @@ -1,6 +1,7 @@ { "name": "softeria-ms-365-mcp-server", "version": "1.0.0", + "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" @@ -10,6 +11,6 @@ "license": "ISC", "type": "commonjs", "dependencies": { - "@softeria/ms-365-mcp-server": "^0.85.0" + "@softeria/ms-365-mcp-server": "^0.85.1" } } diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index b93dd097..0de63c61 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -49,7 +49,7 @@ function isValidEmail(email: string): boolean { const SUBSCRIPTION_PROVIDERS = [ { id: 'openswarm-pro', name: 'OpenSwarm Pro', desc: 'One subscription — no setup, no Claude account needed', color: '#6366F1', preview: false, recommended: true }, { id: 'claude', name: 'Claude', desc: 'Use your own Claude Pro/Max subscription', color: '#E8927A', preview: false }, - { id: 'antigravity', name: 'Gemini', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro & Flash', color: '#4285F4', preview: false }, + { id: 'antigravity', name: 'Gemini Advanced', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro, 2.5 Flash', color: '#4285F4', preview: false }, { id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false }, ]; @@ -373,7 +373,16 @@ const OnboardingModal: React.FC = () => { trackEvent('onboarding.email_suggestion_applied'); }; - // Same connect logic as Settings/SubscriptionCards + // Mirrors Settings/SubscriptionCards `handleConnect` so the Gemini + // (and every other provider) connect popup behaves identically in + // onboarding and settings. The only onboarding-specific differences: + // - OpenSwarm Pro routes to the pricing step instead of OAuth. + // - On success we `dismiss()` the modal to advance the flow. + // Anything else — popup vs. system-browser dispatch, dual + // device-code+status polling, postMessage / Electron IPC code + // delivery, focus-based "user closed the popup" reset, 5-minute + // hard timeout — must stay byte-for-byte aligned with Settings or + // Gemini's anti-embedded-browser policy will break it again. const handleConnect = async (providerId: string) => { // Cancel any previous attempt if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } @@ -391,58 +400,111 @@ const OnboardingModal: React.FC = () => { return; } - // Delay before calling connect — avoids Claude OAuth rate limit on retries - await new Promise(r => setTimeout(r, 1000)); + // Small delay if retrying — avoids hitting Claude's rate limit + await new Promise(r => setTimeout(r, 500)); try { const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: providerId }), }); - if (!r.ok) { - setConnecting(null); - return; - } + if (!r.ok) { setConnecting(null); return; } const data = await r.json(); if (data.flow === 'device_code') { - if (data.verification_uri) window.open(data.verification_uri, '_blank'); + // Use a named window with features so Electron's + // setWindowOpenHandler routes it as a `new-window` popup, not a + // dashboard webview tab. Keep the reference so we can auto-close + // it once the backend poll detects success. + let devicePopup: Window | null = null; + if (data.verification_uri) { + devicePopup = window.open(data.verification_uri, 'oauth_connect', 'width=600,height=720'); + } - const timer = setInterval(async () => { + let stopped = false; + const onDeviceSuccess = () => { + if (stopped) return; + stopped = true; + clearInterval(devicePollTimer); + clearInterval(statusPollTimer); + pollTimerRef.current = null; + trackEvent('onboarding.provider_connected', { provider: providerId }); + // Auto-close the popup 2s after success so the user briefly + // sees the "Connected!" page then it goes away on its own. + setTimeout(() => { + if (devicePopup && !devicePopup.closed) { + try { devicePopup.close(); } catch {} + } + }, 2000); + dismiss(); + }; + + // Path 1: device-code poll — primary path when 9Router is happy. + const pollOnce = async () => { + if (stopped) return; try { const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: providerId, - device_code: data.device_code, - code_verifier: data.code_verifier, - extra_data: data.extra_data, - }), + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }), }); + if (!pr.ok) return; const pd = await pr.json(); - if (pd.success) { - clearInterval(timer); - pollTimerRef.current = null; - trackEvent('onboarding.provider_connected', { provider: providerId }); - dismiss(); + if (pd.success) onDeviceSuccess(); + } catch {} + }; + pollOnce(); + const devicePollTimer = setInterval(pollOnce, 5000); + + // Path 2: status poller — checks 9Router's connection list every + // 2s. Catches the connection even if the device-code poll silently + // errors (e.g. 9Router 500 from postExchange). + const statusPollTimer = setInterval(async () => { + if (stopped) return; + try { + const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); + const sd = await sr.json(); + const connections = sd.providers?.connections || []; + if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) { + onDeviceSuccess(); } } catch {} - }, 5000); - pollTimerRef.current = timer; - // 3-minute popup timeout (was 30s). OAuth with 2FA on Windows can - // easily take >30s; the connectedProviders poller above still picks - // up the connection after timeout, but a longer in-flow window - // keeps the "Connecting…" indicator accurate instead of flipping - // back to "Connect →" mid-auth. - setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 180000); + }, 2000); + + pollTimerRef.current = devicePollTimer; + + // 5-minute hard timeout — clean up everything if the user + // walks away mid-flow. + setTimeout(() => { + if (stopped) return; + stopped = true; + clearInterval(devicePollTimer); + clearInterval(statusPollTimer); + pollTimerRef.current = null; + setConnecting(null); + if (devicePopup && !devicePopup.closed) { + try { devicePopup.close(); } catch {} + } + }, 300000); } else if (data.flow === 'authorization_code') { - const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); + // Some providers (currently Gemini/Google) enforce an + // anti-embedded-browser policy on their OAuth consent page that + // no amount of user-agent spoofing defeats. For those, the + // backend sets `use_external_browser: true` and we open the + // auth URL in the user's default browser via shell.openExternal. + // The callback then lands on the backend's own + // /api/subscriptions/callback endpoint, which performs the + // exchange itself. Detection happens via the status poller — + // no postMessage handoff is possible because the system browser + // has no window.opener relationship back to us. + const useExternal = !!data.use_external_browser; + let popup: Window | null = null; + if (useExternal && (window as any).openswarm?.openExternal) { + (window as any).openswarm.openExternal(data.auth_url); + } else { + popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); + } - // Centralized exchange + cleanup so all three detection paths - // (postMessage, Electron IPC, status polling) can trigger it. let exchanged = false; const runExchange = async (code: string, state?: string) => { if (exchanged) return; @@ -451,41 +513,33 @@ const OnboardingModal: React.FC = () => { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - if (ipcUnsub) { ipcUnsub(); ipcUnsub = null; } - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + if (ipcUnsub) ipcUnsub(); + clearInterval(statusPoller); + pollTimerRef.current = null; if (popup && !popup.closed) popup.close(); try { await fetch(`${API_BASE}/agents/subscriptions/exchange`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - provider: providerId, - code, - redirect_uri: data.redirect_uri, - code_verifier: data.code_verifier, - state: state || data.state, - }), + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: providerId, code, redirect_uri: data.redirect_uri, code_verifier: data.code_verifier, state: state || data.state }), }); } catch {} trackEvent('onboarding.provider_connected', { provider: providerId }); dismiss(); }; - // Poll status as one detection path (works when the connection - // gets created server-side without a client-side callback). const statusPoller = setInterval(async () => { try { const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); const sd = await sr.json(); const connections = sd.providers?.connections || []; - if (connections.some((p: any) => p.provider === providerId && p.isActive)) { + if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) { if (!exchanged) { exchanged = true; if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - if (ipcUnsub) { ipcUnsub(); ipcUnsub = null; } + if (ipcUnsub) ipcUnsub(); clearInterval(statusPoller); pollTimerRef.current = null; trackEvent('onboarding.provider_connected', { provider: providerId }); @@ -496,24 +550,23 @@ const OnboardingModal: React.FC = () => { }, 2000); pollTimerRef.current = statusPoller; - // postMessage from the popup's /callback page (faster when the - // popup isn't cross-origin). + // postMessage listener — only meaningful for the in-app popup + // path. The system-browser path can't reach window.opener. const msgHandler = async (event: MessageEvent) => { const d = event.data; const callbackData = d?.type === 'oauth_callback' ? d.data : d; if (callbackData?.code) await runExchange(callbackData.code, callbackData.state); }; - window.addEventListener('message', msgHandler); - msgHandlerRef.current = msgHandler; + if (!useExternal) { + window.addEventListener('message', msgHandler); + msgHandlerRef.current = msgHandler; + } - // Electron IPC fallback — main.js captures child webContents + // Electron IPC fallback — main.js captures any child webContents // navigating to localhost:20128/callback?code=... and forwards - // the parsed params here. This is the REQUIRED path for Claude - // OAuth in Electron (cross-origin redirects sever opener chain, - // so postMessage can't fire). Without this listener, the - // onboarding flow would only see the connection via the - // 2-second status poll — but the code never gets exchanged - // because the CLI callback page in the popup never reaches us. + // the parsed params here. Required for Claude OAuth in Electron + // (cross-origin redirects sever the opener chain, so + // postMessage can't fire). let ipcUnsub: (() => void) | null = null; const ow = (window as any).openswarm; if (ow && typeof ow.onOauthCallback === 'function') { @@ -522,19 +575,26 @@ const OnboardingModal: React.FC = () => { }); } + // Timeout: 3 min for popup flow (was 30s — too short for 2FA / + // slow Windows OAuth where postMessage can silently fail), 5 + // minutes for external-browser flow (user has to tab-switch, + // log in, consent — takes much longer in practice). + const timeoutMs = useExternal ? 300_000 : 180_000; setTimeout(() => { - if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } - if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } - if (ipcUnsub) { ipcUnsub(); ipcUnsub = null; } + clearInterval(statusPoller); + pollTimerRef.current = null; + if (!useExternal && msgHandlerRef.current) { + window.removeEventListener('message', msgHandlerRef.current); + msgHandlerRef.current = null; + } + if (ipcUnsub) ipcUnsub(); setConnecting(null); - }, 180000); + }, timeoutMs); } else { setConnecting(null); } - } catch { - setConnecting(null); - } + } catch { setConnecting(null); } }; const handleApiKey = () => { trackEvent('onboarding.api_key_chosen'); dismiss(); }; diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 0a2cc763..a474bcef 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -26,6 +26,19 @@ elif [[ "${1:-}" == "--sign" ]]; then SIGN_MODE=true fi +# Defensive: detach any leftover OpenSwarm DMG volumes from prior failed builds. +# hdiutil's "Resource busy" / volume-name-collision errors almost always trace +# back to a stale mount in /Volumes (e.g. after a build crash or a still-open +# Finder window from the last run). +shopt -s nullglob +for vol in /Volumes/OpenSwarm*; do + if [[ -d "$vol" ]]; then + echo "Detaching leftover DMG mount: $vol" + hdiutil detach -force "$vol" 2>/dev/null || hdiutil detach "$vol" 2>/dev/null || true + fi +done +shopt -u nullglob + echo "========================================" echo " OpenSwarm Desktop App Builder" if $PUBLISH_MODE; then