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