fix(oauth): wait for runtime readiness before first sign-in (#1689)

First-launch Google/GitHub sign-in often failed because the auth deep link
ran before BootCheckGate finished and the embedded core answered RPC. Gate
OAuth on core mode + core.ping + bootstrap completion, start processing
earlier from the Welcome buttons, and surface actionable errors instead of a
generic failure message.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ghost Scripter
2026-05-20 02:25:02 +05:30
co-authored by Cursor
parent 71526ea4ab
commit 6019ea2f22
7 changed files with 345 additions and 22 deletions
@@ -2,9 +2,14 @@ import { useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext'; import { useT } from '../../lib/i18n/I18nContext';
import { getBackendUrl } from '../../services/backendUrl'; import { getBackendUrl } from '../../services/backendUrl';
import { getDeepLinkAuthState } from '../../store/deepLinkAuthState'; import {
beginDeepLinkAuthProcessing,
completeDeepLinkAuthProcessing,
getDeepLinkAuthState,
} from '../../store/deepLinkAuthState';
import type { OAuthProviderConfig } from '../../types/oauth'; import type { OAuthProviderConfig } from '../../types/oauth';
import { IS_DEV } from '../../utils/config'; import { IS_DEV } from '../../utils/config';
import { prepareOAuthLoginLaunch } from '../../utils/oauthAppVersionGate';
import { openUrl } from '../../utils/openUrl'; import { openUrl } from '../../utils/openUrl';
import { isTauri } from '../../utils/tauriCommands'; import { isTauri } from '../../utils/tauriCommands';
@@ -113,8 +118,13 @@ const OAuthProviderButton = ({
setStartupError(null); setStartupError(null);
setIsLoading(true); setIsLoading(true);
beginDeepLinkAuthProcessing();
try { try {
if (isTauri()) {
await prepareOAuthLoginLaunch();
}
const backendUrl = await getBackendUrl(); const backendUrl = await getBackendUrl();
const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`; const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`;
@@ -134,6 +144,7 @@ const OAuthProviderButton = ({
window.location.href = loginUrl; window.location.href = loginUrl;
} }
} catch (error) { } catch (error) {
completeDeepLinkAuthProcessing();
const message = getOAuthStartupFailureMessage(provider); const message = getOAuthStartupFailureMessage(provider);
console.error(`[oauth-button][${provider.id}] OAuth startup failed`, { console.error(`[oauth-button][${provider.id}] OAuth startup failed`, {
provider: provider.id, provider: provider.id,
@@ -2,7 +2,11 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getBackendUrl } from '../../../services/backendUrl'; 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 { openUrl } from '../../../utils/openUrl';
import { isTauri } from '../../../utils/tauriCommands'; import { isTauri } from '../../../utils/tauriCommands';
import OAuthProviderButton from '../OAuthProviderButton'; 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/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('../../../utils/oauthAppVersionGate', () => ({
prepareOAuthLoginLaunch: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../../utils/tauriCommands', () => ({ isTauri: vi.fn() })); 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 = { const stubProvider = {
id: 'google' as const, id: 'google' as const,
@@ -59,6 +71,8 @@ describe('OAuthProviderButton', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(beginDeepLinkAuthProcessing).toHaveBeenCalledTimes(1);
expect(prepareOAuthLoginLaunch).toHaveBeenCalledTimes(1);
expect(getBackendUrl).toHaveBeenCalledTimes(1); expect(getBackendUrl).toHaveBeenCalledTimes(1);
expect(openUrl).toHaveBeenCalledWith( expect(openUrl).toHaveBeenCalledWith(
expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/) expect.stringMatching(/^https:\/\/backend\.test\/auth\/google\/login(\?.*)?$/)
@@ -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();
});
});
@@ -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<void> =>
new Promise(resolve => {
setTimeout(resolve, ms);
});
async function pingCoreRpc(): Promise<boolean> {
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<void> {
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<OAuthAuthReadinessResult> {
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<void> {
await ensureLocalCoreProcessStarted();
const quick = await waitForOAuthAuthReadiness(8_000);
if (!quick.ready) {
console.warn(`${logPrefix} pre-launch readiness`, quick);
}
}
@@ -29,6 +29,19 @@ vi.mock('../../lib/coreState/store', () => ({
patchCoreStateSnapshot: vi.fn(), patchCoreStateSnapshot: vi.fn(),
})); }));
const waitForOAuthAuthReadiness = vi.hoisted(() =>
vi.fn().mockResolvedValue({ ready: true as const })
);
vi.mock('../oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<typeof import('../oauthAppVersionGate')>();
return {
...actual,
waitForOAuthAuthReadiness,
oauthAuthReadinessUserMessage: (reason: string) => `blocked:${reason}`,
};
});
const windowControls = vi.hoisted(() => ({ const windowControls = vi.hoisted(() => ({
show: vi.fn().mockResolvedValue(undefined), show: vi.fn().mockResolvedValue(undefined),
unminimize: vi.fn().mockResolvedValue(undefined), unminimize: vi.fn().mockResolvedValue(undefined),
@@ -104,6 +117,19 @@ describe('desktopDeepLinkListener', () => {
expect(state.isProcessing).toBe(false); 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 () => { it('keeps requiresAppDataReset false for non-decryption auth failures', async () => {
vi.mocked(storeSession).mockRejectedValueOnce(new Error('network down')); vi.mocked(storeSession).mockRejectedValueOnce(new Error('network down'));
+13 -19
View File
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/react';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; 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 { consumeLoginToken } from '../services/api/authApi';
import { import {
beginDeepLinkAuthProcessing, beginDeepLinkAuthProcessing,
@@ -10,7 +10,11 @@ import {
failDeepLinkAuthProcessing, failDeepLinkAuthProcessing,
} from '../store/deepLinkAuthState'; } from '../store/deepLinkAuthState';
import { BILLING_DASHBOARD_URL } from './links'; import { BILLING_DASHBOARD_URL } from './links';
import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate'; import {
evaluateOAuthAppVersionGate,
oauthAuthReadinessUserMessage,
waitForOAuthAuthReadiness,
} from './oauthAppVersionGate';
import { openUrl } from './openUrl'; import { openUrl } from './openUrl';
import { storeSession } from './tauriCommands'; import { storeSession } from './tauriCommands';
import { isTauri as coreIsTauri } from './tauriCommands/common'; 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<void> => { const applySessionToken = async (sessionToken: string): Promise<void> => {
await storeSession(sessionToken, {}); await storeSession(sessionToken, {});
patchCoreStateSnapshot({ snapshot: { sessionToken } }); patchCoreStateSnapshot({ snapshot: { sessionToken } });
@@ -109,7 +97,13 @@ const handleAuthDeepLink = async (parsed: URL) => {
try { try {
await focusMainWindow(); 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); const sessionToken = key === 'auth' ? token : await consumeLoginToken(token);
await applySessionToken(sessionToken); await applySessionToken(sessionToken);
+15
View File
@@ -1,9 +1,24 @@
import { getVersion } from '@tauri-apps/api/app'; 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 { LATEST_APP_DOWNLOAD_URL, MINIMUM_SUPPORTED_APP_VERSION } from './config';
import { isVersionAtLeast, parseSemverParts } from './semver'; import { isVersionAtLeast, parseSemverParts } from './semver';
import { isTauri } from './tauriCommands/common'; import { isTauri } from './tauriCommands/common';
export {
oauthAuthReadinessUserMessage,
prepareOAuthLoginLaunch,
waitForOAuthAuthReadiness,
type OAuthAuthReadinessFailure,
type OAuthAuthReadinessResult,
};
export type OAuthAppVersionGateResult = export type OAuthAppVersionGateResult =
| { ok: true } | { ok: true }
| { ok: false; current: string; minimum: string; downloadUrl: string }; | { ok: false; current: string; minimum: string; downloadUrl: string };