diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx index edaf3ac43..8c46fef80 100644 --- a/app/src/components/oauth/OAuthProviderButton.tsx +++ b/app/src/components/oauth/OAuthProviderButton.tsx @@ -2,9 +2,14 @@ import { useEffect, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { getBackendUrl } from '../../services/backendUrl'; -import { getDeepLinkAuthState } from '../../store/deepLinkAuthState'; +import { + beginDeepLinkAuthProcessing, + completeDeepLinkAuthProcessing, + getDeepLinkAuthState, +} from '../../store/deepLinkAuthState'; import type { OAuthProviderConfig } from '../../types/oauth'; import { IS_DEV } from '../../utils/config'; +import { prepareOAuthLoginLaunch } from '../../utils/oauthAppVersionGate'; import { openUrl } from '../../utils/openUrl'; import { isTauri } from '../../utils/tauriCommands'; @@ -113,8 +118,13 @@ const OAuthProviderButton = ({ setStartupError(null); setIsLoading(true); + beginDeepLinkAuthProcessing(); try { + if (isTauri()) { + await prepareOAuthLoginLaunch(); + } + const backendUrl = await getBackendUrl(); const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`; @@ -134,6 +144,7 @@ const OAuthProviderButton = ({ window.location.href = loginUrl; } } catch (error) { + completeDeepLinkAuthProcessing(); const message = getOAuthStartupFailureMessage(provider); console.error(`[oauth-button][${provider.id}] OAuth startup failed`, { provider: provider.id, diff --git a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx index 5e2e8a428..58f7cb987 100644 --- a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx +++ b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx @@ -2,7 +2,11 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getBackendUrl } from '../../../services/backendUrl'; -import { getDeepLinkAuthState } from '../../../store/deepLinkAuthState'; +import { + beginDeepLinkAuthProcessing, + getDeepLinkAuthState, +} from '../../../store/deepLinkAuthState'; +import { prepareOAuthLoginLaunch } from '../../../utils/oauthAppVersionGate'; import { openUrl } from '../../../utils/openUrl'; import { isTauri } from '../../../utils/tauriCommands'; import OAuthProviderButton from '../OAuthProviderButton'; @@ -11,9 +15,17 @@ vi.mock('../../../services/backendUrl', () => ({ getBackendUrl: vi.fn() })); vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() })); +vi.mock('../../../utils/oauthAppVersionGate', () => ({ + prepareOAuthLoginLaunch: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../../utils/tauriCommands', () => ({ isTauri: vi.fn() })); -vi.mock('../../../store/deepLinkAuthState', () => ({ getDeepLinkAuthState: vi.fn() })); +vi.mock('../../../store/deepLinkAuthState', () => ({ + beginDeepLinkAuthProcessing: vi.fn(), + completeDeepLinkAuthProcessing: vi.fn(), + getDeepLinkAuthState: vi.fn(), +})); const stubProvider = { id: 'google' as const, @@ -59,6 +71,8 @@ describe('OAuthProviderButton', () => { await Promise.resolve(); }); + expect(beginDeepLinkAuthProcessing).toHaveBeenCalledTimes(1); + expect(prepareOAuthLoginLaunch).toHaveBeenCalledTimes(1); expect(getBackendUrl).toHaveBeenCalledTimes(1); expect(openUrl).toHaveBeenCalledWith( expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/) diff --git a/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts b/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts new file mode 100644 index 000000000..2c017912b --- /dev/null +++ b/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getCoreStateSnapshot } from '../../../lib/coreState/store'; +import { bootCheckTransport } from '../../../services/bootCheckService'; +import { testCoreRpcConnection } from '../../../services/coreRpcClient'; +import { getStoredCoreMode } from '../../../utils/configPersistence'; +import { isTauri } from '../../../utils/tauriCommands/common'; +import { oauthAuthReadinessUserMessage, waitForOAuthAuthReadiness } from '../oauthAuthReadiness'; + +vi.mock('../../../lib/coreState/store', () => ({ getCoreStateSnapshot: vi.fn() })); + +vi.mock('../../../services/coreRpcClient', () => ({ + getCoreRpcUrl: vi.fn().mockResolvedValue('http://127.0.0.1:7788/rpc'), + testCoreRpcConnection: vi.fn(), +})); + +vi.mock('../../../services/bootCheckService', () => ({ + bootCheckTransport: { invokeCmd: vi.fn().mockResolvedValue(undefined), callRpc: vi.fn() }, +})); + +vi.mock('../../../utils/configPersistence', () => ({ getStoredCoreMode: vi.fn() })); + +vi.mock('../../../utils/tauriCommands/common', () => ({ isTauri: vi.fn().mockReturnValue(true) })); + +describe('oauthAuthReadiness', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getStoredCoreMode).mockReturnValue('local'); + vi.mocked(getCoreStateSnapshot).mockReturnValue({ + isBootstrapping: false, + isReady: true, + snapshot: { + sessionToken: null, + auth: { isAuthenticated: false, userId: null, user: null, profileId: null }, + currentUser: null, + onboardingCompleted: false, + chatOnboardingCompleted: false, + analyticsEnabled: false, + meetAutoOrchestratorHandoff: false, + localState: { encryptionKey: null, onboardingTasks: null }, + runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, + }, + teams: [], + teamMembersById: {}, + teamInvitesById: {}, + }); + vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: true } as Response); + }); + + it('returns core_mode_unset when BootCheckGate has not committed a mode', async () => { + vi.mocked(getStoredCoreMode).mockReturnValue(null); + + const result = await waitForOAuthAuthReadiness(500); + + expect(result).toEqual({ ready: false, reason: 'core_mode_unset' }); + expect(oauthAuthReadinessUserMessage('core_mode_unset')).toMatch(/setup screen/i); + }); + + it('returns ready when core mode, bootstrap, and ping are satisfied', async () => { + const result = await waitForOAuthAuthReadiness(2_000); + + expect(result).toEqual({ ready: true }); + expect(bootCheckTransport.invokeCmd).toHaveBeenCalledWith('start_core_process', {}); + expect(testCoreRpcConnection).toHaveBeenCalled(); + }); + + it('returns core_unreachable when ping never succeeds', async () => { + vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: false } as Response); + + const result = await waitForOAuthAuthReadiness(600); + + expect(result).toEqual({ ready: false, reason: 'core_unreachable' }); + }); + + it('waits for bootstrap to finish before returning ready', async () => { + vi.mocked(getCoreStateSnapshot) + .mockReturnValueOnce({ + isBootstrapping: true, + isReady: false, + snapshot: { + sessionToken: null, + auth: { isAuthenticated: false, userId: null, user: null, profileId: null }, + currentUser: null, + onboardingCompleted: false, + chatOnboardingCompleted: false, + analyticsEnabled: false, + meetAutoOrchestratorHandoff: false, + localState: { encryptionKey: null, onboardingTasks: null }, + runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, + }, + teams: [], + teamMembersById: {}, + teamInvitesById: {}, + }) + .mockReturnValue({ + isBootstrapping: false, + isReady: true, + snapshot: { + sessionToken: null, + auth: { isAuthenticated: false, userId: null, user: null, profileId: null }, + currentUser: null, + onboardingCompleted: false, + chatOnboardingCompleted: false, + analyticsEnabled: false, + meetAutoOrchestratorHandoff: false, + localState: { encryptionKey: null, onboardingTasks: null }, + runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, + }, + teams: [], + teamMembersById: {}, + teamInvitesById: {}, + }); + + const result = await waitForOAuthAuthReadiness(3_000); + + expect(result).toEqual({ ready: true }); + }); + + it('does not start the local core on web builds', async () => { + vi.mocked(isTauri).mockReturnValue(false); + + await waitForOAuthAuthReadiness(1_000); + + expect(bootCheckTransport.invokeCmd).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/oauth/oauthAuthReadiness.ts b/app/src/components/oauth/oauthAuthReadiness.ts new file mode 100644 index 000000000..1a9e4ddd8 --- /dev/null +++ b/app/src/components/oauth/oauthAuthReadiness.ts @@ -0,0 +1,137 @@ +import { getCoreStateSnapshot } from '../../lib/coreState/store'; +import { bootCheckTransport } from '../../services/bootCheckService'; +import { getCoreRpcUrl, testCoreRpcConnection } from '../../services/coreRpcClient'; +import { getStoredCoreMode } from '../../utils/configPersistence'; +import { isTauri } from '../../utils/tauriCommands/common'; + +const logPrefix = '[oauth-auth-readiness]'; + +const DEFAULT_MAX_WAIT_MS = 30_000; +const POLL_MS = 200; + +export type OAuthAuthReadinessFailure = + | 'core_mode_unset' + | 'core_unreachable' + | 'bootstrap_timeout'; + +export type OAuthAuthReadinessResult = + | { ready: true } + | { ready: false; reason: OAuthAuthReadinessFailure }; + +const delay = (ms: number): Promise => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +async function pingCoreRpc(): Promise { + try { + const rpcUrl = await getCoreRpcUrl(); + const response = await testCoreRpcConnection(rpcUrl); + return response.ok; + } catch (err) { + console.debug(`${logPrefix} core.ping probe failed`, err); + return false; + } +} + +async function ensureLocalCoreProcessStarted(): Promise { + if (!isTauri()) { + return; + } + if (getStoredCoreMode() !== 'local') { + return; + } + try { + await bootCheckTransport.invokeCmd('start_core_process', {}); + console.debug(`${logPrefix} start_core_process invoked`); + } catch (err) { + console.debug(`${logPrefix} start_core_process skipped or failed`, err); + } +} + +/** + * Block OAuth sign-in until the BootCheckGate has committed a core mode, + * the embedded core answers `core.ping`, and CoreStateProvider has finished + * its first bootstrap pass (or already holds a session). + * + * First-launch sign-in often failed with a generic "Sign-in failed" because + * the deep-link handler only waited ~1.5s while `isBootstrapping` stayed true + * behind the runtime picker, then called RPC against a core that was not up yet. + */ +export async function waitForOAuthAuthReadiness( + maxWaitMs = DEFAULT_MAX_WAIT_MS +): Promise { + const deadline = Date.now() + maxWaitMs; + let sawCoreMode = false; + + while (Date.now() < deadline) { + const mode = getStoredCoreMode(); + if (mode) { + sawCoreMode = true; + break; + } + await delay(POLL_MS); + } + + if (!sawCoreMode) { + console.warn(`${logPrefix} timed out waiting for core mode selection`); + return { ready: false, reason: 'core_mode_unset' }; + } + + await ensureLocalCoreProcessStarted(); + + while (Date.now() < deadline) { + const coreState = getCoreStateSnapshot(); + const bootstrapReady = !coreState.isBootstrapping || Boolean(coreState.snapshot.sessionToken); + + if (bootstrapReady && (await pingCoreRpc())) { + console.debug(`${logPrefix} ready`, { + authBootstrapComplete: !coreState.isBootstrapping, + hasSessionToken: Boolean(coreState.snapshot.sessionToken), + coreMode: getStoredCoreMode(), + }); + return { ready: true }; + } + + await delay(POLL_MS); + } + + if (!(await pingCoreRpc())) { + console.warn(`${logPrefix} core RPC unreachable after ${maxWaitMs}ms`); + return { ready: false, reason: 'core_unreachable' }; + } + + console.warn(`${logPrefix} auth bootstrap still in flight after ${maxWaitMs}ms`); + return { ready: false, reason: 'bootstrap_timeout' }; +} + +export function oauthAuthReadinessUserMessage(reason: OAuthAuthReadinessFailure): string { + switch (reason) { + case 'core_mode_unset': + return ( + 'Finish choosing how OpenHuman runs (tap Continue on the setup screen), ' + + 'then try signing in again.' + ); + case 'core_unreachable': + return ( + 'OpenHuman could not reach its local runtime. Quit and reopen the app, ' + + 'then try signing in again.' + ); + case 'bootstrap_timeout': + default: + return 'Sign-in is still starting up. Wait a few seconds and try again.'; + } +} + +/** + * Lightweight preflight before opening the system browser for OAuth. + * Marks the Welcome screen as busy immediately and ensures the local core + * process has been asked to start when running in local mode. + */ +export async function prepareOAuthLoginLaunch(): Promise { + await ensureLocalCoreProcessStarted(); + const quick = await waitForOAuthAuthReadiness(8_000); + if (!quick.ready) { + console.warn(`${logPrefix} pre-launch readiness`, quick); + } +} diff --git a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts index 150db6174..33d489861 100644 --- a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts +++ b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts @@ -29,6 +29,19 @@ vi.mock('../../lib/coreState/store', () => ({ patchCoreStateSnapshot: vi.fn(), })); +const waitForOAuthAuthReadiness = vi.hoisted(() => + vi.fn().mockResolvedValue({ ready: true as const }) +); + +vi.mock('../oauthAppVersionGate', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + waitForOAuthAuthReadiness, + oauthAuthReadinessUserMessage: (reason: string) => `blocked:${reason}`, + }; +}); + const windowControls = vi.hoisted(() => ({ show: vi.fn().mockResolvedValue(undefined), unminimize: vi.fn().mockResolvedValue(undefined), @@ -104,6 +117,19 @@ describe('desktopDeepLinkListener', () => { expect(state.isProcessing).toBe(false); }); + it('surfaces readiness failures instead of a generic sign-in error', async () => { + waitForOAuthAuthReadiness.mockResolvedValueOnce({ ready: false, reason: 'core_mode_unset' }); + + vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']); + + await setupDesktopDeepLinkListener(); + + const state = getDeepLinkAuthState(); + expect(state.errorMessage).toBe('blocked:core_mode_unset'); + expect(state.isProcessing).toBe(false); + expect(storeSession).not.toHaveBeenCalled(); + }); + it('keeps requiresAppDataReset false for non-decryption auth failures', async () => { vi.mocked(storeSession).mockRejectedValueOnce(new Error('network down')); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 50c75d1a6..e254de2f5 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; -import { getCoreStateSnapshot, patchCoreStateSnapshot } from '../lib/coreState/store'; +import { patchCoreStateSnapshot } from '../lib/coreState/store'; import { consumeLoginToken } from '../services/api/authApi'; import { beginDeepLinkAuthProcessing, @@ -10,7 +10,11 @@ import { failDeepLinkAuthProcessing, } from '../store/deepLinkAuthState'; import { BILLING_DASHBOARD_URL } from './links'; -import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate'; +import { + evaluateOAuthAppVersionGate, + oauthAuthReadinessUserMessage, + waitForOAuthAuthReadiness, +} from './oauthAppVersionGate'; import { openUrl } from './openUrl'; import { storeSession } from './tauriCommands'; import { isTauri as coreIsTauri } from './tauriCommands/common'; @@ -71,22 +75,6 @@ const focusMainWindow = async () => { } }; -const waitForAuthReadiness = async (maxAttempts = 10, delayMs = 150) => { - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const coreState = getCoreStateSnapshot(); - if (!coreState.isBootstrapping || coreState.snapshot.sessionToken) { - console.log('[DeepLink][auth] app ready', { - attempt, - hasToken: Boolean(coreState.snapshot.sessionToken), - authBootstrapComplete: !coreState.isBootstrapping, - }); - return; - } - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - console.warn('[DeepLink][auth] readiness timeout; continuing'); -}; - const applySessionToken = async (sessionToken: string): Promise => { await storeSession(sessionToken, {}); patchCoreStateSnapshot({ snapshot: { sessionToken } }); @@ -109,7 +97,13 @@ const handleAuthDeepLink = async (parsed: URL) => { try { await focusMainWindow(); - await waitForAuthReadiness(); + + const readiness = await waitForOAuthAuthReadiness(); + if (!readiness.ready) { + console.warn('[DeepLink][auth] OAuth readiness gate blocked login', readiness); + failDeepLinkAuthProcessing(oauthAuthReadinessUserMessage(readiness.reason)); + return; + } const sessionToken = key === 'auth' ? token : await consumeLoginToken(token); await applySessionToken(sessionToken); diff --git a/app/src/utils/oauthAppVersionGate.ts b/app/src/utils/oauthAppVersionGate.ts index dff36d7c5..2f41bfaf4 100644 --- a/app/src/utils/oauthAppVersionGate.ts +++ b/app/src/utils/oauthAppVersionGate.ts @@ -1,9 +1,24 @@ import { getVersion } from '@tauri-apps/api/app'; +import { + type OAuthAuthReadinessFailure, + type OAuthAuthReadinessResult, + oauthAuthReadinessUserMessage, + prepareOAuthLoginLaunch, + waitForOAuthAuthReadiness, +} from '../components/oauth/oauthAuthReadiness'; import { LATEST_APP_DOWNLOAD_URL, MINIMUM_SUPPORTED_APP_VERSION } from './config'; import { isVersionAtLeast, parseSemverParts } from './semver'; import { isTauri } from './tauriCommands/common'; +export { + oauthAuthReadinessUserMessage, + prepareOAuthLoginLaunch, + waitForOAuthAuthReadiness, + type OAuthAuthReadinessFailure, + type OAuthAuthReadinessResult, +}; + export type OAuthAppVersionGateResult = | { ok: true } | { ok: false; current: string; minimum: string; downloadUrl: string };