diff --git a/shared/app/global-errors.tsx b/shared/app/global-errors.tsx index 816a4ffa4733..205a1b45fd7c 100644 --- a/shared/app/global-errors.tsx +++ b/shared/app/global-errors.tsx @@ -112,14 +112,8 @@ const GlobalError = () => { const {daemonError, error, onDismiss, onFeedback} = d const {cachedDetails, cachedSummary, size, onExpandClick} = d - if (size === 'Closed') { - return null - } - - if (!daemonError && !error) { - return null - } - + // daemonError first: size only tracks globalError, so checking it before this + // branch would hide the disconnect overlay if (daemonError) { if (isMobile) { return null @@ -131,19 +125,23 @@ const GlobalError = () => { const message = daemonError.message || 'Keybase is currently unreachable. Trying to reconnect you…' return ( - - + + {message} - + ) } + if (size === 'Closed') { + return null + } + if (isMobile) { return ( import * as C from '@/constants' -import {useConfigState} from '@/stores/config' import {useShellState} from '@/stores/shell' import * as Kb from '@/common-adapters' import * as React from 'react' @@ -143,7 +142,6 @@ const useInit = () => { }, onEngineIncoming) initPlatformListener() eng.listenersAreReady() - useConfigState.getState().dispatch.installerRan() }, []) } diff --git a/shared/constants/config.tsx b/shared/constants/config.tsx index 86fd6cae67bf..4a58d3da8fbd 100644 --- a/shared/constants/config.tsx +++ b/shared/constants/config.tsx @@ -5,7 +5,6 @@ export const invalidPasswordErrorString = 'Bad password: Invalid password. Serve export const defaultKBFSPath = runMode === 'prod' ? '/keybase' : `/keybase.${runMode}` export const defaultPrivatePrefix = '/private/' export const defaultPublicPrefix = '/public/' -export const noKBFSFailReason = "Can't connect to KBFS" const defaultTeamPrefix = '/team/' export const privateFolderWithUsers = (users: ReadonlyArray) => diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index a41fb47aa5f9..c1fd38583335 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -2,7 +2,7 @@ import * as Chat from '@/constants/chat' import {ignorePromise, neverThrowPromiseFunc} from '@/constants/utils' import {useConfigState} from '@/stores/config' -import {useDaemonState} from '@/stores/daemon' +import {FatalHandshakeError, useDaemonState} from '@/stores/daemon' import {useRouterState} from '@/stores/router' import {useShellState, type ConnectionType} from '@/stores/shell' import {useSettingsContactsState} from '@/stores/settings-contacts' @@ -16,7 +16,6 @@ import {afterKbfsDaemonRpcStatusChanged} from '@/fs/common/lifecycle' import {logState, setThreadInputCommandStatus} from '@/constants/router' import {initSharedSubscriptions, _onEngineIncoming, onEngineConnected as onSharedEngineConnected} from './shared' import {noConversationIDKey} from '../types/chat/common' -import {noKBFSFailReason} from '@/constants/config' import {dumpLogs, persistRoute} from '@/util/storeless-actions' // ─── Desktop-only imports (runtime-guarded) ────────────────────────────────── @@ -190,15 +189,13 @@ const loadStartupDetails = async () => { const {guiConfig, Linking} = _getNative() const {getStartupDetailsFromInitialPush} = await import('./push-listener.native') - const [routeState, initialUrl, push] = await Promise.all([ - neverThrowPromiseFunc(async () => { - try { - const config = JSON.parse(guiConfig) as {ui?: {routeState2?: string}} | undefined - return Promise.resolve(config?.ui?.routeState2 ?? '') - } catch { - return Promise.resolve('') - } - }), + let routeState = '' + try { + const config = JSON.parse(guiConfig) as {ui?: {routeState2?: string}} | undefined + routeState = config?.ui?.routeState2 ?? '' + } catch {} + + const [initialUrl, push] = await Promise.all([ neverThrowPromiseFunc(async () => { const linkingStart = Date.now() logger.info('[Startup] loadStartupDetails: calling Linking.getInitialURL') @@ -450,16 +447,6 @@ const _initNativePlatformListener = () => { } } - useDaemonState.subscribe((s, old) => { - const versionChanged = s.handshakeVersion !== old.handshakeVersion - const stateChanged = s.handshakeState !== old.handshakeState - const justBecameReady = stateChanged && s.handshakeState === 'done' && old.handshakeState !== 'done' - - if (versionChanged || justBecameReady) { - configureAndroidCacheDir() - } - }) - useConfigState.subscribe((s, old) => { if (s.loggedIn === old.loggedIn) return const f = async () => { @@ -554,14 +541,23 @@ const _initNativePlatformListener = () => { ignorePromise(setupAudioMode(false)) if (isAndroid) { - const daemonState = useDaemonState.getState() - if (daemonState.handshakeState === 'done' || daemonState.handshakeVersion > 0) { + // HMR re-init after the handshake already finished: the bootstrap step won't run again + if (useDaemonState.getState().handshakeState === 'done') { configureAndroidCacheDir() } afterKbfsDaemonRpcStatusChanged() } - initSharedSubscriptions() + initSharedSubscriptions( + isAndroid + ? [ + async () => { + configureAndroidCacheDir() + return Promise.resolve() + }, + ] + : [] + ) } const _initDesktopPlatformListener = () => { @@ -660,30 +656,6 @@ const _initDesktopPlatformListener = () => { } _platformUnsubs.push(useDaemonState.subscribe((s, old) => { - if (s.handshakeVersion !== old.handshakeVersion) { - if (!isWindows) return - - const f = async () => { - const waitKey = 'pipeCheckFail' - const version = s.handshakeVersion - const {wait} = s.dispatch - wait(waitKey, version, true) - try { - logger.info('Checking RPC ownership') - if (KB2.functions.winCheckRPCOwnership) { - await KB2.functions.winCheckRPCOwnership() - } - wait(waitKey, version, false) - } catch (error_) { - // error will be logged in bootstrap check - getEngine().reset() - const error = error_ as {message?: string} - wait(waitKey, version, false, error.message || 'windows pipe owner fail', true) - } - } - ignorePromise(f()) - } - if (s.handshakeState !== old.handshakeState && s.handshakeState === 'done') { useConfigState.getState().dispatch.setStartupDetails({ conversation: Chat.noConversationIDKey, @@ -694,20 +666,24 @@ const _initDesktopPlatformListener = () => { } })) - useDaemonState.setState(s => { - s.dispatch.onRestartHandshakeNative = () => { - const {handshakeFailedReason} = useDaemonState.getState() - if (isWindows && handshakeFailedReason === noKBFSFailReason) { - KB2.functions.requestWindowsStartService?.() + const winPipeCheckStep = async () => { + try { + logger.info('Checking RPC ownership') + if (KB2.functions.winCheckRPCOwnership) { + await KB2.functions.winCheckRPCOwnership() } + } catch (error_) { + getEngine().reset() + const error = error_ as {message?: string} + throw new FatalHandshakeError(error.message || 'windows pipe owner fail') } - }) + } if (!isLinux) { afterKbfsDaemonRpcStatusChanged() } - initSharedSubscriptions() + initSharedSubscriptions(isWindows ? [winPipeCheckStep] : []) } export {onEngineConnected, onEngineDisconnected} from './shared' diff --git a/shared/constants/init/push-listener.native.tsx b/shared/constants/init/push-listener.native.tsx index a7a11b6f70d6..b2dd8e0986d0 100644 --- a/shared/constants/init/push-listener.native.tsx +++ b/shared/constants/init/push-listener.native.tsx @@ -8,11 +8,9 @@ import { getNativeEmitter, getInitialNotification, removeAllPendingNotificationRequests, - shareListenersRegistered, } from 'react-native-kb' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' -import {useLogoutState} from '@/stores/logout' import {usePushState} from '@/stores/push' import {useShellState} from '@/stores/shell' @@ -240,12 +238,6 @@ export const initPushListener = () => { .catch(() => {}) }) - // Token handling - useLogoutState.subscribe((s, old) => { - if (s.version === old.version) return - usePushState.getState().dispatch.deleteToken(s.version) - }) - let lastCount = -1 useConfigState.subscribe((s, old) => { if (s.badgeState === old.badgeState) return @@ -351,7 +343,9 @@ export const initPushListener = () => { } emitDeepLink('keybase://incoming-share') }) - shareListenersRegistered() + // shareListenersRegistered() is deliberately NOT called here: it makes native flush + // pending share intents, and emitDeepLink has no queue. The init/index.tsx router + // subscriber calls it once we're logged in with the router mounted. } } catch (e) { logger.error('[Push] failed to set up listeners: ', e) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 704b5983e427..c946979249da 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -5,8 +5,6 @@ import isEqual from 'lodash/isEqual' import logger from '@/logger' import * as Tabs from '@/constants/tabs' declare global { - var __hmr_startupOnce: boolean | undefined - var __hmr_sharedUnsubs: Array<() => void> | undefined var __hmr_platformUnsubs: Array<() => void> | undefined @@ -21,13 +19,13 @@ import type * as UseUsersStateType from '@/stores/users' import {notifyEngineActionListeners} from '@/engine/action-listener' import {serviceStaticConfigToStaticConfig} from '@/constants/chat/static-config' import {emitDeepLink} from '@/router-v2/linking' -import {ignorePromise} from '../utils' +import {ignorePromise, timeoutPromise} from '../utils' import {isPhone, serverConfigFileName} from '../platform' import {useAvatarState} from '@/common-adapters/avatar/store' import {useInboxLayoutState} from '@/chat/inbox/layout-state' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' -import {useDaemonState} from '@/stores/daemon' +import {useDaemonState, type BootstrapStep} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' import {useFollowerState} from '@/stores/followers' import {useShellState} from '@/stores/shell' @@ -52,10 +50,7 @@ import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' import {clearNavBadges} from '@/teams/actions' -let _emitStartupOnLoadDaemonConnectedOnce: boolean = __DEV__ ? (globalThis.__hmr_startupOnce ?? false) : false - const _sharedUnsubs: Array<() => void> = __DEV__ ? (globalThis.__hmr_sharedUnsubs ??= []) : [] -const getAccountsWaitKey = 'config.getAccounts' type SubscribeStore = { subscribe: (listener: (state: State, previousState: State) => void) => () => void @@ -78,51 +73,40 @@ type ConfigState = ReturnType type DaemonState = ReturnType type RouterState = ReturnType -const bootstrapStatusKey = (bootstrap?: DaemonState['bootstrapStatus']) => - bootstrap - ? [ - bootstrap.registered ? '1' : '0', - bootstrap.loggedIn ? '1' : '0', - bootstrap.uid, - bootstrap.username, - bootstrap.deviceID, - bootstrap.deviceName, - bootstrap.fullname, - bootstrap.httpSrvInfo?.address ?? '', - bootstrap.httpSrvInfo?.token ?? '', - ].join('\x00') - : '' - -const loadConfiguredAccountsForBootstrap = () => { - const configState = useConfigState.getState() - if (configState.configuredAccounts.length) { - return - } +// ─── Bootstrap steps ────────────────────────────────────────────────────────── +// Gating steps for the daemon handshake, run by useDaemonState.dispatch.startHandshake after +// bootstrapStatus loads. Throwing fails the attempt and triggers a retry. - const version = useDaemonState.getState().handshakeVersion - const handshakeWait = !configState.loggedIn - const refreshAccounts = configState.dispatch.refreshAccounts - const {wait} = useDaemonState.getState().dispatch - - const f = async () => { - try { - if (handshakeWait) { - wait(getAccountsWaitKey, version, true) - } - - await refreshAccounts() +const loadDarkPrefsStep = async () => { + useDarkModeState.getState().dispatch.loadDarkPrefs() + return Promise.resolve() +} - if (handshakeWait && useDaemonState.getState().handshakeWaiters.get(getAccountsWaitKey)) { - wait(getAccountsWaitKey, version, false) - } - } catch { - if (handshakeWait && useDaemonState.getState().handshakeWaiters.get(getAccountsWaitKey)) { - wait(getAccountsWaitKey, version, false, "Can't get accounts") - } - } +const loadChatStaticConfigStep = async () => { + const {chatBuiltinCommands, chatDeletableByDeleteHistory} = useConfigState.getState() + if (chatBuiltinCommands && chatDeletableByDeleteHistory) { + return } + const staticConfig = serviceStaticConfigToStaticConfig(await T.RPCChat.localGetStaticConfigRpcPromise()) + if (!staticConfig) { + logger.error('chat.loadStaticConfig: missing required static config') + return + } + useConfigState.getState().dispatch.setChatStaticConfig(staticConfig) +} - ignorePromise(f()) +const loadAccountsStep = async () => { + const refreshAccounts = useConfigState.getState().dispatch.refreshAccounts + if (useDaemonState.getState().bootstrapStatus?.loggedIn) { + // logged in: the account list only feeds the switcher, don't gate startup on it + ignorePromise(refreshAccounts().catch(() => {})) + return + } + try { + await refreshAccounts() + } catch { + throw new Error("Can't get accounts") + } } const requestFollowerInfoForStartup = () => { @@ -158,22 +142,25 @@ const refreshStartupChat = () => { } } -const onLoadOnStartPhaseChanged = (loadOnStartPhase: ConfigState['loadOnStartPhase']) => { - if (loadOnStartPhase !== 'startupOrReloginButNotInARush') { - return +// Loads that want a logged-in user but shouldn't compete with first paint +const scheduleStartupOrReloginWork = () => { + const f = async () => { + await timeoutPromise(1000) + requestAnimationFrame(() => { + requestFollowerInfoForStartup() + ignorePromise(updateServerConfigForStartup()) + loadStartupSettings() + refreshStartupChat() + }) } - - requestFollowerInfoForStartup() - ignorePromise(updateServerConfigForStartup()) - loadStartupSettings() - refreshStartupChat() + ignorePromise(f()) } const onGregorReachableChanged = (gregorReachable: ConfigState['gregorReachable']) => { // Re-get info about our account if you log in/we're done handshaking/became reachable if ( gregorReachable === T.RPCGen.Reachable.yes && - useDaemonState.getState().handshakeWaiters.size === 0 && + useDaemonState.getState().handshakeState === 'done' && !useConfigState.getState().userSwitching ) { ignorePromise(useDaemonState.getState().dispatch.loadDaemonBootstrapStatus()) @@ -182,7 +169,10 @@ const onGregorReachableChanged = (gregorReachable: ConfigState['gregorReachable' const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { if (loggedIn) { + // runtime login: refresh bootstrap status. During the handshake this is already in + // flight, and the store dedupes it. ignorePromise(useDaemonState.getState().dispatch.loadDaemonBootstrapStatus()) + scheduleStartupOrReloginWork() } else { clearSignupEmail() clearSignupDeviceNameDraft() @@ -191,14 +181,11 @@ const onLoggedInChanged = (loggedIn: ConfigState['loggedIn']) => { ) as typeof UseBlockButtonsStateType useBlockButtonsState.getState().dispatch.resetState() } - loadConfiguredAccountsForBootstrap() - if (!useConfigState.getState().loggedInCausedbyStartup) { - ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) - } + ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) } const onRevokedTriggerChanged = () => { - loadConfiguredAccountsForBootstrap() + ignorePromise(useConfigState.getState().dispatch.refreshAccounts()) } const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configuredAccounts']) => { @@ -211,35 +198,6 @@ const onConfiguredAccountsChanged = (configuredAccounts: ConfigState['configured } } -const loadChatStaticConfig = () => { - const {chatBuiltinCommands, chatDeletableByDeleteHistory} = useConfigState.getState() - if (chatBuiltinCommands && chatDeletableByDeleteHistory) { - return - } - const {handshakeVersion, dispatch} = useDaemonState.getState() - const f = async () => { - const name = 'chat.loadStatic' - dispatch.wait(name, handshakeVersion, true) - try { - const staticConfig = serviceStaticConfigToStaticConfig(await T.RPCChat.localGetStaticConfigRpcPromise()) - if (!staticConfig) { - logger.error('chat.loadStaticConfig: missing required static config') - return - } - useConfigState.getState().dispatch.setChatStaticConfig(staticConfig) - } finally { - dispatch.wait(name, handshakeVersion, false) - } - } - ignorePromise(f()) -} - -const onHandshakeVersionChanged = () => { - useDarkModeState.getState().dispatch.loadDarkPrefs() - loadChatStaticConfig() - loadConfiguredAccountsForBootstrap() -} - const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => { if (!bootstrap) { return @@ -259,20 +217,11 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) => logger.info('[Bootstrap] ignoring loggedIn=false result during account switch') return } - configDispatch.setLoggedIn(loggedIn, false) + configDispatch.setLoggedIn(loggedIn) if (bootstrap.httpSrvInfo) { configDispatch.setHTTPSrvInfo(bootstrap.httpSrvInfo.address, bootstrap.httpSrvInfo.token) } - -} - -const onHandshakeStateChanged = (handshakeState: DaemonState['handshakeState']) => { - if (handshakeState === 'done' && !_emitStartupOnLoadDaemonConnectedOnce) { - _emitStartupOnLoadDaemonConnectedOnce = true - if (__DEV__) globalThis.__hmr_startupOnce = true - useConfigState.getState().dispatch.loadOnStart('connectedToDaemonForFirstTime') - } } const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => { @@ -354,25 +303,27 @@ export const onEngineDisconnected = () => { useDaemonState.getState().dispatch.setError(new Error('Disconnected')) } -export const initSharedSubscriptions = () => { +export const initSharedSubscriptions = (platformBootstrapSteps: Array = []) => { + useDaemonState + .getState() + .dispatch.initBootstrapSteps([ + loadDarkPrefsStep, + loadChatStaticConfigStep, + loadAccountsStep, + ...platformBootstrapSteps, + ]) + // HMR cleanup: unsubscribe old store subscriptions before re-subscribing for (const unsub of _sharedUnsubs) unsub() _sharedUnsubs.length = 0 _sharedUnsubs.push( - subscribeValue(useConfigState, s => s.loadOnStartPhase, onLoadOnStartPhaseChanged), subscribeValue(useConfigState, s => s.gregorReachable, onGregorReachableChanged), subscribeValue(useConfigState, s => s.loggedIn, onLoggedInChanged), subscribeValue(useConfigState, s => s.revokedTrigger, onRevokedTriggerChanged), subscribeValue(useConfigState, s => s.configuredAccounts, onConfiguredAccountsChanged) ) - _sharedUnsubs.push( - subscribeValue(useDaemonState, s => s.handshakeVersion, onHandshakeVersionChanged), - subscribeValue(useDaemonState, s => bootstrapStatusKey(s.bootstrapStatus), () => - onBootstrapStatusChanged(useDaemonState.getState().bootstrapStatus) - ), - subscribeValue(useDaemonState, s => s.handshakeState, onHandshakeStateChanged) - ) + _sharedUnsubs.push(subscribeValue(useDaemonState, s => s.bootstrapStatus, onBootstrapStatusChanged)) _sharedUnsubs.push( subscribeValue(useRouterState, s => s.navState, onNavStateChanged) diff --git a/shared/constants/types/config.tsx b/shared/constants/types/config.tsx index 3d2714d906e0..809a87127ebf 100644 --- a/shared/constants/types/config.tsx +++ b/shared/constants/types/config.tsx @@ -4,7 +4,7 @@ export type OutOfDate = { updating: boolean outOfDate: boolean } -export type DaemonHandshakeState = 'starting' | 'waitingForWaiters' | 'done' +export type DaemonHandshakeState = 'loading' | 'done' | 'failed' export type ConfiguredAccount = { fullname?: string hasStoredSecret: boolean diff --git a/shared/fs/common/daemon.tsx b/shared/fs/common/daemon.tsx index a489e8a7c907..e1c038ab506e 100644 --- a/shared/fs/common/daemon.tsx +++ b/shared/fs/common/daemon.tsx @@ -3,7 +3,9 @@ import * as Constants from '@/constants/fs' import * as React from 'react' import * as RouterConstants from '@/constants/router' import * as T from '@/constants/types' +import {timeoutPromise} from '@/constants/utils' import {useConfigState} from '@/stores/config' +import {useDaemonState} from '@/stores/daemon' import {useRouterState} from '@/stores/router' import {afterKbfsDaemonRpcStatusChanged, fsUserIn, fsUserOut} from './lifecycle' @@ -22,6 +24,8 @@ const emptyFsDaemonActions: FsDaemonActions = { const FsDaemonStatusContext = React.createContext(undefined) const FsDaemonActionsContext = React.createContext(undefined) +const connectedPollIntervalMs = 5000 + const waitForKbfsDaemon = async ( generation: number, isCurrentAsyncGeneration: (generation: number) => boolean, @@ -30,36 +34,40 @@ const waitForKbfsDaemon = async ( waitForKbfsDaemonInProgressRef: {current: boolean}, asyncGenerationRef: {current: number} ) => { - while (isCurrentAsyncGeneration(generation)) { - try { - const connected = await T.RPCGen.configWaitForClientRpcPromise({ - clientType: T.RPCGen.ClientType.kbfs, - timeout: 0, // Don't wait; just check if it's there. - }) - if (!isCurrentAsyncGeneration(generation)) { - return - } - const newStatus = connected ? T.FS.KbfsDaemonRpcStatus.Connected : T.FS.KbfsDaemonRpcStatus.Waiting - if (kbfsDaemonStatusRef.current.rpcStatus !== newStatus) { - kbfsDaemonRpcStatusChanged(newStatus) - } - if (newStatus === T.FS.KbfsDaemonRpcStatus.Connected) { - return - } - waitForKbfsDaemonInProgressRef.current = true + // The service has no notification for a client detaching, so this loop runs for the whole + // login session: it polls while connected to notice kbfs dying, and waits while disconnected. + waitForKbfsDaemonInProgressRef.current = true + try { + while (isCurrentAsyncGeneration(generation)) { try { - await T.RPCGen.configWaitForClientRpcPromise({ + const connected = await T.RPCGen.configWaitForClientRpcPromise({ clientType: T.RPCGen.ClientType.kbfs, - timeout: 60, // 1min. This is arbitrary since we're gonna check again anyway if we're not connected. + timeout: 0, // Don't wait; just check if it's there. }) - } catch { - } finally { - if (generation === asyncGenerationRef.current) { - waitForKbfsDaemonInProgressRef.current = false + if (!isCurrentAsyncGeneration(generation)) { + return } + const newStatus = connected ? T.FS.KbfsDaemonRpcStatus.Connected : T.FS.KbfsDaemonRpcStatus.Waiting + if (kbfsDaemonStatusRef.current.rpcStatus !== newStatus) { + kbfsDaemonRpcStatusChanged(newStatus) + } + if (newStatus === T.FS.KbfsDaemonRpcStatus.Connected) { + await timeoutPromise(connectedPollIntervalMs) + continue + } + try { + await T.RPCGen.configWaitForClientRpcPromise({ + clientType: T.RPCGen.ClientType.kbfs, + timeout: 60, // 1min. This is arbitrary since we're gonna check again anyway if we're not connected. + }) + } catch {} + } catch { + return } - } catch { - return + } + } finally { + if (generation === asyncGenerationRef.current) { + waitForKbfsDaemonInProgressRef.current = false } } } @@ -68,6 +76,9 @@ export const FsDaemonProvider = ({children}: {children: React.ReactNode}) => { const loggedIn = useConfigState(s => s.loggedIn) const userSwitching = useConfigState(s => s.userSwitching) const installerRanCount = useConfigState(s => s.installerRanCount) + // Re-kick the watcher when the daemon handshake (re)completes: the watch loop exits + // if the service dies, and a new handshake means RPCs work again. + const handshakeDone = useDaemonState(s => s.handshakeState === 'done') const navState = useRouterState(s => s.navState as RouterConstants.NavState | undefined) const [kbfsDaemonStatus, setKbfsDaemonStatus] = React.useState( Constants.unknownKbfsDaemonStatus @@ -151,7 +162,7 @@ export const FsDaemonProvider = ({children}: {children: React.ReactNode}) => { return } checkKbfsDaemonRpcStatus() - }, [installerRanCount, shouldRunBackgroundFSRPC]) + }, [installerRanCount, shouldRunBackgroundFSRPC, handshakeDone]) React.useEffect(() => { const previousNavState = previousNavStateRef.current diff --git a/shared/login/loading.tsx b/shared/login/loading.tsx index 4310fa3d4a3e..37293f1fa2db 100644 --- a/shared/login/loading.tsx +++ b/shared/login/loading.tsx @@ -6,12 +6,12 @@ import {useDaemonState} from '@/stores/daemon' const SplashContainer = () => { const failedReason = useDaemonState(s => s.handshakeFailedReason) const retriesLeft = useDaemonState(s => s.handshakeRetriesLeft) + const handshakeFailed = useDaemonState(s => s.handshakeState === 'failed') const startHandshake = useDaemonState(s => s.dispatch.startHandshake) let status = '' let failed = '' - // Totally failed - if (retriesLeft === 0) { + if (handshakeFailed) { failed = failedReason } else if (retriesLeft === C.maxHandshakeTries) { // First try @@ -26,7 +26,7 @@ const SplashContainer = () => { C.Router2.navigateAppend({name: 'feedback', params: {}}) } : undefined - const onRetry = retriesLeft === 0 ? startHandshake : undefined + const onRetry = handshakeFailed ? startHandshake : undefined return } diff --git a/shared/menubar/index.desktop.tsx b/shared/menubar/index.desktop.tsx index 3540824207a7..dc521e3e78f7 100644 --- a/shared/menubar/index.desktop.tsx +++ b/shared/menubar/index.desktop.tsx @@ -499,7 +499,7 @@ const LoggedOut = (p: {daemonHandshakeState: T.Config.DaemonHandshakeState; logg const text = fullyLoggedOut ? 'You are logged out of Keybase.' - : daemonHandshakeState === 'waitingForWaiters' + : daemonHandshakeState === 'loading' ? 'Connecting interface to crypto engine... This may take a few seconds.' : 'Starting up Keybase...' diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index 30ed2dcb5c57..c76bcd5a0138 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -108,6 +108,17 @@ type LeftTabNavigatorType = { }> } +// Sticky: once the handshake finishes we never go back to the splash, even if it +// restarts later (engine reconnect); the disconnected overlay covers that case. +const useHandshakeEverDone = () => { + const everDoneRef = React.useRef(false) + return useDaemonState(s => { + const done = everDoneRef.current || s.handshakeState === 'done' + everDoneRef.current = done + return done + }) +} + let desktopTab: LeftTabNavigatorType | undefined const desktopTabComponents: Record = {} let DesktopRootComponent: React.ComponentType @@ -187,35 +198,18 @@ if (!isMobile) { title: '', } satisfies NativeStackNavigationOptions - const useIsLoadingDesktop = () => { - const everLoadedRef = React.useRef(false) - return !useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) - } + const useIsLoadingDesktop = () => !useHandshakeEverDone() const useIsLoggedInDesktop = () => { - const everLoadedRef = React.useRef(false) - const loggedInLoaded = useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) + const loaded = useHandshakeEverDone() const loggedIn = useConfigState(s => s.loggedIn) - return loggedInLoaded && loggedIn + return loaded && loggedIn } const useIsLoggedOutDesktop = () => { - const everLoadedRef = React.useRef(false) - const loggedInLoaded = useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) + const loaded = useHandshakeEverDone() const loggedIn = useConfigState(s => s.loggedIn) - return loggedInLoaded && !loggedIn + return loaded && !loggedIn } const desktopModalScreensConfig = routeMapToStaticScreens(modalRoutes, makeLayout, true, false, false) @@ -618,12 +612,7 @@ if (isMobile) { const nativeLinkingConfig = isMobile ? createLinkingConfig(handleAppLink) : undefined function NativeRouter() { - const everLoadedRef = React.useRef(false) - const loggedInLoaded = useDaemonState(s => { - const loaded = everLoadedRef.current || s.handshakeState === 'done' - everLoadedRef.current = loaded - return loaded - }) + const loggedInLoaded = useHandshakeEverDone() const {loggedIn, startupLoaded} = useConfigState( C.useShallow(s => ({loggedIn: s.loggedIn, startupLoaded: s.startup.loaded})) @@ -687,7 +676,7 @@ function NativeRouter() { return ( {bar} - + } linking={loggedIn ? nativeLinkingConfig : undefined} onReady={onReady} diff --git a/shared/settings/use-request-logout.tsx b/shared/settings/use-request-logout.tsx index 52bc16fdb319..9590ebc215a4 100644 --- a/shared/settings/use-request-logout.tsx +++ b/shared/settings/use-request-logout.tsx @@ -1,9 +1,10 @@ import * as C from '@/constants' +import {ignorePromise} from '@/constants/utils' import {navigateAppend, switchTab} from '@/constants/router' import {settingsPasswordTab} from '@/constants/settings' import * as T from '@/constants/types' import * as Tabs from '@/constants/tabs' -import {useLogoutState} from '@/stores/logout' +import {usePushState} from '@/stores/push' const navigateToLogoutPassword = () => { if (isMobile) { @@ -14,8 +15,15 @@ const navigateToLogoutPassword = () => { } } +const logout = async () => { + // Unregister the push token first; the API call needs the still-logged-in session + await usePushState.getState().dispatch.deleteTokenForLogout() + try { + await T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: false}) + } catch {} +} + export const useRequestLogout = () => { - const start = useLogoutState(s => s.dispatch.start) const canLogout = C.useRPC(T.RPCGen.userCanLogoutRpcPromise) return () => { @@ -23,7 +31,7 @@ export const useRequestLogout = () => { [undefined], canLogoutRes => { if (canLogoutRes.canLogout) { - start() + ignorePromise(logout()) } else { navigateToLogoutPassword() } diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 0d5cb81e6e84..d563271d541b 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -37,13 +37,6 @@ type Store = T.Immutable<{ justDeletedSelf: string justRevokedSelf: string loggedIn: boolean - loggedInCausedbyStartup: boolean - loadOnStartPhase: - | 'notStarted' - | 'initialStartupAsEarlyAsPossible' - | 'connectedToDaemonForFirstTime' - | 'reloggedIn' - | 'startupOrReloginButNotInARush' outOfDate: T.Config.OutOfDate remoteWindowNeedsProps: Map> revokedTrigger: number @@ -82,9 +75,7 @@ const initialStore: Store = { isOnline: true, justDeletedSelf: '', justRevokedSelf: '', - loadOnStartPhase: 'notStarted', loggedIn: false, - loggedInCausedbyStartup: false, loginError: undefined, outOfDate: { critical: false, @@ -110,7 +101,6 @@ export type State = Store & { initAppUpdateLoop: () => void installerRan: () => void loadIsOnline: () => void - loadOnStart: (phase: State['loadOnStartPhase']) => void login: (username: string, password: string) => void setLoginError: (error?: RPCError) => void logoutToLoggedOutFlow: () => void @@ -133,7 +123,7 @@ export type State = Store & { setHTTPSrvInfo: (address: string, token: string) => void setIncomingShareUseOriginal: (use: boolean) => void setJustDeletedSelf: (s: string) => void - setLoggedIn: (l: boolean, causedByStartup: boolean, fromMenubar?: boolean) => void + setLoggedIn: (l: boolean) => void setStartupDetails: (st: Omit) => void setOutOfDate: (outOfDate: T.Config.OutOfDate) => void setUpdating: () => void @@ -144,6 +134,8 @@ export type State = Store & { } export const useConfigState = Z.createZustand('config', (set, get) => { + let inflightRefreshAccounts: Promise | undefined + const _checkForUpdate = async () => { try { const {status, message} = await T.RPCGen.configGetUpdateInfoRpcPromise() @@ -237,12 +229,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { } ignorePromise(f()) }, - loadOnStart: phase => { - if (phase === get().loadOnStartPhase) return - set(s => { - s.loadOnStartPhase = phase - }) - }, login: (username, passphrase) => { const cancelDesc = 'Canceling RPC' const cancelOnCallback = (_: unknown, response: CommonResponseHandler) => { @@ -300,13 +286,13 @@ export const useConfigState = Z.createZustand('config', (set, get) => { waitingKey: waitingKeyConfigLogin, }) logger.info('login call succeeded') - get().dispatch.setLoggedIn(true, false) + get().dispatch.setLoggedIn(true) } catch (error) { if (!(error instanceof RPCError)) { return } if (error.code === T.RPCGen.StatusCode.scalreadyloggedin) { - get().dispatch.setLoggedIn(true, false) + get().dispatch.setLoggedIn(true) } else if (error.desc !== cancelDesc) { // If we're canceling then ignore the error error.desc = niceError(error) @@ -363,7 +349,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(registerForGregorNotifications()) onEngineConnectedInPlatform() - get().dispatch.loadOnStart('initialStartupAsEarlyAsPossible') }, onEngineIncoming: action => { switch (action.type) { @@ -400,7 +385,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { // only send this if we think we're not logged in const {loggedIn, dispatch} = get() if (!loggedIn) { - dispatch.setLoggedIn(true, false) + dispatch.setLoggedIn(true) } break } @@ -409,7 +394,7 @@ export const useConfigState = Z.createZustand('config', (set, get) => { const {loggedIn, dispatch} = get() // only send this if we think we're logged in (errors on provison can trigger this and mess things up) if (loggedIn) { - dispatch.setLoggedIn(false, false) + dispatch.setLoggedIn(false) } break } @@ -428,28 +413,39 @@ export const useConfigState = Z.createZustand('config', (set, get) => { ignorePromise(f()) }, refreshAccounts: async () => { - const defaultUsername = get().defaultUsername - const configuredAccounts = (await T.RPCGen.loginGetConfiguredAccountsRpcPromise()) ?? [] - const {setAccounts, setDefaultUsername} = get().dispatch + if (inflightRefreshAccounts) { + return inflightRefreshAccounts + } + const f = async () => { + const defaultUsername = get().defaultUsername + const configuredAccounts = (await T.RPCGen.loginGetConfiguredAccountsRpcPromise()) ?? [] + const {setAccounts, setDefaultUsername} = get().dispatch - let existingDefaultFound = false as boolean - let currentName = '' - const nextConfiguredAccounts: Array = [] + let existingDefaultFound = false as boolean + let currentName = '' + const nextConfiguredAccounts: Array = [] - configuredAccounts.forEach(account => { - const {username, isCurrent, fullname, hasStoredSecret, uid} = account - if (username === defaultUsername) { - existingDefaultFound = true - } - if (isCurrent) { - currentName = account.username + configuredAccounts.forEach(account => { + const {username, isCurrent, fullname, hasStoredSecret, uid} = account + if (username === defaultUsername) { + existingDefaultFound = true + } + if (isCurrent) { + currentName = account.username + } + nextConfiguredAccounts.push({fullname, hasStoredSecret, uid, username}) + }) + if (!existingDefaultFound) { + setDefaultUsername(currentName) } - nextConfiguredAccounts.push({fullname, hasStoredSecret, uid, username}) - }) - if (!existingDefaultFound) { - setDefaultUsername(currentName) + setAccounts(nextConfiguredAccounts) + } + inflightRefreshAccounts = f() + try { + await inflightRefreshAccounts + } finally { + inflightRefreshAccounts = undefined } - setAccounts(nextConfiguredAccounts) }, remoteWindowNeedsProps: (component, params) => { set(s => { @@ -555,30 +551,12 @@ export const useConfigState = Z.createZustand('config', (set, get) => { s.justDeletedSelf = self }) }, - setLoggedIn: (loggedIn, causedByStartup, fromMenubar = false) => { + setLoggedIn: loggedIn => { const changed = get().loggedIn !== loggedIn set(s => { s.loggedIn = loggedIn - s.loggedInCausedbyStartup = causedByStartup }) - - if (fromMenubar) return - - if (!changed) return - - const {loadOnStart} = get().dispatch - if (loggedIn) { - if (!causedByStartup) { - loadOnStart('reloggedIn') - const f = async () => { - await timeoutPromise(1000) - requestAnimationFrame(() => { - loadOnStart('startupOrReloginButNotInARush') - }) - } - ignorePromise(f()) - } - } else { + if (changed && !loggedIn) { Z.resetAllStores() } }, diff --git a/shared/stores/daemon.tsx b/shared/stores/daemon.tsx index b1fe1d64871f..fee4687b7013 100644 --- a/shared/stores/daemon.tsx +++ b/shared/stores/daemon.tsx @@ -1,152 +1,86 @@ import logger from '@/logger' -import {ignorePromise} from '@/constants/utils' +import isEqual from 'lodash/isEqual' +import {ignorePromise, timeoutPromise} from '@/constants/utils' import * as T from '@/constants/types' import * as Z from '@/util/zustand' import {maxHandshakeTries} from '@/constants/values' +// A bootstrap step gates the handshake: the app stays on the splash screen until every step +// resolves. Throwing fails the whole attempt (FatalHandshakeError skips the remaining retries). +// Steps are injected by initSharedSubscriptions since stores can't import the init layer. +export type BootstrapStep = () => Promise + +export class FatalHandshakeError extends Error {} + type Store = T.Immutable<{ bootstrapStatus?: T.RPCGen.BootstrapStatus error?: Error handshakeFailedReason: string handshakeRetriesLeft: number handshakeState: T.Config.DaemonHandshakeState - handshakeVersion: number - handshakeWaiters: Map - // if we ever restart handshake up this so we can ignore any waiters for old things }> const initialStore: Store = { bootstrapStatus: undefined, + error: undefined, handshakeFailedReason: '', handshakeRetriesLeft: maxHandshakeTries, - handshakeState: 'starting', - handshakeVersion: 0, - handshakeWaiters: new Map(), + handshakeState: 'loading', } export type State = Store & { dispatch: { + initBootstrapSteps: (steps: Array) => void loadDaemonBootstrapStatus: () => Promise resetState: () => void setError: (e?: Error) => void - setFailed: (r: string) => void - setState: (s: T.Config.DaemonHandshakeState) => void - updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void - wait: ( - name: string, - version: number, - increment: boolean, - failedReason?: string, - failedFatal?: true - ) => void startHandshake: () => void - daemonHandshake: (version: number) => void - daemonHandshakeDone: () => void - onRestartHandshakeNative: () => void + updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void } } -export const useDaemonState = Z.createZustand('daemon', (set, get) => { - const restartHandshake = () => { - get().dispatch.onRestartHandshakeNative() - set(s => { - s.handshakeState = 'starting' - s.handshakeFailedReason = '' - s.handshakeRetriesLeft = maxHandshakeTries - }) - } +const retryDelayMs = 1000 - let _firstTimeBootstrapDone = true - const maybeDoneWithDaemonHandshake = (version: number) => { - if (version !== get().handshakeVersion) { - // ignore out of date actions - return - } - const {handshakeWaiters, handshakeFailedReason, handshakeRetriesLeft} = get() - if (handshakeWaiters.size === 0) { - if (handshakeFailedReason) { - if (handshakeRetriesLeft) { - restartHandshake() - } - } else { - if (_firstTimeBootstrapDone) { - _firstTimeBootstrapDone = false - logger.info('First bootstrap ended') - } - get().dispatch.daemonHandshakeDone() - } - } - } - - // When there are no more waiters, we can show the actual app +export const useDaemonState = Z.createZustand('daemon', (set, get) => { + let bootstrapSteps: Array = [] + // bumped on every startHandshake (engine reconnect, splash Reload) so a stale in-flight + // run can't write results over a newer one + let generation = 0 + let inflightBootstrapStatus: Promise | undefined const dispatch: State['dispatch'] = { - daemonHandshake: version => { - get().dispatch.setState('waitingForWaiters') - const changed = get().handshakeVersion !== version - set(s => { - s.handshakeVersion = version - s.handshakeWaiters = new Map() - }) - - if (!changed) return - - const f = async () => { - const name = 'config.getBootstrapStatus' - const {wait} = get().dispatch - wait(name, version, true) - logger.info('[Bootstrap] loadDaemonBootstrapStatus: starting') - try { - await get().dispatch.loadDaemonBootstrapStatus() - } finally { - wait(name, version, false) - } - } - ignorePromise(f()) + initBootstrapSteps: steps => { + bootstrapSteps = steps }, - daemonHandshakeDone: () => { - get().dispatch.setState('done') - }, - // set to true so we reget status when we're reachable again loadDaemonBootstrapStatus: async () => { - const version = get().handshakeVersion - const {wait} = get().dispatch - - const s = await T.RPCGen.configGetBootstrapStatusRpcPromise() - set(state => { - state.bootstrapStatus = T.castDraft(s) - }) - - logger.info(`[Bootstrap] loggedIn: ${s.loggedIn ? 1 : 0}`) - - // set HTTP srv info - if (s.httpSrvInfo) { - logger.info(`[Bootstrap] http server: addr: ${s.httpSrvInfo.address} token: ${s.httpSrvInfo.token}`) - } else { - logger.info(`[Bootstrap] http server: no info given`) + if (inflightBootstrapStatus) { + return inflightBootstrapStatus } - - // if we're logged in act like getAccounts is done already - if (s.loggedIn) { - const {handshakeWaiters} = get() - if (handshakeWaiters.get('config.getAccounts')) { - wait('config.getAccounts', version, false) + const f = async () => { + const bs = await T.RPCGen.configGetBootstrapStatusRpcPromise() + logger.info( + `[Bootstrap] loggedIn: ${bs.loggedIn ? 1 : 0} http: ${bs.httpSrvInfo ? bs.httpSrvInfo.address : 'none'}` + ) + if (isEqual(bs, get().bootstrapStatus)) { + return } + set(s => { + s.bootstrapStatus = T.castDraft(bs) + }) + } + inflightBootstrapStatus = f() + try { + await inflightBootstrapStatus + } finally { + inflightBootstrapStatus = undefined } - }, - onRestartHandshakeNative: () => { - // overriden on desktop }, resetState: () => { set(s => ({ ...s, ...initialStore, - dispatch: { - ...s.dispatch, - onRestartHandshakeNative: s.dispatch.onRestartHandshakeNative, - }, + dispatch: s.dispatch, handshakeState: s.handshakeState, - handshakeVersion: s.handshakeVersion, })) }, setError: e => { @@ -157,26 +91,49 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { s.error = e }) }, - setFailed: r => { - set(s => { - s.handshakeFailedReason = r - }) - }, - setState: ds => { - if (ds === get().handshakeState) return - set(s => { - s.handshakeState = ds - }) - }, startHandshake: () => { - const nextVersion = get().handshakeVersion + 1 + const gen = ++generation set(s => { s.error = undefined - s.handshakeState = 'starting' s.handshakeFailedReason = '' - s.handshakeRetriesLeft = Math.max(0, s.handshakeRetriesLeft - 1) + s.handshakeRetriesLeft = maxHandshakeTries + s.handshakeState = 'loading' }) - get().dispatch.daemonHandshake(nextVersion) + const run = async () => { + while (gen === generation) { + try { + await get().dispatch.loadDaemonBootstrapStatus() + await Promise.all(bootstrapSteps.map(async step => step())) + if (gen !== generation) { + return + } + set(s => { + s.handshakeFailedReason = '' + s.handshakeState = 'done' + }) + logger.info('[Bootstrap] handshake done') + return + } catch (error) { + if (gen !== generation) { + return + } + const fatal = error instanceof FatalHandshakeError + logger.warn('[Bootstrap] handshake attempt failed:', error) + set(s => { + s.handshakeFailedReason = error instanceof Error ? error.message : String(error) + s.handshakeRetriesLeft = fatal ? 0 : Math.max(0, s.handshakeRetriesLeft - 1) + }) + if (get().handshakeRetriesLeft === 0) { + set(s => { + s.handshakeState = 'failed' + }) + return + } + await timeoutPromise(retryDelayMs) + } + } + } + ignorePromise(run()) }, updateUserReacjis: userReacjis => { set(s => { @@ -185,41 +142,6 @@ export const useDaemonState = Z.createZustand('daemon', (set, get) => { } }) }, - wait: (name, version, increment, failedReason, failedFatal) => { - const {handshakeState, handshakeFailedReason, handshakeVersion} = get() - if (handshakeState !== 'waitingForWaiters') { - throw new Error("Should only get a wait while we're waiting") - } - if (version !== handshakeVersion) { - logger.info('Ignoring handshake wait due to version mismatch', version, handshakeVersion) - return - } - set(s => { - const oldCount = s.handshakeWaiters.get(name) || 0 - const newCount = oldCount + (increment ? 1 : -1) - if (newCount === 0) { - s.handshakeWaiters.delete(name) - } else { - s.handshakeWaiters.set(name, newCount) - } - }) - const remaining = get().handshakeWaiters.size - logger.info(`[Bootstrap] waiter ${increment ? '+' : '-'} ${name} v${version}, remaining: ${remaining}`) - - if (failedFatal) { - set(s => { - s.handshakeFailedReason = failedReason || '' - s.handshakeRetriesLeft = 0 - }) - } else { - // Keep the first error - const f = failedReason || '' - if (f && !handshakeFailedReason) { - get().dispatch.setFailed(f) - } - } - maybeDoneWithDaemonHandshake(version) - }, } return { ...initialStore, diff --git a/shared/stores/logout.tsx b/shared/stores/logout.tsx deleted file mode 100644 index 43ab125cbbbd..000000000000 --- a/shared/stores/logout.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import logger from '@/logger' -import {ignorePromise, timeoutPromise} from '@/constants/utils' -import * as T from '@/constants/types' -// normally util.container but it re-exports from us so break the cycle -import * as Z from '@/util/zustand' - -// This store has no dependencies on other stores and is safe to import directly from other stores. -type Store = T.Immutable<{ - waiters: Map - // if we ever restart handshake up this so we can ignore any waiters for old things - version: number -}> - -const initialStore: Store = { - version: 1, - waiters: new Map(), -} - -export type State = Store & { - dispatch: { - resetState: () => void - wait: (name: string, version: number, increment: boolean) => void - start: () => void - } -} - -export const useLogoutState = Z.createZustand('logout', (set, get) => { - const dispatch: State['dispatch'] = { - resetState: () => { - set(s => ({ - ...s, - ...initialStore, - version: s.version, - waiters: s.waiters, - })) - }, - start: () => { - const version = get().version + 1 - set(s => { - s.version = version - }) - - // Give time for all waiters to register and allow the case where there are no waiters - const f = async () => { - const waitKey = 'nullhandshake' - get().dispatch.wait(waitKey, version, true) - await timeoutPromise(10) - get().dispatch.wait(waitKey, version, false) - } - ignorePromise(f()) - }, - wait: (name, _version, increment) => { - const {version} = get() - - if (version !== _version) { - logger.info('Ignoring handshake wait due to version mismatch', version, _version) - return - } - - set(s => { - const oldCount = s.waiters.get(name) || 0 - const newCount = oldCount + (increment ? 1 : -1) - if (newCount === 0) { - s.waiters.delete(name) - } else { - s.waiters.set(name, newCount) - } - }) - - const {waiters} = get() - if (waiters.size > 0) { - // still waiting for things to finish - } else { - T.RPCGen.loginLogoutRpcPromise({force: false, keepSecrets: false}) - .then(() => {}) - .catch(() => {}) - } - }, - } - return { - ...initialStore, - dispatch, - } -}) diff --git a/shared/stores/push.tsx b/shared/stores/push.tsx index 1f2d73b95ff5..2a9fe8a019a7 100644 --- a/shared/stores/push.tsx +++ b/shared/stores/push.tsx @@ -9,7 +9,6 @@ import {emitDeepLink} from '@/router-v2/linking' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' -import {useLogoutState} from '@/stores/logout' import {useWaitingState} from '@/stores/waiting' import {openAppSettings} from '@/util/storeless-actions' type Store = { @@ -24,7 +23,7 @@ type State = Store & { dispatch: { checkPermissions: () => Promise clearPendingPushNotification: () => void - deleteToken: (version: number) => void + deleteTokenForLogout: () => Promise handlePush: (notification: T.Push.PushNotification) => void initialPermissionsCheck: () => void rejectPermissions: () => void @@ -66,7 +65,7 @@ export const usePushState = Z.createZustand('push', (set, get) => { return Promise.resolve(false) }, clearPendingPushNotification: () => {}, - deleteToken: () => {}, + deleteTokenForLogout: async () => {}, handlePush: () => {}, initialPermissionsCheck: () => {}, rejectPermissions: () => {}, @@ -172,31 +171,24 @@ export const usePushState = Z.createZustand('push', (set, get) => { s.pendingPushNotification = undefined }) }, - deleteToken: version => { - const f = async () => { - const waitKey = 'push:deleteToken' - useLogoutState.getState().dispatch.wait(waitKey, version, true) - try { - const deviceID = useCurrentUserState.getState().deviceID - if (!deviceID) { - logger.info('[PushToken] no device id') - return - } - await T.RPCGen.apiserverDeleteRpcPromise({ - args: [ - {key: 'device_id', value: deviceID}, - {key: 'token_type', value: tokenType}, - ], - endpoint: 'device/push_token', - }) - logger.info('[PushToken] deleted from server') - } catch (e) { - logger.error('[PushToken] delete failed', e) - } finally { - useLogoutState.getState().dispatch.wait(waitKey, version, false) + deleteTokenForLogout: async () => { + try { + const deviceID = useCurrentUserState.getState().deviceID + if (!deviceID) { + logger.info('[PushToken] no device id') + return } + await T.RPCGen.apiserverDeleteRpcPromise({ + args: [ + {key: 'device_id', value: deviceID}, + {key: 'token_type', value: tokenType}, + ], + endpoint: 'device/push_token', + }) + logger.info('[PushToken] deleted from server') + } catch (e) { + logger.error('[PushToken] delete failed', e) } - ignorePromise(f()) }, handlePush: notification => { const f = async () => { diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index 8c67056013b1..229dca4c4c03 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -1,52 +1,127 @@ /// +import * as T from '@/constants/types' import {maxHandshakeTries} from '@/constants/values' import {resetAllStores} from '@/util/zustand' -import {useDaemonState} from '../daemon' +import {FatalHandshakeError, useDaemonState} from '../daemon' -afterEach(() => { - jest.restoreAllMocks() - resetAllStores() -}) +const bootstrapStatus = { + deviceID: 'd1', + deviceName: 'testuser-mac', + fullname: 'Test User', + loggedIn: true, + registered: true, + uid: 'u1', + username: 'testuser', +} as unknown as T.RPCGen.BootstrapStatus -test('wait tracks handshake waiters and keeps the first failure reason', () => { - const store = useDaemonState - - store.setState( - { - ...store.getState(), - handshakeState: 'waitingForWaiters', - handshakeVersion: 1, - }, - true - ) - store.getState().dispatch.wait('config.getBootstrapStatus', 1, true, 'first') - store.getState().dispatch.wait('config.getBootstrapStatus', 1, true, 'second') - store.getState().dispatch.wait('config.getBootstrapStatus', 1, false, 'ignored later') - - expect(store.getState().handshakeWaiters.get('config.getBootstrapStatus')).toBe(1) - expect(store.getState().handshakeFailedReason).toBe('first') -}) +describe('daemon store', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + resetAllStores() + }) + + test('startHandshake runs bootstrap steps and finishes', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + + expect(step).toHaveBeenCalledTimes(1) + expect(store.getState().handshakeState).toBe('done') + expect(store.getState().bootstrapStatus?.username).toBe('testuser') + }) + + test('a failing step retries and can recover', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}).mockRejectedValueOnce(new Error('flaky')) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + + expect(store.getState().handshakeState).toBe('loading') + expect(store.getState().handshakeFailedReason).toBe('flaky') + expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries - 1) + + await jest.advanceTimersByTimeAsync(1000) + + expect(store.getState().handshakeState).toBe('done') + expect(store.getState().handshakeFailedReason).toBe('') + }) + + test('exhausting retries fails the handshake', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}).mockRejectedValue(new Error('down')) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + for (let i = 1; i < maxHandshakeTries; i++) { + await jest.advanceTimersByTimeAsync(1000) + } + + expect(step).toHaveBeenCalledTimes(maxHandshakeTries) + expect(store.getState().handshakeState).toBe('failed') + expect(store.getState().handshakeRetriesLeft).toBe(0) + expect(store.getState().handshakeFailedReason).toBe('down') + }) + + test('a fatal error skips retries', async () => { + jest.spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise').mockResolvedValue(bootstrapStatus) + const step = jest.fn(async () => {}).mockRejectedValue(new FatalHandshakeError('pipe owner fail')) + const store = useDaemonState + store.getState().dispatch.initBootstrapSteps([step]) + + store.getState().dispatch.startHandshake() + await jest.advanceTimersByTimeAsync(0) + + expect(step).toHaveBeenCalledTimes(1) + expect(store.getState().handshakeState).toBe('failed') + expect(store.getState().handshakeRetriesLeft).toBe(0) + }) + + test('loadDaemonBootstrapStatus dedupes concurrent loads', async () => { + const spy = jest + .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') + .mockResolvedValue(bootstrapStatus) + const store = useDaemonState + + await Promise.all([ + store.getState().dispatch.loadDaemonBootstrapStatus(), + store.getState().dispatch.loadDaemonBootstrapStatus(), + ]) + + expect(spy).toHaveBeenCalledTimes(1) + expect(store.getState().bootstrapStatus?.uid).toBe('u1') + }) + + test('resetState preserves the handshake state but clears transient values', () => { + const store = useDaemonState + store.setState( + { + ...store.getState(), + error: new Error('boom'), + handshakeFailedReason: 'bad', + handshakeRetriesLeft: 0, + handshakeState: 'done', + }, + true + ) + + store.getState().dispatch.resetState() -test('resetState preserves the handshake session but clears transient values', () => { - const store = useDaemonState - - store.setState( - { - ...store.getState(), - error: new Error('boom'), - handshakeFailedReason: 'bad', - handshakeRetriesLeft: 0, - handshakeState: 'waitingForWaiters', - handshakeVersion: 7, - }, - true - ) - - store.getState().dispatch.resetState() - - expect(store.getState().handshakeState).toBe('waitingForWaiters') - expect(store.getState().handshakeVersion).toBe(7) - expect(store.getState().error).toMatchObject({message: 'boom'}) - expect(store.getState().handshakeFailedReason).toBe('') - expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries) + expect(store.getState().handshakeState).toBe('done') + expect(store.getState().error).toBe(undefined) + expect(store.getState().handshakeFailedReason).toBe('') + expect(store.getState().handshakeRetriesLeft).toBe(maxHandshakeTries) + }) }) diff --git a/shared/stores/tests/logout.test.ts b/shared/stores/tests/logout.test.ts deleted file mode 100644 index d71fbc1b235d..000000000000 --- a/shared/stores/tests/logout.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/// -import * as T from '../../constants/types' -import {resetAllStores} from '../../util/zustand' -import {useLogoutState} from '../logout' - -describe('logout store', () => { - afterEach(() => { - jest.restoreAllMocks() - resetAllStores() - }) - - test('wait ignores version mismatches', () => { - const store = useLogoutState - store.getState().dispatch.wait('sync', 999, true) - - expect(store.getState().waiters.size).toBe(0) - }) - - test('wait tracks outstanding work and logs out when the last waiter finishes', async () => { - const logoutSpy = jest.spyOn(T.RPCGen, 'loginLogoutRpcPromise').mockResolvedValue(undefined) - const store = useLogoutState - const version = store.getState().version - - store.getState().dispatch.wait('sync', version, true) - store.getState().dispatch.wait('sync', version, true) - expect(store.getState().waiters.get('sync')).toBe(2) - - store.getState().dispatch.wait('sync', version, false) - expect(store.getState().waiters.get('sync')).toBe(1) - expect(logoutSpy).not.toHaveBeenCalled() - - store.getState().dispatch.wait('sync', version, false) - await Promise.resolve() - - expect(store.getState().waiters.has('sync')).toBe(false) - expect(logoutSpy).toHaveBeenCalledWith({force: false, keepSecrets: false}) - }) - - test('start increments the handshake version', () => { - const store = useLogoutState - const version = store.getState().version - - store.getState().dispatch.start() - - expect(store.getState().version).toBe(version + 1) - }) -}) diff --git a/shared/stores/tests/push.desktop.test.ts b/shared/stores/tests/push.desktop.test.ts index 5a0c2882629d..8f640c662f53 100644 --- a/shared/stores/tests/push.desktop.test.ts +++ b/shared/stores/tests/push.desktop.test.ts @@ -12,7 +12,7 @@ test('desktop push store reports resettable defaults', async () => { await expect(dispatch.checkPermissions()).resolves.toBe(false) dispatch.clearPendingPushNotification() - dispatch.deleteToken(1) + await dispatch.deleteTokenForLogout() dispatch.initialPermissionsCheck() dispatch.rejectPermissions() dispatch.requestPermissions()