diff --git a/.github/tauri-cef-expected-sha b/.github/tauri-cef-expected-sha index cab89c30c..8dac2a865 100644 --- a/.github/tauri-cef-expected-sha +++ b/.github/tauri-cef-expected-sha @@ -1 +1 @@ -c90c8a330056286e7c0d05439ae3d4527fa4fafe \ No newline at end of file +c90c8a330056286e7c0d05439ae3d4527fa4fafe diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx index 767f120de..219d00c6f 100644 --- a/app/src/components/oauth/OAuthProviderButton.tsx +++ b/app/src/components/oauth/OAuthProviderButton.tsx @@ -1,10 +1,16 @@ +import debug from 'debug'; import { useEffect, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { checkBackendHealthy } from '../../services/backendHealth'; -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'; @@ -29,7 +35,36 @@ const OAUTH_PREFLIGHT_TIMEOUT_MS = 4_000; const BACKEND_UNAVAILABLE_MESSAGE = 'OpenHuman cloud sign-in is temporarily unavailable. Please try again in a few minutes.'; -const getOAuthStartupFailureMessage = (provider: OAuthProviderConfig): string => { +const log = debug('oauth:button'); +const warnLog = debug('oauth:button:warn'); +const errorLog = debug('oauth:button:error'); + +const SAFE_READINESS_STARTUP_MESSAGE_PREFIXES = [ + 'Finish choosing how OpenHuman runs', + 'OpenHuman could not reach its local runtime', +]; + +const getSafeReadinessStartupMessage = (error: unknown): string | null => { + if (!(error instanceof Error)) { + return null; + } + + const message = error.message.trim(); + if (!message) { + return null; + } + + return SAFE_READINESS_STARTUP_MESSAGE_PREFIXES.some(prefix => message.startsWith(prefix)) + ? message + : null; +}; + +const getOAuthStartupFailureMessage = (provider: OAuthProviderConfig, error?: unknown): string => { + const readinessMessage = getSafeReadinessStartupMessage(error); + if (readinessMessage) { + return readinessMessage; + } + if (provider.id === 'twitter') { return 'Twitter/X sign-in could not start. Check that the Twitter OAuth app callback URL, client ID/secret, and requested scopes match the OpenHuman backend, then try again.'; } @@ -84,14 +119,14 @@ const OAuthProviderButton = ({ void checkBackendHealthy() .then(result => { if (!result.healthy) { - console.warn(`[oauth-button][${provider.id}] ${label} probe → backend unhealthy`, { + warnLog('[%s] %s probe -> backend unhealthy %o', provider.id, label, { reason: result.reason, latencyMs: result.latencyMs, status: 'status' in result ? result.status : undefined, }); setStartupError(BACKEND_UNAVAILABLE_MESSAGE); } else { - console.debug(`[oauth-button][${provider.id}] ${label} probe → backend healthy`, { + log('[%s] %s probe -> backend healthy %o', provider.id, label, { status: result.status, latencyMs: result.latencyMs, }); @@ -100,7 +135,7 @@ const OAuthProviderButton = ({ .catch(err => { // checkBackendHealthy already swallows network/abort errors and // turns them into a result; reaching this branch is unexpected. - console.debug(`[oauth-button][${provider.id}] ${label} probe threw`, err); + log('[%s] %s probe threw %o', provider.id, label, err); }); }; @@ -109,7 +144,7 @@ const OAuthProviderButton = ({ // and resetting first would briefly re-enable the button mid-redirect. const skipDuringDeepLink = (label: string) => { if (getDeepLinkAuthState().isProcessing) { - console.debug(`[oauth-button][${provider.id}] ${label} — skip (deep-link processing)`); + log('[%s] %s - skip (deep-link processing)', provider.id, label); return true; } return false; @@ -119,7 +154,7 @@ const OAuthProviderButton = ({ // browser. On most platforms this lifts the loading state immediately. const handleFocus = () => { if (skipDuringDeepLink('focus')) return; - console.debug(`[oauth-button][${provider.id}] window focus → reset isLoading`); + log('[%s] window focus -> reset isLoading', provider.id); reset(); probeBackendOnReturn('focus'); }; @@ -130,13 +165,13 @@ const OAuthProviderButton = ({ const handleVisibilityChange = () => { if (document.visibilityState !== 'visible') return; if (skipDuringDeepLink('visibilitychange')) return; - console.debug(`[oauth-button][${provider.id}] visibilitychange visible → reset isLoading`); + log('[%s] visibilitychange visible -> reset isLoading', provider.id); reset(); probeBackendOnReturn('visibilitychange'); }; const timer = window.setTimeout(() => { - console.debug(`[oauth-button][${provider.id}] timeout → reset isLoading`); + log('[%s] timeout -> reset isLoading', provider.id); reset(); // 90s with no deep-link is a strong "something went wrong" signal even // if the user never refocused the app. Probe so we can attribute it. @@ -161,29 +196,35 @@ const OAuthProviderButton = ({ if (externalDisabled || isLoading) return; - console.debug(`[oauth-button][${provider.id}] starting OAuth login (isTauri=${isTauri()})`); + log('[%s] starting OAuth login (isTauri=%s)', provider.id, isTauri()); setStartupError(null); setIsLoading(true); + beginDeepLinkAuthProcessing(); browserOpenedRef.current = false; - // Fail-fast pre-flight: hitting `api.tinyhumans.ai/health` before opening - // the browser lets us catch Cloudflare 504s / DNS outages immediately - // (issue #1985) instead of sending the user into a system browser that - // lands on a gateway-error page with no path back into the app. - const preflight = await checkBackendHealthy({ timeoutMs: OAUTH_PREFLIGHT_TIMEOUT_MS }); - if (!preflight.healthy) { - console.warn(`[oauth-button][${provider.id}] preflight → backend unhealthy`, { - reason: preflight.reason, - latencyMs: preflight.latencyMs, - status: 'status' in preflight ? preflight.status : undefined, - }); - setStartupError(BACKEND_UNAVAILABLE_MESSAGE); - setIsLoading(false); - return; - } - try { + // Fail-fast pre-flight: hitting `api.tinyhumans.ai/health` before opening + // the browser lets us catch Cloudflare 504s / DNS outages immediately + // (issue #1985) instead of sending the user into a system browser that + // lands on a gateway-error page with no path back into the app. + const preflight = await checkBackendHealthy({ timeoutMs: OAUTH_PREFLIGHT_TIMEOUT_MS }); + if (!preflight.healthy) { + warnLog('[%s] preflight -> backend unhealthy %o', provider.id, { + reason: preflight.reason, + latencyMs: preflight.latencyMs, + status: 'status' in preflight ? preflight.status : undefined, + }); + completeDeepLinkAuthProcessing(); + setStartupError(BACKEND_UNAVAILABLE_MESSAGE); + setIsLoading(false); + return; + } + + if (isTauri()) { + await prepareOAuthLoginLaunch(); + } + // Reuse the URL the preflight already resolved — `getBackendUrl()` may // hit a Tauri IPC round-trip and the result hasn't changed within a // single click handler. @@ -206,9 +247,11 @@ const OAuthProviderButton = ({ window.location.href = loginUrl; } browserOpenedRef.current = true; + completeDeepLinkAuthProcessing(); } catch (error) { - const message = getOAuthStartupFailureMessage(provider); - console.error(`[oauth-button][${provider.id}] OAuth startup failed`, { + completeDeepLinkAuthProcessing(); + const message = getOAuthStartupFailureMessage(provider, error); + errorLog('[%s] OAuth startup failed %o', provider.id, { provider: provider.id, providerName: provider.name, reason: summarizeOAuthStartupError(error), diff --git a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx index 657c29caa..e3d113e08 100644 --- a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx +++ b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx @@ -2,7 +2,12 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { checkBackendHealthy } from '../../../services/backendHealth'; -import { getDeepLinkAuthState } from '../../../store/deepLinkAuthState'; +import { + beginDeepLinkAuthProcessing, + completeDeepLinkAuthProcessing, + 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 +16,17 @@ vi.mock('../../../services/backendHealth', () => ({ checkBackendHealthy: 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, @@ -67,6 +80,10 @@ describe('OAuthProviderButton', () => { for (let i = 0; i < 6; i++) await Promise.resolve(); }); + expect(beginDeepLinkAuthProcessing).toHaveBeenCalledTimes(1); + expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1); + expect(prepareOAuthLoginLaunch).toHaveBeenCalledTimes(1); + expect(checkBackendHealthy).toHaveBeenCalledTimes(1); expect(openUrl).toHaveBeenCalledWith( expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/) ); @@ -212,15 +229,26 @@ describe('OAuthProviderButton', () => { 'Twitter/X sign-in could not start. Check that the Twitter OAuth app callback URL, client ID/secret, and requested scopes match the OpenHuman backend, then try again.' ); expect(screen.getByRole('button', { name: 'Twitter' })).toBeEnabled(); - expect(console.error).toHaveBeenCalledWith( - '[oauth-button][twitter] OAuth startup failed', - expect.objectContaining({ - provider: 'twitter', - providerName: 'Twitter', - guidance: expect.stringContaining('Twitter/X sign-in could not start'), - reason: expect.not.stringContaining('token=secret'), - }) - ); + expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1); + }); + + it('surfaces safe readiness messages when the pre-launch readiness check fails', async () => { + const readinessMessage = + 'OpenHuman could not reach its local runtime. Quit and reopen the app, then try signing in again.'; + vi.mocked(prepareOAuthLoginLaunch).mockRejectedValueOnce(new Error(readinessMessage)); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + + await act(async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + }); + + expect(openUrl).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent(readinessMessage); + expect(screen.getByRole('button', { name: 'Google' })).toBeEnabled(); + expect(completeDeepLinkAuthProcessing).toHaveBeenCalledTimes(1); }); // --- Pre-flight + post-failure backend health probe (issue #1985) --- 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..56bbcbeb6 --- /dev/null +++ b/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts @@ -0,0 +1,138 @@ +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 { isTauri } from '../../../services/webviewAccountService'; +import { getStoredCoreMode } from '../../../utils/configPersistence'; +import { + oauthAuthReadinessUserMessage, + prepareOAuthLoginLaunch, + 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('../../../services/webviewAccountService', () => ({ + 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); + vi.mocked(isTauri).mockReturnValue(true); + }); + + 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 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('does not block first-login callbacks on CoreStateProvider bootstrap once ping succeeds', async () => { + vi.mocked(getCoreStateSnapshot).mockReturnValue({ + 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: {}, + }); + + 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(); + }); + + it('starts the local core only once during pre-launch readiness', async () => { + await prepareOAuthLoginLaunch(); + + expect(bootCheckTransport.invokeCmd).toHaveBeenCalledTimes(1); + expect(bootCheckTransport.invokeCmd).toHaveBeenCalledWith('start_core_process', {}); + }); + + it('rejects with the readiness message when pre-launch core readiness fails', async () => { + vi.useFakeTimers(); + vi.mocked(testCoreRpcConnection).mockResolvedValue({ ok: false } as Response); + + try { + const launch = expect(prepareOAuthLoginLaunch()).rejects.toThrow( + oauthAuthReadinessUserMessage('core_unreachable') + ); + + await vi.advanceTimersByTimeAsync(8_000); + + await launch; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/app/src/components/oauth/oauthAuthReadiness.ts b/app/src/components/oauth/oauthAuthReadiness.ts new file mode 100644 index 000000000..3d519c4eb --- /dev/null +++ b/app/src/components/oauth/oauthAuthReadiness.ts @@ -0,0 +1,136 @@ +import debug from 'debug'; + +import { getCoreStateSnapshot } from '../../lib/coreState/store'; +import { bootCheckTransport } from '../../services/bootCheckService'; +import { getCoreRpcUrl, testCoreRpcConnection } from '../../services/coreRpcClient'; +import { isTauri } from '../../services/webviewAccountService'; +import { getStoredCoreMode } from '../../utils/configPersistence'; + +const logPrefix = '[oauth-auth-readiness]'; +const log = debug('oauth:auth-readiness'); +const warnLog = debug('oauth:auth-readiness:warn'); + +const DEFAULT_MAX_WAIT_MS = 30_000; +const POLL_MS = 200; + +export type OAuthAuthReadinessFailure = 'core_mode_unset' | 'core_unreachable'; + +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) { + log(`${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', {}); + log(`${logPrefix} start_core_process invoked`); + } catch (err) { + log(`${logPrefix} start_core_process skipped or failed`, err); + } +} + +/** + * Block OAuth sign-in until the BootCheckGate has committed a core mode, + * and the embedded core answers `core.ping`. + * + * 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. + * The login callback itself can proceed before CoreStateProvider completes its + * first snapshot refresh; requiring that UI bootstrap here deadlocks some E2E + * and first-login paths where the callback is what creates the session. + */ +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) { + warnLog(`${logPrefix} timed out waiting for core mode selection`); + return { ready: false, reason: 'core_mode_unset' }; + } + + await ensureLocalCoreProcessStarted(); + + while (Date.now() < deadline) { + const coreState = getCoreStateSnapshot(); + if (await pingCoreRpc()) { + log(`${logPrefix} ready`, { + authBootstrapComplete: !coreState.isBootstrapping, + hasSessionToken: Boolean(coreState.snapshot.sessionToken), + coreMode: getStoredCoreMode(), + }); + return { ready: true }; + } + + await delay(POLL_MS); + } + + if (!(await pingCoreRpc())) { + warnLog(`${logPrefix} core RPC unreachable after ${maxWaitMs}ms`); + return { ready: false, reason: 'core_unreachable' }; + } + + return { ready: true }; +} + +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.' + ); + default: + return 'Sign-in is still starting up. Wait a few seconds and try again.'; + } +} + +/** + * Lightweight preflight before opening the system browser for OAuth. + * Blocks browser launch when the local auth runtime is not ready yet. + * `waitForOAuthAuthReadiness()` starts the local core when needed. + */ +export async function prepareOAuthLoginLaunch(): Promise { + const quick = await waitForOAuthAuthReadiness(8_000); + if (!quick.ready) { + warnLog(`${logPrefix} pre-launch readiness`, quick); + throw new Error(oauthAuthReadinessUserMessage(quick.reason)); + } +} diff --git a/app/src/utils/__tests__/configPersistence.test.ts b/app/src/utils/__tests__/configPersistence.test.ts index 4fe8f58da..b1b9b537d 100644 --- a/app/src/utils/__tests__/configPersistence.test.ts +++ b/app/src/utils/__tests__/configPersistence.test.ts @@ -417,5 +417,23 @@ describe('configPersistence', () => { clearStoredCoreMode(); expect(getStoredCoreMode()).toBeNull(); }); + + it('falls back to the E2E default local mode when no marker has been written', async () => { + vi.resetModules(); + vi.doMock('../config', () => ({ + CORE_RPC_URL: 'http://127.0.0.1:7788/rpc', + E2E_DEFAULT_CORE_MODE: 'local', + })); + + try { + localStorage.removeItem(MODE_STORAGE_KEY); + const mod = await import('../configPersistence'); + + expect(mod.getStoredCoreMode()).toBe('local'); + } finally { + vi.doUnmock('../config'); + vi.resetModules(); + } + }); }); }); diff --git a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts index 150db6174..41665241d 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), @@ -42,6 +55,10 @@ describe('desktopDeepLinkListener', () => { vi.mocked(isTauri).mockReturnValue(true); vi.mocked(getCurrent).mockResolvedValue(null); vi.mocked(onOpenUrl).mockResolvedValue(() => {}); + waitForOAuthAuthReadiness.mockReset(); + waitForOAuthAuthReadiness.mockResolvedValue({ ready: true }); + vi.mocked(storeSession).mockReset(); + vi.mocked(storeSession).mockResolvedValue(undefined); windowControls.show.mockClear(); windowControls.unminimize.mockClear(); windowControls.setFocus.mockClear(); @@ -104,6 +121,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')); @@ -117,6 +147,34 @@ describe('desktopDeepLinkListener', () => { expect(state.errorMessage).toBe('Sign-in failed. Please try again.'); }); + it('does not make the E2E deep-link helper wait for auth readiness', async () => { + let resolveReadiness!: (_value: { ready: true }) => void; + waitForOAuthAuthReadiness.mockReturnValueOnce( + new Promise<{ ready: true }>(resolve => { + resolveReadiness = resolve; + }) + ); + + await setupDesktopDeepLinkListener(); + + const simulateDeepLink = ( + window as Window & { __simulateDeepLink?: (url: string) => Promise } + ).__simulateDeepLink; + + expect(simulateDeepLink).toBeTypeOf('function'); + await expect(simulateDeepLink!('openhuman://auth?token=abc&key=auth')).resolves.toBeUndefined(); + expect(storeSession).not.toHaveBeenCalled(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(waitForOAuthAuthReadiness).toHaveBeenCalledTimes(1); + + resolveReadiness({ ready: true }); + await waitForAuthSettled(); + + expect(storeSession).toHaveBeenCalledWith('abc', {}); + expect(getDeepLinkAuthState().isProcessing).toBe(false); + }); + it('sanitizes provider and error code values from OAuth error deep links', async () => { const oauthErrorEvents: CustomEvent[] = []; window.addEventListener('oauth:error', event => { diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index 4a222bc8e..1e2e62b1c 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -4,7 +4,7 @@ * Handles storing/retrieving user preferences like RPC URL using * localStorage (web) or Tauri store (desktop). */ -import { CORE_RPC_URL } from './config'; +import { CORE_RPC_URL, E2E_DEFAULT_CORE_MODE } from './config'; import { isTauri } from './tauriCommands'; // Storage key for RPC URL preference @@ -239,10 +239,15 @@ export function clearStoredCoreToken(): void { export function getStoredCoreMode(): 'local' | 'cloud' | null { try { const stored = localStorage.getItem(CORE_MODE_STORAGE_KEY)?.trim(); - if (stored === 'local' || stored === 'cloud') return stored; + if (stored) { + if (stored === 'local' || stored === 'cloud') return stored; + return null; + } } catch { console.warn('[configPersistence] Unable to access localStorage'); } + + if (E2E_DEFAULT_CORE_MODE === 'local') return 'local'; return null; } diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 50c75d1a6..254b6f96d 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); @@ -334,7 +328,9 @@ export const setupDesktopDeepLinkListener = async () => { // window.__simulateDeepLink('openhuman://oauth/success?integrationId=69cafd0b103bd070232d3223&provider=notion') // window.__simulateDeepLink('openhuman://oauth/success?integrationId=69cafd0b103bd070232d3223&skillId=discord') const win = window as Window & { __simulateDeepLink?: (url: string) => Promise }; - win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]); + win.__simulateDeepLink = async (url: string) => { + void handleDeepLinkUrls([url]); + }; } } catch (err) { console.error('[DeepLink] Setup failed:', err); 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 }; diff --git a/app/test/OAuthDiscord.test.tsx b/app/test/OAuthDiscord.test.tsx index d8f78f6f6..c81942c43 100644 --- a/app/test/OAuthDiscord.test.tsx +++ b/app/test/OAuthDiscord.test.tsx @@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils'; // Module mocks // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ +const { + mockGetBackendUrl, + mockOpenUrl, + mockIsTauri, + mockPrepareOAuthLoginLaunch, + mockCheckBackendHealthy, +} = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + mockPrepareOAuthLoginLaunch: vi.fn(), // Default to a healthy backend so the pre-flight in OAuthProviderButton // (added for issue #1985) doesn't short-circuit the OAuth flow these // tests exercise. @@ -49,6 +56,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); +vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => { + const actual = await importOriginal>(); + return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch }; +}); + +beforeEach(() => { + mockPrepareOAuthLoginLaunch.mockReset(); + mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined); +}); // --------------------------------------------------------------------------- // Helpers diff --git a/app/test/OAuthGitHub.test.tsx b/app/test/OAuthGitHub.test.tsx index 67e962164..17ca6550c 100644 --- a/app/test/OAuthGitHub.test.tsx +++ b/app/test/OAuthGitHub.test.tsx @@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils'; // Module mocks // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ +const { + mockGetBackendUrl, + mockOpenUrl, + mockIsTauri, + mockPrepareOAuthLoginLaunch, + mockCheckBackendHealthy, +} = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + mockPrepareOAuthLoginLaunch: vi.fn(), // Default to a healthy backend so the pre-flight in OAuthProviderButton // (added for issue #1985) doesn't short-circuit the OAuth flow these // tests exercise. @@ -49,6 +56,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); +vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => { + const actual = await importOriginal>(); + return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch }; +}); + +beforeEach(() => { + mockPrepareOAuthLoginLaunch.mockReset(); + mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined); +}); // --------------------------------------------------------------------------- // Helpers diff --git a/app/test/OAuthLoginSection.test.tsx b/app/test/OAuthLoginSection.test.tsx index 33888a17b..c4e688f6b 100644 --- a/app/test/OAuthLoginSection.test.tsx +++ b/app/test/OAuthLoginSection.test.tsx @@ -24,10 +24,17 @@ import { renderWithProviders } from '../src/test/test-utils'; // (which are hoisted to the top of the file by Vitest). // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ +const { + mockGetBackendUrl, + mockOpenUrl, + mockIsTauri, + mockPrepareOAuthLoginLaunch, + mockCheckBackendHealthy, +} = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + mockPrepareOAuthLoginLaunch: vi.fn(), // Default to a healthy backend so the pre-flight in OAuthProviderButton // (added for issue #1985) doesn't short-circuit the OAuth flow these // tests exercise. @@ -54,6 +61,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); +vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => { + const actual = await importOriginal>(); + return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch }; +}); + +beforeEach(() => { + mockPrepareOAuthLoginLaunch.mockReset(); + mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined); +}); // IS_DEV is set to `true` by the global setup mock of '../utils/config' diff --git a/app/test/OAuthTwitter.test.tsx b/app/test/OAuthTwitter.test.tsx index 0489ed216..a6331ba64 100644 --- a/app/test/OAuthTwitter.test.tsx +++ b/app/test/OAuthTwitter.test.tsx @@ -21,10 +21,17 @@ import { renderWithProviders } from '../src/test/test-utils'; // Module mocks // --------------------------------------------------------------------------- -const { mockGetBackendUrl, mockOpenUrl, mockIsTauri, mockCheckBackendHealthy } = vi.hoisted(() => ({ +const { + mockGetBackendUrl, + mockOpenUrl, + mockIsTauri, + mockPrepareOAuthLoginLaunch, + mockCheckBackendHealthy, +} = vi.hoisted(() => ({ mockGetBackendUrl: vi.fn(), mockOpenUrl: vi.fn(), mockIsTauri: vi.fn(), + mockPrepareOAuthLoginLaunch: vi.fn(), // Default to a healthy backend so the pre-flight in OAuthProviderButton // (added for issue #1985) doesn't short-circuit the OAuth flow these // tests exercise. @@ -49,6 +56,15 @@ vi.mock('../src/utils/tauriCommands', async importOriginal => { const actual = await importOriginal>(); return { ...actual, isTauri: mockIsTauri }; }); +vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => { + const actual = await importOriginal>(); + return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch }; +}); + +beforeEach(() => { + mockPrepareOAuthLoginLaunch.mockReset(); + mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined); +}); // --------------------------------------------------------------------------- // Helpers diff --git a/app/test/e2e/helpers/deep-link-helpers.ts b/app/test/e2e/helpers/deep-link-helpers.ts index 541117478..a3b37990f 100644 --- a/app/test/e2e/helpers/deep-link-helpers.ts +++ b/app/test/e2e/helpers/deep-link-helpers.ts @@ -81,6 +81,15 @@ function supportsWebDriverScriptExecute(): boolean { return false; } +function isAuthDeepLink(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'openhuman:' && parsed.hostname === 'auth'; + } catch { + return false; + } +} + /** * When WebDriver can execute JS in the app WebView, dispatch the same URLs as the * deep-link plugin via `window.__simulateDeepLink` (see desktopDeepLinkListener). @@ -200,6 +209,12 @@ export async function triggerDeepLink(url: string): Promise { platform: process.platform, }); + if (isAuthDeepLink(url)) { + await dismissBootCheckGateIfVisibleInline().catch(err => { + deepLinkDebug('pre-auth deep-link BootCheckGate dismiss failed (continuing):', err); + }); + } + if (typeof browser !== 'undefined') { // Strategy 1: WebView simulate — the only reliable path on the unified // CEF/Appium-Chromium harness. If it succeeds we're done; if it throws