From 22a3fdb5c36b803c5f64f89ddb0f560f8fc60f66 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 12 Jan 2026 15:32:40 -0500 Subject: [PATCH 1/6] WIP --- shared/stores/team-building.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shared/stores/team-building.tsx b/shared/stores/team-building.tsx index cd65d9ee05de..d76b23e45847 100644 --- a/shared/stores/team-building.tsx +++ b/shared/stores/team-building.tsx @@ -1,6 +1,5 @@ import * as T from '@/constants/types' import {ignorePromise} from '@/constants/utils' -import * as Router2 from '@/stores/router2' import * as React from 'react' import * as Z from '@/util/zustand' import logger from '@/logger' @@ -12,7 +11,7 @@ import {type StoreApi, type UseBoundStore, useStore} from 'zustand' import {validateEmailAddress} from '@/util/email-address' import {registerDebugClear} from '@/util/debug' import {searchWaitingKey} from '@/constants/strings' -import {navigateUp} from '@/constants/router2' +import {navigateUp, getModalStack} from '@/constants/router2' export {allServices, selfToUser} from '@/constants/team-building' export {searchWaitingKey} from '@/constants/strings' @@ -301,7 +300,7 @@ const createSlice: Z.ImmerStateCreator = (set, get) => { }) }, closeTeamBuilding: () => { - const modals = Router2.getModalStack() + const modals = getModalStack() const routeNames = [...namespaceToRoute.values()] const routeName = modals.at(-1)?.name if (routeNames.includes(routeName ?? '')) { From 15cd7649b697983d567f7e35632fb37ec7b014fd Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 12 Jan 2026 15:47:11 -0500 Subject: [PATCH 2/6] WIP --- shared/constants/fs/platform-specific.android.tsx | 4 ++-- shared/constants/init/index.native.tsx | 2 -- shared/constants/init/shared.tsx | 4 +++- shared/stores/convostate.tsx | 3 --- shared/stores/fs.tsx | 7 +------ 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/shared/constants/fs/platform-specific.android.tsx b/shared/constants/fs/platform-specific.android.tsx index a31524a9b47e..f2c96a169173 100644 --- a/shared/constants/fs/platform-specific.android.tsx +++ b/shared/constants/fs/platform-specific.android.tsx @@ -1,5 +1,5 @@ -import * as T from '../types' -import {ignorePromise, wrapErrors} from '../utils' +import * as T from '@/constants/types' +import {ignorePromise, wrapErrors} from '@/constants/utils' import * as FS from '@/stores/fs' import logger from '@/logger' import nativeInit from './common.native' diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index 33f79bb51267..89535ef1bbc2 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -501,8 +501,6 @@ export const initPlatformListener = () => { }) initSharedSubscriptions() - - ignorePromise(useFSState.getState().dispatch.setupSubscriptions()) } export {onEngineConnected, onEngineDisconnected} from './shared' diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 5a3c36820eb0..990e4d798d9c 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -15,7 +15,7 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' -import {handleKeybaseLink} from '../deeplinks' +import {handleKeybaseLink} from '@/constants/deeplinks' import {useFollowerState} from '@/stores/followers' import isEqual from 'lodash/isEqual' import type * as UseFSStateType from '@/stores/fs' @@ -37,6 +37,7 @@ import type * as UseTracker2StateType from '@/stores/tracker2' import type * as UseUnlockFoldersStateType from '@/stores/unlock-folders' import type * as UseUsersStateType from '@/stores/users' import {useWhatsNewState} from '@/stores/whats-new' +import initFSPlatformSpecific from '@/constants/fs/platform-specific' let _emitStartupOnLoadDaemonConnectedOnce = false let _devicesLoaded = false @@ -336,6 +337,7 @@ export const initSharedSubscriptions = () => { }) initTeamBuildingCallbacks() + initFSPlatformSpecific () } // This is to defer loading stores we don't need immediately. diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index fd246ac0d5de..dd190e0caefe 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -298,7 +298,6 @@ export interface ConvoState extends ConvoStore { setReplyTo: (o: T.Chat.Ordinal) => void setThreadSearchQuery: (query: string) => void setTyping: DebouncedFunc<(t: Set) => void> - setupSubscriptions: () => void showInfoPanel: (show: boolean, tab: 'settings' | 'members' | 'attachments' | 'bots' | undefined) => void tabSelected: () => void threadSearch: (query: string) => void @@ -2845,7 +2844,6 @@ const createSlice: Z.ImmerStateCreator = (set, get) => { } }) }, 1000), - setupSubscriptions: () => {}, showInfoPanel: (show, tab) => { storeRegistry.getState('chat').dispatch.updateInfoPanel(show, tab) const conversationIDKey = get().id @@ -3254,7 +3252,6 @@ const createConvoStore = (id: T.Chat.ConversationIDKey) => { const next = Z.createZustand(createSlice) next.setState({id}) chatStores.set(id, next) - next.getState().dispatch.setupSubscriptions() return next } diff --git a/shared/stores/fs.tsx b/shared/stores/fs.tsx index a7dbef4ad088..86ac6863c5a0 100644 --- a/shared/stores/fs.tsx +++ b/shared/stores/fs.tsx @@ -269,7 +269,6 @@ export interface State extends Store { setTlfsAsUnloaded: () => void setTlfSyncConfig: (tlfPath: T.FS.Path, enabled: boolean) => void setSorting: (path: T.FS.Path, sortSetting: T.FS.SortSetting) => void - setupSubscriptions: () => Promise showIncomingShare: (initialDestinationParentPath: T.FS.Path) => void showMoveOrCopy: (initialDestinationParentPath: T.FS.Path) => void startManualConflictResolution: (tlfPath: T.FS.Path) => void @@ -1596,11 +1595,7 @@ export const useFSState = Z.createZustand((set, get) => { set(s => { s.tlfs.loaded = false }) - }, - setupSubscriptions: async () => { - const initPlatformSpecific = await import('../constants/fs/platform-specific') - initPlatformSpecific.default() - }, + }, showIncomingShare: initialDestinationParentPath => { set(s => { if (s.destinationPicker.source.type !== T.FS.DestinationPickerSource.IncomingShare) { From b9f60b3b93a541752a5a022f2da3df557255f83b Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 12 Jan 2026 15:48:24 -0500 Subject: [PATCH 3/6] WIP --- shared/constants/fs/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/shared/constants/fs/index.tsx b/shared/constants/fs/index.tsx index 543c1ff5d733..421000448081 100644 --- a/shared/constants/fs/index.tsx +++ b/shared/constants/fs/index.tsx @@ -1,9 +1,9 @@ import type * as React from 'react' -import * as Tabs from '../tabs' -import * as T from '../types' -import {isLinux, isMobile} from '../platform' -import {settingsFsTab} from '../settings' -import {navigateAppend} from '../router2' +import * as Tabs from '@constants/tabs' +import * as T from '@constants/types' +import {isLinux, isMobile} from '@constants/platform' +import {settingsFsTab} from '@constants/settings' +import {navigateAppend} from '@constants/router2' export const makeActionForOpenPathInFilesTab = ( // TODO: remove the second arg when we are done with migrating to nav2 path: T.FS.Path From a55cf834427e64b8ec860554c1545dd8c77c3061 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 12 Jan 2026 15:48:38 -0500 Subject: [PATCH 4/6] WIP --- shared/constants/init/index.desktop.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/constants/init/index.desktop.tsx b/shared/constants/init/index.desktop.tsx index 1b239d9bad46..4ae00ac9ef64 100644 --- a/shared/constants/init/index.desktop.tsx +++ b/shared/constants/init/index.desktop.tsx @@ -278,7 +278,7 @@ export const initPlatformListener = () => { }) initSharedSubscriptions() - ignorePromise(useFSState.getState().dispatch.setupSubscriptions()) + } export {onEngineConnected, onEngineDisconnected} from './shared' From 781e2d6cdbf0819f61c1ad9e2c31746ae3bd24fe Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 12 Jan 2026 16:02:16 -0500 Subject: [PATCH 5/6] WIP --- shared/constants/fs/common.native.tsx | 69 ---- .../fs/platform-specific.android.tsx | 66 ---- shared/constants/fs/platform-specific.d.ts | 2 - .../fs/platform-specific.desktop.tsx | 310 ------------------ shared/constants/fs/platform-specific.ios.tsx | 2 - shared/constants/init/index.desktop.tsx | 301 ++++++++++++++++- shared/constants/init/index.native.tsx | 120 ++++++- shared/constants/init/shared.tsx | 2 - 8 files changed, 419 insertions(+), 453 deletions(-) delete mode 100644 shared/constants/fs/common.native.tsx delete mode 100644 shared/constants/fs/platform-specific.android.tsx delete mode 100644 shared/constants/fs/platform-specific.d.ts delete mode 100644 shared/constants/fs/platform-specific.desktop.tsx delete mode 100644 shared/constants/fs/platform-specific.ios.tsx diff --git a/shared/constants/fs/common.native.tsx b/shared/constants/fs/common.native.tsx deleted file mode 100644 index 920f0561993f..000000000000 --- a/shared/constants/fs/common.native.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import logger from '@/logger' -import {ignorePromise} from '../utils' -import {wrapErrors} from '@/util/debug' -import * as T from '../types' -import * as Styles from '@/styles' -import * as FS from '@/stores/fs' -import {launchImageLibraryAsync} from '@/util/expo-image-picker.native' -import {saveAttachmentToCameraRoll, showShareActionSheet} from '../platform-specific' -import {useFSState} from '@/stores/fs' - -export default function initNative() { - useFSState.setState(s => { - s.dispatch.dynamic.pickAndUploadMobile = wrapErrors( - (type: T.FS.MobilePickType, parentPath: T.FS.Path) => { - const f = async () => { - try { - const result = await launchImageLibraryAsync(type, true, true) - if (result.canceled) return - result.assets.map(r => - useFSState.getState().dispatch.upload(parentPath, Styles.unnormalizePath(r.uri)) - ) - } catch (e) { - FS.errorToActionOrThrow(e) - } - } - ignorePromise(f()) - } - ) - - s.dispatch.dynamic.finishedDownloadWithIntentMobile = wrapErrors( - (downloadID: string, downloadIntent: T.FS.DownloadIntent, mimeType: string) => { - const f = async () => { - const {downloads, dispatch} = useFSState.getState() - const downloadState = downloads.state.get(downloadID) || FS.emptyDownloadState - if (downloadState === FS.emptyDownloadState) { - logger.warn('missing download', downloadID) - return - } - const dismissDownload = dispatch.dismissDownload - if (downloadState.error) { - dispatch.redbar(downloadState.error) - dismissDownload(downloadID) - return - } - const {localPath} = downloadState - try { - switch (downloadIntent) { - case T.FS.DownloadIntent.CameraRoll: - await saveAttachmentToCameraRoll(localPath, mimeType) - dismissDownload(downloadID) - return - case T.FS.DownloadIntent.Share: - await showShareActionSheet({filePath: localPath, mimeType}) - dismissDownload(downloadID) - return - case T.FS.DownloadIntent.None: - return - default: - return - } - } catch (err) { - FS.errorToActionOrThrow(err) - } - } - ignorePromise(f()) - } - ) - }) -} diff --git a/shared/constants/fs/platform-specific.android.tsx b/shared/constants/fs/platform-specific.android.tsx deleted file mode 100644 index f2c96a169173..000000000000 --- a/shared/constants/fs/platform-specific.android.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as T from '@/constants/types' -import {ignorePromise, wrapErrors} from '@/constants/utils' -import * as FS from '@/stores/fs' -import logger from '@/logger' -import nativeInit from './common.native' -import {useFSState} from '@/stores/fs' -import {androidAddCompleteDownload, fsCacheDir, fsDownloadDir} from 'react-native-kb' - -const finishedRegularDownloadIDs = new Set() - -export default function initPlatformSpecific() { - nativeInit() - - useFSState.setState(s => { - s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged = wrapErrors(() => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSConfigureDownloadRpcPromise({ - // Android's cache dir is (when I tried) [app]/cache but Go side uses - // [app]/.cache by default, which can't be used for sharing to other apps. - cacheDirOverride: fsCacheDir, - downloadDirOverride: fsDownloadDir, - }) - } - ignorePromise(f()) - }) - // needs to be called, TODO could make this better - s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged() - - s.dispatch.dynamic.finishedRegularDownloadMobile = wrapErrors((downloadID: string, mimeType: string) => { - const f = async () => { - // This is fired from a hook and can happen more than once per downloadID. - // So just deduplicate them here. This is small enough and won't happen - // constantly, so don't worry about clearing them. - if (finishedRegularDownloadIDs.has(downloadID)) { - return - } - finishedRegularDownloadIDs.add(downloadID) - - const {downloads} = useFSState.getState() - - const downloadState = downloads.state.get(downloadID) || FS.emptyDownloadState - const downloadInfo = downloads.info.get(downloadID) || FS.emptyDownloadInfo - if (downloadState === FS.emptyDownloadState || downloadInfo === FS.emptyDownloadInfo) { - logger.warn('missing download', downloadID) - return - } - if (downloadState.error) { - return - } - try { - await androidAddCompleteDownload({ - description: `Keybase downloaded ${downloadInfo.filename}`, - mime: mimeType, - path: downloadState.localPath, - showNotification: true, - title: downloadInfo.filename, - }) - } catch { - logger.warn('Failed to addCompleteDownload') - } - // No need to dismiss here as the download wrapper does it for Android. - } - ignorePromise(f()) - }) - }) -} diff --git a/shared/constants/fs/platform-specific.d.ts b/shared/constants/fs/platform-specific.d.ts deleted file mode 100644 index e5bdb364c59a..000000000000 --- a/shared/constants/fs/platform-specific.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare function initPlatformSpecific(): void -export default initPlatformSpecific diff --git a/shared/constants/fs/platform-specific.desktop.tsx b/shared/constants/fs/platform-specific.desktop.tsx deleted file mode 100644 index 25ff18430ff0..000000000000 --- a/shared/constants/fs/platform-specific.desktop.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import * as T from '@/constants/types' -import {ignorePromise, wrapErrors} from '@/constants/utils' -import * as Constants from '@/constants/fs' -import * as Tabs from '@/constants/tabs' -import {isWindows, isLinux, pathSep, isDarwin} from '@/constants/platform.desktop' -import logger from '@/logger' -import * as Path from '@/util/path' -import KB2 from '@/util/electron.desktop' -import {uint8ArrayToHex} from 'uint8array-extras' -import {navigateAppend} from '@/constants/router2' -import {useConfigState} from '@/stores/config' -import {useFSState, errorToActionOrThrow} from '@/stores/fs' - -const {openPathInFinder, openURL, getPathType, selectFilesToUploadDialog} = KB2.functions -const {darwinCopyToKBFSTempUploadFile, relaunchApp, uninstallKBFSDialog, uninstallDokanDialog} = KB2.functions -const {exitApp, windowsCheckMountFromOtherDokanInstall, installCachedDokan, uninstallDokan} = KB2.functions - -// _openPathInSystemFileManagerPromise opens `openPath` in system file manager. -// If isFolder is true, it just opens it. Otherwise, it shows it in its parent -// folder. This function does not check if the file exists, or try to convert -// KBFS paths. Caller should take care of those. -const _openPathInSystemFileManagerPromise = async (openPath: string, isFolder: boolean): Promise => - openPathInFinder?.(openPath, isFolder) - -const escapeBackslash = isWindows - ? (pathElem: string): string => - pathElem - .replace(/‰/g, '‰2030') - .replace(/([<>:"/\\|?*])/g, (_, c: Uint8Array) => '‰' + uint8ArrayToHex(c)) - : (pathElem: string): string => pathElem - -const _rebaseKbfsPathToMountLocation = (kbfsPath: T.FS.Path, mountLocation: string) => - Path.join(mountLocation, T.FS.getPathElements(kbfsPath).slice(1).map(escapeBackslash).join(pathSep)) - -const fuseStatusToUninstallExecPath = isWindows - ? (status: T.RPCGen.FuseStatus) => { - const field = status.status.fields?.find(({key}) => key === 'uninstallString') - return field?.value - } - : () => undefined - -const fuseStatusToActions = - (previousStatusType: T.FS.DriverStatusType) => (status: T.RPCGen.FuseStatus | undefined) => { - if (!status) { - useFSState.getState().dispatch.setDriverStatus(Constants.defaultDriverStatus) - return - } - - if (status.kextStarted) { - useFSState.getState().dispatch.setDriverStatus({ - ...Constants.emptyDriverStatusEnabled, - dokanOutdated: status.installAction === T.RPCGen.InstallAction.upgrade, - dokanUninstallExecPath: fuseStatusToUninstallExecPath(status), - }) - } else { - useFSState.getState().dispatch.setDriverStatus(Constants.emptyDriverStatusDisabled) - } - - if (status.kextStarted && previousStatusType === T.FS.DriverStatusType.Disabled) { - useFSState - .getState() - .dispatch.dynamic.openPathInSystemFileManagerDesktop?.(T.FS.stringToPath('/keybase')) - } - } - -const fuseInstallResultIsKextPermissionError = (result: T.RPCGen.InstallResult): boolean => - result.componentResults?.findIndex( - c => c.name === 'fuse' && c.exitCode === Constants.ExitCodeFuseKextPermissionError - ) !== -1 - -const driverEnableFuse = async (isRetry: boolean) => { - const result = await T.RPCGen.installInstallFuseRpcPromise() - if (fuseInstallResultIsKextPermissionError(result)) { - useFSState.getState().dispatch.driverKextPermissionError() - if (!isRetry) { - navigateAppend('kextPermission') - } - } else { - await T.RPCGen.installInstallKBFSRpcPromise() // restarts kbfsfuse - await T.RPCGen.kbfsMountWaitForMountsRpcPromise() - useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() - } -} - -const uninstallKBFSConfirm = async () => { - const remove = await (uninstallKBFSDialog?.() ?? Promise.resolve(false)) - if (remove) { - useFSState.getState().dispatch.driverDisabling() - } -} - -const uninstallKBFS = async () => - T.RPCGen.installUninstallKBFSRpcPromise().then(() => { - // Restart since we had to uninstall KBFS and it's needed by the service (for chat) - relaunchApp?.() - exitApp?.(0) - }) - -const uninstallDokanConfirm = async () => { - const driverStatus = useFSState.getState().sfmi.driverStatus - if (driverStatus.type !== T.FS.DriverStatusType.Enabled) { - return - } - if (!driverStatus.dokanUninstallExecPath) { - await uninstallDokanDialog?.() - useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() - return - } - useFSState.getState().dispatch.driverDisabling() -} - -const onUninstallDokan = async () => { - const driverStatus = useFSState.getState().sfmi.driverStatus - if (driverStatus.type !== T.FS.DriverStatusType.Enabled) return - const execPath: string = driverStatus.dokanUninstallExecPath || '' - logger.info('Invoking dokan uninstaller', execPath) - try { - await uninstallDokan?.(execPath) - } catch {} - useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() -} - -// Invoking the cached installer package has to happen from the topmost process -// or it won't be visible to the user. The service also does this to support command line -// operations. -const onInstallCachedDokan = async () => { - try { - await installCachedDokan?.() - useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() - } catch (e) { - errorToActionOrThrow(e) - } -} - -const initPlatformSpecific = () => { - useConfigState.subscribe((s, old) => { - if (s.appFocused === old.appFocused) return - useFSState.getState().dispatch.onChangedFocus(s.appFocused) - }) - - useFSState.setState(s => { - s.dispatch.dynamic.uploadFromDragAndDropDesktop = wrapErrors( - (parentPath: T.FS.Path, localPaths: string[]) => { - const {upload} = useFSState.getState().dispatch - const f = async () => { - if (isDarwin && darwinCopyToKBFSTempUploadFile) { - const dir = await T.RPCGen.SimpleFSSimpleFSMakeTempDirForUploadRpcPromise() - const lp = await Promise.all( - localPaths.map(async localPath => darwinCopyToKBFSTempUploadFile(dir, localPath)) - ) - lp.forEach(localPath => upload(parentPath, localPath)) - } else { - localPaths.forEach(localPath => upload(parentPath, localPath)) - } - } - ignorePromise(f()) - } - ) - - s.dispatch.dynamic.openLocalPathInSystemFileManagerDesktop = wrapErrors((localPath: string) => { - const f = async () => { - try { - if (getPathType) { - const pathType = await getPathType(localPath) - await _openPathInSystemFileManagerPromise(localPath, pathType === 'directory') - } - } catch (e) { - errorToActionOrThrow(e) - } - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.openPathInSystemFileManagerDesktop = wrapErrors((path: T.FS.Path) => { - const f = async () => { - const {sfmi, pathItems} = useFSState.getState() - return sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled && sfmi.directMountDir - ? _openPathInSystemFileManagerPromise( - _rebaseKbfsPathToMountLocation(path, sfmi.directMountDir), - ![T.FS.PathKind.InGroupTlf, T.FS.PathKind.InTeamTlf].includes(Constants.parsePath(path).kind) || - Constants.getPathItem(pathItems, path).type === T.FS.PathType.Folder - ).catch((e: unknown) => errorToActionOrThrow(e, path)) - : new Promise((resolve, reject) => { - if (sfmi.driverStatus.type !== T.FS.DriverStatusType.Enabled) { - // This usually indicates a developer error as - // openPathInSystemFileManager shouldn't be used when FUSE integration - // is not enabled. So just blackbar to encourage a log send. - reject(new Error('FUSE integration is not enabled')) - } else { - logger.warn('empty directMountDir') // if this happens it might be a race? - resolve() - } - }) - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.refreshDriverStatusDesktop = wrapErrors(() => { - const f = async () => { - let status = await T.RPCGen.installFuseStatusRpcPromise({ - bundleVersion: '', - }) - if (isWindows && status.installStatus !== T.RPCGen.InstallStatus.installed) { - const m = await T.RPCGen.kbfsMountGetCurrentMountDirRpcPromise() - status = await (windowsCheckMountFromOtherDokanInstall?.(m, status) ?? Promise.resolve(status)) - } - fuseStatusToActions(useFSState.getState().sfmi.driverStatus.type)(status) - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.refreshMountDirsDesktop = wrapErrors(() => { - const f = async () => { - const {sfmi, dispatch} = useFSState.getState() - const driverStatus = sfmi.driverStatus - if (driverStatus.type !== T.FS.DriverStatusType.Enabled) { - return - } - const directMountDir = await T.RPCGen.kbfsMountGetCurrentMountDirRpcPromise() - const preferredMountDirs = await T.RPCGen.kbfsMountGetPreferredMountDirsRpcPromise() - dispatch.setDirectMountDir(directMountDir) - dispatch.setPreferredMountDirs(preferredMountDirs || []) - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.setSfmiBannerDismissedDesktop = wrapErrors((dismissed: boolean) => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSSetSfmiBannerDismissedRpcPromise({dismissed}) - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.afterDriverEnabled = wrapErrors((isRetry: boolean) => { - const f = async () => { - useFSState.getState().dispatch.dynamic.setSfmiBannerDismissedDesktop?.(false) - if (isWindows) { - await onInstallCachedDokan() - } else { - await driverEnableFuse(isRetry) - } - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.afterDriverDisable = wrapErrors(() => { - const f = async () => { - useFSState.getState().dispatch.dynamic.setSfmiBannerDismissedDesktop?.(false) - if (isWindows) { - await uninstallDokanConfirm() - } else { - await uninstallKBFSConfirm() - } - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.afterDriverDisabling = wrapErrors(() => { - const f = async () => { - if (isWindows) { - await onUninstallDokan() - } else { - await uninstallKBFS() - } - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.openSecurityPreferencesDesktop = wrapErrors(() => { - const f = async () => { - await openURL?.('x-apple.systempreferences:com.apple.preference.security?General', {activate: true}) - } - ignorePromise(f()) - }) - - s.dispatch.dynamic.openFilesFromWidgetDesktop = wrapErrors((path: T.FS.Path) => { - useConfigState.getState().dispatch.showMain() - if (path) { - Constants.makeActionForOpenPathInFilesTab(path) - } else { - navigateAppend(Tabs.fsTab) - } - }) - - s.dispatch.dynamic.openAndUploadDesktop = wrapErrors( - (type: T.FS.OpenDialogType, parentPath: T.FS.Path) => { - const f = async () => { - const localPaths = await (selectFilesToUploadDialog?.(type, parentPath ?? undefined) ?? - Promise.resolve([])) - localPaths.forEach(localPath => useFSState.getState().dispatch.upload(parentPath, localPath)) - } - ignorePromise(f()) - } - ) - - if (!isLinux) { - s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged = wrapErrors(() => { - const {kbfsDaemonStatus, dispatch} = useFSState.getState() - if (kbfsDaemonStatus.rpcStatus === T.FS.KbfsDaemonRpcStatus.Connected) { - dispatch.dynamic.refreshDriverStatusDesktop?.() - } - dispatch.dynamic.refreshMountDirsDesktop?.() - }) - // force call as it could have happened already - s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged() - } - }) -} - -export default initPlatformSpecific diff --git a/shared/constants/fs/platform-specific.ios.tsx b/shared/constants/fs/platform-specific.ios.tsx deleted file mode 100644 index abfbc2d4b997..000000000000 --- a/shared/constants/fs/platform-specific.ios.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import nativeInit from './common.native' -export default nativeInit diff --git a/shared/constants/init/index.desktop.tsx b/shared/constants/init/index.desktop.tsx index 4ae00ac9ef64..5c4283c71170 100644 --- a/shared/constants/init/index.desktop.tsx +++ b/shared/constants/init/index.desktop.tsx @@ -14,16 +14,25 @@ import KB2 from '@/util/electron.desktop' import logger from '@/logger' import type {RPCError} from '@/util/errors' import {getEngine} from '@/engine' -import {isLinux, isWindows} from '@/constants/platform.desktop' +import {isLinux, isWindows, isDarwin, pathSep} from '@/constants/platform.desktop' import {kbfsNotification} from '@/constants/platform-specific/kbfs-notifications' import {skipAppFocusActions} from '@/local-debug.desktop' import NotifyPopup from '@/util/notify-popup' import {noKBFSFailReason} from '@/constants/config' import {initSharedSubscriptions, _onEngineIncoming} from './shared' import {wrapErrors} from '@/util/debug' +import * as Constants from '@/constants/fs' +import * as Tabs from '@/constants/tabs' +import * as Path from '@/util/path' +import {uint8ArrayToHex} from 'uint8array-extras' +import {navigateAppend} from '@/constants/router2' +import {errorToActionOrThrow} from '@/stores/fs' const {showMainWindow, activeChanged, requestWindowsStartService, ctlQuit, dumpNodeLogger} = KB2.functions const {quitApp, exitApp, setOpenAtLogin, copyToClipboard} = KB2.functions +const {openPathInFinder, openURL, getPathType, selectFilesToUploadDialog} = KB2.functions +const {darwinCopyToKBFSTempUploadFile, relaunchApp, uninstallKBFSDialog, uninstallDokanDialog} = KB2.functions +const {windowsCheckMountFromOtherDokanInstall, installCachedDokan, uninstallDokan} = KB2.functions const dumpLogs = async (reason?: string) => { await logger.dump() @@ -125,6 +134,123 @@ export const onEngineIncoming = (action: EngineGen.Actions) => { } } +// _openPathInSystemFileManagerPromise opens `openPath` in system file manager. +// If isFolder is true, it just opens it. Otherwise, it shows it in its parent +// folder. This function does not check if the file exists, or try to convert +// KBFS paths. Caller should take care of those. +const _openPathInSystemFileManagerPromise = async (openPath: string, isFolder: boolean): Promise => + openPathInFinder?.(openPath, isFolder) + +const escapeBackslash = isWindows + ? (pathElem: string): string => + pathElem + .replace(/‰/g, '‰2030') + .replace(/([<>:"/\\|?*])/g, (_, c: Uint8Array) => '‰' + uint8ArrayToHex(c)) + : (pathElem: string): string => pathElem + +const _rebaseKbfsPathToMountLocation = (kbfsPath: T.FS.Path, mountLocation: string) => + Path.join(mountLocation, T.FS.getPathElements(kbfsPath).slice(1).map(escapeBackslash).join(pathSep)) + +const fuseStatusToUninstallExecPath = isWindows + ? (status: T.RPCGen.FuseStatus) => { + const field = status.status.fields?.find(({key}) => key === 'uninstallString') + return field?.value + } + : () => undefined + +const fuseStatusToActions = + (previousStatusType: T.FS.DriverStatusType) => (status: T.RPCGen.FuseStatus | undefined) => { + if (!status) { + useFSState.getState().dispatch.setDriverStatus(Constants.defaultDriverStatus) + return + } + + if (status.kextStarted) { + useFSState.getState().dispatch.setDriverStatus({ + ...Constants.emptyDriverStatusEnabled, + dokanOutdated: status.installAction === T.RPCGen.InstallAction.upgrade, + dokanUninstallExecPath: fuseStatusToUninstallExecPath(status), + }) + } else { + useFSState.getState().dispatch.setDriverStatus(Constants.emptyDriverStatusDisabled) + } + + if (status.kextStarted && previousStatusType === T.FS.DriverStatusType.Disabled) { + useFSState + .getState() + .dispatch.dynamic.openPathInSystemFileManagerDesktop?.(T.FS.stringToPath('/keybase')) + } + } + +const fuseInstallResultIsKextPermissionError = (result: T.RPCGen.InstallResult): boolean => + result.componentResults?.findIndex( + c => c.name === 'fuse' && c.exitCode === Constants.ExitCodeFuseKextPermissionError + ) !== -1 + +const driverEnableFuse = async (isRetry: boolean) => { + const result = await T.RPCGen.installInstallFuseRpcPromise() + if (fuseInstallResultIsKextPermissionError(result)) { + useFSState.getState().dispatch.driverKextPermissionError() + if (!isRetry) { + navigateAppend('kextPermission') + } + } else { + await T.RPCGen.installInstallKBFSRpcPromise() // restarts kbfsfuse + await T.RPCGen.kbfsMountWaitForMountsRpcPromise() + useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() + } +} + +const uninstallKBFSConfirm = async () => { + const remove = await (uninstallKBFSDialog?.() ?? Promise.resolve(false)) + if (remove) { + useFSState.getState().dispatch.driverDisabling() + } +} + +const uninstallKBFS = async () => + T.RPCGen.installUninstallKBFSRpcPromise().then(() => { + // Restart since we had to uninstall KBFS and it's needed by the service (for chat) + relaunchApp?.() + exitApp?.(0) + }) + +const uninstallDokanConfirm = async () => { + const driverStatus = useFSState.getState().sfmi.driverStatus + if (driverStatus.type !== T.FS.DriverStatusType.Enabled) { + return + } + if (!driverStatus.dokanUninstallExecPath) { + await uninstallDokanDialog?.() + useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() + return + } + useFSState.getState().dispatch.driverDisabling() +} + +const onUninstallDokan = async () => { + const driverStatus = useFSState.getState().sfmi.driverStatus + if (driverStatus.type !== T.FS.DriverStatusType.Enabled) return + const execPath: string = driverStatus.dokanUninstallExecPath || '' + logger.info('Invoking dokan uninstaller', execPath) + try { + await uninstallDokan?.(execPath) + } catch {} + useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() +} + +// Invoking the cached installer package has to happen from the topmost process +// or it won't be visible to the user. The service also does this to support command line +// operations. +const onInstallCachedDokan = async () => { + try { + await installCachedDokan?.() + useFSState.getState().dispatch.dynamic.refreshDriverStatusDesktop?.() + } catch (e) { + errorToActionOrThrow(e) + } +} + export const initPlatformListener = () => { useConfigState.setState(s => { s.dispatch.dynamic.dumpLogsNative = dumpLogs @@ -139,6 +265,11 @@ export const initPlatformListener = () => { }) }) + useConfigState.subscribe((s, old) => { + if (s.appFocused === old.appFocused) return + useFSState.getState().dispatch.onChangedFocus(s.appFocused) + }) + useConfigState.subscribe((s, old) => { if (s.loggedIn !== old.loggedIn) { s.dispatch.osNetworkStatusChanged(navigator.onLine, 'notavailable', true) @@ -277,6 +408,174 @@ export const initPlatformListener = () => { } }) + useFSState.setState(s => { + s.dispatch.dynamic.uploadFromDragAndDropDesktop = wrapErrors( + (parentPath: T.FS.Path, localPaths: string[]) => { + const {upload} = useFSState.getState().dispatch + const f = async () => { + if (isDarwin && darwinCopyToKBFSTempUploadFile) { + const dir = await T.RPCGen.SimpleFSSimpleFSMakeTempDirForUploadRpcPromise() + const lp = await Promise.all( + localPaths.map(async localPath => darwinCopyToKBFSTempUploadFile(dir, localPath)) + ) + lp.forEach(localPath => upload(parentPath, localPath)) + } else { + localPaths.forEach(localPath => upload(parentPath, localPath)) + } + } + ignorePromise(f()) + } + ) + + s.dispatch.dynamic.openLocalPathInSystemFileManagerDesktop = wrapErrors((localPath: string) => { + const f = async () => { + try { + if (getPathType) { + const pathType = await getPathType(localPath) + await _openPathInSystemFileManagerPromise(localPath, pathType === 'directory') + } + } catch (e) { + errorToActionOrThrow(e) + } + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.openPathInSystemFileManagerDesktop = wrapErrors((path: T.FS.Path) => { + const f = async () => { + const {sfmi, pathItems} = useFSState.getState() + return sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled && sfmi.directMountDir + ? _openPathInSystemFileManagerPromise( + _rebaseKbfsPathToMountLocation(path, sfmi.directMountDir), + ![T.FS.PathKind.InGroupTlf, T.FS.PathKind.InTeamTlf].includes(Constants.parsePath(path).kind) || + Constants.getPathItem(pathItems, path).type === T.FS.PathType.Folder + ).catch((e: unknown) => errorToActionOrThrow(e, path)) + : new Promise((resolve, reject) => { + if (sfmi.driverStatus.type !== T.FS.DriverStatusType.Enabled) { + // This usually indicates a developer error as + // openPathInSystemFileManager shouldn't be used when FUSE integration + // is not enabled. So just blackbar to encourage a log send. + reject(new Error('FUSE integration is not enabled')) + } else { + logger.warn('empty directMountDir') // if this happens it might be a race? + resolve() + } + }) + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.refreshDriverStatusDesktop = wrapErrors(() => { + const f = async () => { + let status = await T.RPCGen.installFuseStatusRpcPromise({ + bundleVersion: '', + }) + if (isWindows && status.installStatus !== T.RPCGen.InstallStatus.installed) { + const m = await T.RPCGen.kbfsMountGetCurrentMountDirRpcPromise() + status = await (windowsCheckMountFromOtherDokanInstall?.(m, status) ?? Promise.resolve(status)) + } + fuseStatusToActions(useFSState.getState().sfmi.driverStatus.type)(status) + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.refreshMountDirsDesktop = wrapErrors(() => { + const f = async () => { + const {sfmi, dispatch} = useFSState.getState() + const driverStatus = sfmi.driverStatus + if (driverStatus.type !== T.FS.DriverStatusType.Enabled) { + return + } + const directMountDir = await T.RPCGen.kbfsMountGetCurrentMountDirRpcPromise() + const preferredMountDirs = await T.RPCGen.kbfsMountGetPreferredMountDirsRpcPromise() + dispatch.setDirectMountDir(directMountDir) + dispatch.setPreferredMountDirs(preferredMountDirs || []) + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.setSfmiBannerDismissedDesktop = wrapErrors((dismissed: boolean) => { + const f = async () => { + await T.RPCGen.SimpleFSSimpleFSSetSfmiBannerDismissedRpcPromise({dismissed}) + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.afterDriverEnabled = wrapErrors((isRetry: boolean) => { + const f = async () => { + useFSState.getState().dispatch.dynamic.setSfmiBannerDismissedDesktop?.(false) + if (isWindows) { + await onInstallCachedDokan() + } else { + await driverEnableFuse(isRetry) + } + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.afterDriverDisable = wrapErrors(() => { + const f = async () => { + useFSState.getState().dispatch.dynamic.setSfmiBannerDismissedDesktop?.(false) + if (isWindows) { + await uninstallDokanConfirm() + } else { + await uninstallKBFSConfirm() + } + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.afterDriverDisabling = wrapErrors(() => { + const f = async () => { + if (isWindows) { + await onUninstallDokan() + } else { + await uninstallKBFS() + } + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.openSecurityPreferencesDesktop = wrapErrors(() => { + const f = async () => { + await openURL?.('x-apple.systempreferences:com.apple.preference.security?General', {activate: true}) + } + ignorePromise(f()) + }) + + s.dispatch.dynamic.openFilesFromWidgetDesktop = wrapErrors((path: T.FS.Path) => { + useConfigState.getState().dispatch.showMain() + if (path) { + Constants.makeActionForOpenPathInFilesTab(path) + } else { + navigateAppend(Tabs.fsTab) + } + }) + + s.dispatch.dynamic.openAndUploadDesktop = wrapErrors( + (type: T.FS.OpenDialogType, parentPath: T.FS.Path) => { + const f = async () => { + const localPaths = await (selectFilesToUploadDialog?.(type, parentPath ?? undefined) ?? + Promise.resolve([])) + localPaths.forEach(localPath => useFSState.getState().dispatch.upload(parentPath, localPath)) + } + ignorePromise(f()) + } + ) + + if (!isLinux) { + s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged = wrapErrors(() => { + const {kbfsDaemonStatus, dispatch} = useFSState.getState() + if (kbfsDaemonStatus.rpcStatus === T.FS.KbfsDaemonRpcStatus.Connected) { + dispatch.dynamic.refreshDriverStatusDesktop?.() + } + dispatch.dynamic.refreshMountDirsDesktop?.() + }) + // force call as it could have happened already + s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged() + } + }) + initSharedSubscriptions() } diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index 89535ef1bbc2..8e2e2755b7bc 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -25,6 +25,7 @@ import {getTab, getVisiblePath, logState} from '@/constants/router2' import {launchImageLibraryAsync} from '@/util/expo-image-picker.native' import {setupAudioMode} from '@/util/audio.native' import { + androidAddCompleteDownload, androidOpenSettings, fsCacheDir, fsDownloadDir, @@ -38,7 +39,11 @@ import type {ImageInfo} from '@/util/expo-image-picker.native' import {noConversationIDKey} from '../types/chat2/common' import {getSelectedConversation} from '../chat2/common' import {getConvoState} from '@/stores/convostate' -import {requestLocationPermission, showShareActionSheet} from '../platform-specific/index.native' +import {requestLocationPermission, saveAttachmentToCameraRoll, showShareActionSheet} from '../platform-specific/index.native' +import * as FS from '@/stores/fs' +import * as Styles from '@/styles' + +const finishedRegularDownloadIDs = new Set() const loadStartupDetails = async () => { const [routeState, initialUrl, push] = await Promise.all([ @@ -500,6 +505,119 @@ export const initPlatformListener = () => { }) }) + useFSState.setState(s => { + s.dispatch.dynamic.pickAndUploadMobile = wrapErrors( + (type: T.FS.MobilePickType, parentPath: T.FS.Path) => { + const f = async () => { + try { + const result = await launchImageLibraryAsync(type, true, true) + if (result.canceled) return + result.assets.map(r => + useFSState.getState().dispatch.upload(parentPath, Styles.unnormalizePath(r.uri)) + ) + } catch (e) { + FS.errorToActionOrThrow(e) + } + } + ignorePromise(f()) + } + ) + + s.dispatch.dynamic.finishedDownloadWithIntentMobile = wrapErrors( + (downloadID: string, downloadIntent: T.FS.DownloadIntent, mimeType: string) => { + const f = async () => { + const {downloads, dispatch} = useFSState.getState() + const downloadState = downloads.state.get(downloadID) || FS.emptyDownloadState + if (downloadState === FS.emptyDownloadState) { + logger.warn('missing download', downloadID) + return + } + const dismissDownload = dispatch.dismissDownload + if (downloadState.error) { + dispatch.redbar(downloadState.error) + dismissDownload(downloadID) + return + } + const {localPath} = downloadState + try { + switch (downloadIntent) { + case T.FS.DownloadIntent.CameraRoll: + await saveAttachmentToCameraRoll(localPath, mimeType) + dismissDownload(downloadID) + return + case T.FS.DownloadIntent.Share: + await showShareActionSheet({filePath: localPath, mimeType}) + dismissDownload(downloadID) + return + case T.FS.DownloadIntent.None: + return + default: + return + } + } catch (err) { + FS.errorToActionOrThrow(err) + } + } + ignorePromise(f()) + } + ) + }) + + if (isAndroid) { + useFSState.setState(s => { + s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged = wrapErrors(() => { + const f = async () => { + await T.RPCGen.SimpleFSSimpleFSConfigureDownloadRpcPromise({ + // Android's cache dir is (when I tried) [app]/cache but Go side uses + // [app]/.cache by default, which can't be used for sharing to other apps. + cacheDirOverride: fsCacheDir, + downloadDirOverride: fsDownloadDir, + }) + } + ignorePromise(f()) + }) + // needs to be called, TODO could make this better + s.dispatch.dynamic.afterKbfsDaemonRpcStatusChanged() + + s.dispatch.dynamic.finishedRegularDownloadMobile = wrapErrors((downloadID: string, mimeType: string) => { + const f = async () => { + // This is fired from a hook and can happen more than once per downloadID. + // So just deduplicate them here. This is small enough and won't happen + // constantly, so don't worry about clearing them. + if (finishedRegularDownloadIDs.has(downloadID)) { + return + } + finishedRegularDownloadIDs.add(downloadID) + + const {downloads} = useFSState.getState() + + const downloadState = downloads.state.get(downloadID) || FS.emptyDownloadState + const downloadInfo = downloads.info.get(downloadID) || FS.emptyDownloadInfo + if (downloadState === FS.emptyDownloadState || downloadInfo === FS.emptyDownloadInfo) { + logger.warn('missing download', downloadID) + return + } + if (downloadState.error) { + return + } + try { + await androidAddCompleteDownload({ + description: `Keybase downloaded ${downloadInfo.filename}`, + mime: mimeType, + path: downloadState.localPath, + showNotification: true, + title: downloadInfo.filename, + }) + } catch { + logger.warn('Failed to addCompleteDownload') + } + // No need to dismiss here as the download wrapper does it for Android. + } + ignorePromise(f()) + }) + }) + } + initSharedSubscriptions() } diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 990e4d798d9c..07306304d8bc 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -37,7 +37,6 @@ import type * as UseTracker2StateType from '@/stores/tracker2' import type * as UseUnlockFoldersStateType from '@/stores/unlock-folders' import type * as UseUsersStateType from '@/stores/users' import {useWhatsNewState} from '@/stores/whats-new' -import initFSPlatformSpecific from '@/constants/fs/platform-specific' let _emitStartupOnLoadDaemonConnectedOnce = false let _devicesLoaded = false @@ -337,7 +336,6 @@ export const initSharedSubscriptions = () => { }) initTeamBuildingCallbacks() - initFSPlatformSpecific () } // This is to defer loading stores we don't need immediately. From 341eb9f182314f62881869b37681b7afbdec4512 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 12 Jan 2026 16:39:46 -0500 Subject: [PATCH 6/6] WIP --- shared/constants/fs/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/shared/constants/fs/index.tsx b/shared/constants/fs/index.tsx index 421000448081..790ed28645a9 100644 --- a/shared/constants/fs/index.tsx +++ b/shared/constants/fs/index.tsx @@ -1,9 +1,10 @@ import type * as React from 'react' -import * as Tabs from '@constants/tabs' -import * as T from '@constants/types' -import {isLinux, isMobile} from '@constants/platform' -import {settingsFsTab} from '@constants/settings' -import {navigateAppend} from '@constants/router2' +import * as Tabs from '@/constants/tabs' +import * as T from '@/constants/types' +import {isLinux, isMobile} from '@/constants/platform' +import {settingsFsTab} from '@/constants/settings' +import {navigateAppend} from '@/constants/router2' + export const makeActionForOpenPathInFilesTab = ( // TODO: remove the second arg when we are done with migrating to nav2 path: T.FS.Path