mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user