fix: guard folder picker after update install

This commit is contained in:
lucaronin
2026-04-23 18:16:43 +02:00
parent d98fa94e14
commit 11fc123492
10 changed files with 316 additions and 70 deletions

View File

@@ -8,11 +8,15 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
vi.mock('../utils/vault-dialog', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/vault-dialog')>()
return {
...actual,
pickFolder: vi.fn(),
}
})
import { pickFolder } from '../utils/vault-dialog'
import { NativeFolderPickerBlockedError, pickFolder } from '../utils/vault-dialog'
import { useGettingStartedClone } from './useGettingStartedClone'
describe('useGettingStartedClone', () => {
@@ -70,4 +74,21 @@ describe('useGettingStartedClone', () => {
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith('Could not download Getting Started vault. Check your connection and try again.')
})
it('surfaces the restart-required message when folder picking is blocked after update install', async () => {
vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError())
const onSuccess = vi.fn()
const onError = vi.fn()
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
await act(async () => {
await result.current()
})
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
)
})
})

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
import {
buildGettingStartedVaultPath,
formatGettingStartedCloneError,
@@ -22,7 +22,14 @@ export function useGettingStartedClone({
onSuccess,
}: UseGettingStartedCloneOptions) {
return useCallback(async () => {
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
let parentPath: string | null
try {
parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
} catch (err) {
onError(formatFolderPickerActionError('Could not choose a parent folder', err))
return
}
if (!parentPath) return
const targetPath = buildGettingStartedVaultPath(parentPath)

View File

@@ -30,11 +30,15 @@ vi.mock('../mock-tauri', () => ({
vi.mock('./useVaultSwitcher', () => ({}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
vi.mock('../utils/vault-dialog', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/vault-dialog')>()
return {
...actual,
pickFolder: vi.fn(),
}
})
import { pickFolder } from '../utils/vault-dialog'
import { NativeFolderPickerBlockedError, pickFolder } from '../utils/vault-dialog'
import { useOnboarding } from './useOnboarding'
function mockCommands(overrides: Record<string, MockOverride> = {}) {
@@ -314,6 +318,23 @@ describe('useOnboarding', () => {
})
})
it('shows the restart-required picker message instead of crashing the welcome flow', async () => {
mockCommands()
vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError())
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleOpenFolder()
})
expect(result.current.error).toBe(
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
)
expect(result.current.state.status).toBe('welcome')
})
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {
mockCommands()

View File

@@ -1,9 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
import { buildGettingStartedVaultPath, formatGettingStartedCloneError } from '../utils/gettingStartedVault'
import { pickFolder } from '../utils/vault-dialog'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
type OnboardingState =
| { status: 'loading' }
@@ -12,6 +12,21 @@ type OnboardingState =
| { status: 'ready'; vaultPath: string }
type CreatingAction = 'template' | 'empty' | null
type SetError = Dispatch<SetStateAction<string | null>>
type SetCreatingAction = Dispatch<SetStateAction<CreatingAction>>
interface ReadyVaultHandlers {
setState: Dispatch<SetStateAction<OnboardingState>>
setUserReadyVaultPath: Dispatch<SetStateAction<string | null>>
}
interface TemplateVaultCreationOptions {
handlers: ReadyVaultHandlers
setCreatingAction: SetCreatingAction
setError: SetError
setLastTemplatePath: Dispatch<SetStateAction<string | null>>
onTemplateVaultReady?: (vaultPath: string) => void
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
@@ -58,6 +73,107 @@ async function clearMissingActiveVault(missingPath: string): Promise<boolean> {
}
}
function markVaultReady(
handlers: ReadyVaultHandlers,
vaultPath: string,
) {
markDismissed()
handlers.setState({ status: 'ready', vaultPath })
handlers.setUserReadyVaultPath(vaultPath)
}
async function pickFolderWithOnboardingError(
title: string,
setError: SetError,
action: string,
): Promise<string | null> {
setError(null)
try {
return await pickFolder(title)
} catch (err) {
setError(formatFolderPickerActionError(action, err))
return null
}
}
function useTemplateVaultCreation(
options: TemplateVaultCreationOptions,
) {
return useCallback(async (targetPath: string) => {
options.setCreatingAction('template')
options.setError(null)
options.setLastTemplatePath(targetPath)
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markVaultReady(options.handlers, vaultPath)
options.onTemplateVaultReady?.(vaultPath)
} catch (err) {
options.setError(formatGettingStartedCloneError(err))
} finally {
options.setCreatingAction(null)
}
}, [options])
}
function useCreateVaultHandler(
createTemplateVault: (targetPath: string) => Promise<void>,
setError: SetError,
) {
return useCallback(async () => {
const parentPath = await pickFolderWithOnboardingError(
'Choose a parent folder for the Getting Started vault',
setError,
'Could not choose a parent folder',
)
if (!parentPath) return
await createTemplateVault(buildGettingStartedVaultPath(parentPath))
}, [createTemplateVault, setError])
}
function useCreateEmptyVaultHandler(
handlers: ReadyVaultHandlers,
setCreatingAction: SetCreatingAction,
setError: SetError,
) {
return useCallback(async () => {
const path = await pickFolderWithOnboardingError(
'Choose where to create your vault',
setError,
'Could not choose where to create your vault',
)
if (!path) return
try {
setCreatingAction('empty')
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markVaultReady(handlers, vaultPath)
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
setCreatingAction(null)
}
}, [handlers, setCreatingAction, setError])
}
function useOpenFolderHandler(
handlers: ReadyVaultHandlers,
setError: SetError,
) {
return useCallback(async () => {
const path = await pickFolderWithOnboardingError(
'Open vault folder',
setError,
'Failed to open folder',
)
if (!path) return
markVaultReady(handlers, path)
}, [handlers, setError])
}
export function useOnboarding(
initialVaultPath: string,
onTemplateVaultReady?: (vaultPath: string) => void,
@@ -68,12 +184,12 @@ export function useOnboarding(
const [error, setError] = useState<string | null>(null)
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
const [userReadyVaultPath, setUserReadyVaultPath] = useState<string | null>(null)
const readyVaultHandlers = { setState, setUserReadyVaultPath }
useEffect(() => {
let cancelled = false
if (!initialVaultResolved) {
setState({ status: 'loading' })
return () => { cancelled = true }
}
@@ -108,71 +224,38 @@ export function useOnboarding(
return () => { cancelled = true }
}, [initialVaultPath, initialVaultResolved])
const createTemplateVault = useCallback(async (targetPath: string) => {
setCreatingAction('template')
setError(null)
setLastTemplatePath(targetPath)
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
onTemplateVaultReady?.(vaultPath)
} catch (err) {
setError(formatGettingStartedCloneError(err))
} finally {
setCreatingAction(null)
}
}, [onTemplateVaultReady])
const createTemplateVault = useTemplateVaultCreation({
handlers: readyVaultHandlers,
setCreatingAction,
setError,
setLastTemplatePath,
onTemplateVaultReady,
})
const handleCreateVault = useCallback(async () => {
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
if (!parentPath) return
await createTemplateVault(buildGettingStartedVaultPath(parentPath))
}, [createTemplateVault])
const handleCreateVault = useCreateVaultHandler(createTemplateVault, setError)
const retryCreateVault = useCallback(async () => {
if (!lastTemplatePath) return
await createTemplateVault(lastTemplatePath)
}, [createTemplateVault, lastTemplatePath])
const handleCreateEmptyVault = useCallback(async () => {
try {
setError(null)
const path = await pickFolder('Choose where to create your vault')
if (!path) return
setCreatingAction('empty')
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
setCreatingAction(null)
}
}, [])
const handleCreateEmptyVault = useCreateEmptyVaultHandler(
readyVaultHandlers,
setCreatingAction,
setError,
)
const handleOpenFolder = useCallback(async () => {
try {
setError(null)
const path = await pickFolder('Open vault folder')
if (!path) return
markDismissed()
setState({ status: 'ready', vaultPath: path })
setUserReadyVaultPath(path)
} catch (err) {
setError(`Failed to open folder: ${err}`)
}
}, [])
const handleOpenFolder = useOpenFolderHandler(readyVaultHandlers, setError)
const handleDismiss = useCallback(() => {
markDismissed()
setState({ status: 'ready', vaultPath: initialVaultPath })
}, [initialVaultPath])
const resolvedState = initialVaultResolved ? state : { status: 'loading' as const }
return {
state,
state: resolvedState,
creating: creatingAction !== null,
creatingAction,
error,

View File

@@ -1,6 +1,10 @@
import { renderHook, act } from '@testing-library/react'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { useUpdater } from './useUpdater'
import {
clearRestartRequiredAfterUpdate,
isRestartRequiredAfterUpdate,
} from '../lib/appUpdater'
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
@@ -108,6 +112,7 @@ describe('useUpdater', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
clearRestartRequiredAfterUpdate()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
@@ -270,6 +275,7 @@ describe('useUpdater', () => {
version: '2026.4.16',
displayVersion: '2026.4.16',
})
expect(isRestartRequiredAfterUpdate()).toBe(true)
})
it('transitions to error when download fails', async () => {

View File

@@ -28,9 +28,15 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
vi.mock('../utils/vault-dialog', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/vault-dialog')>()
return {
...actual,
pickFolder: vi.fn(),
}
})
import { NativeFolderPickerBlockedError } from '../utils/vault-dialog'
type MockInvokeOverrides = {
checkVaultExists?: boolean | ((args: { path?: string }) => boolean)
@@ -294,6 +300,21 @@ describe('useVaultSwitcher', () => {
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
it('shows a clear toast when folder picking is blocked until restart', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError())
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleOpenLocalFolder()
})
expect(onToast).toHaveBeenCalledWith(
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
)
})
it('creates an empty vault and switches to it', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault')

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
import type { VaultOption } from '../components/StatusBar'
import { trackEvent } from '../lib/telemetry'
@@ -650,7 +650,14 @@ function useOpenLocalFolderAction(
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
const path = await pickFolder('Open vault folder')
let path: string | null
try {
path = await pickFolder('Open vault folder')
} catch (err) {
onToastRef.current(formatFolderPickerActionError('Could not open vault folder', err))
return
}
if (!path) return
const label = labelFromPath({ path })
@@ -664,10 +671,16 @@ function useCreateEmptyVaultAction(
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
let targetPath: string | null
try {
const targetPath = await pickFolder('Choose where to create your vault')
if (!targetPath) return
targetPath = await pickFolder('Choose where to create your vault')
} catch (err) {
onToastRef.current(formatFolderPickerActionError('Could not choose where to create your vault', err))
return
}
try {
if (!targetPath) return
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
const label = labelFromPath({ path: vaultPath })
addAndSwitch(vaultPath, label)

View File

@@ -13,6 +13,23 @@ export type AppUpdateDownloadEvent =
| { event: 'Progress'; data: { chunkLength: number } }
| { event: 'Finished' }
export const RESTART_REQUIRED_FOLDER_PICKER_MESSAGE =
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.'
let restartRequiredAfterUpdate = false
export function markRestartRequiredAfterUpdate(): void {
restartRequiredAfterUpdate = true
}
export function clearRestartRequiredAfterUpdate(): void {
restartRequiredAfterUpdate = false
}
export function isRestartRequiredAfterUpdate(): boolean {
return restartRequiredAfterUpdate
}
export async function checkForAppUpdate(
releaseChannel: string | null | undefined,
): Promise<AppUpdateMetadata | null> {
@@ -34,4 +51,5 @@ export async function downloadAndInstallAppUpdate(
expectedVersion,
onEvent: channel,
})
markRestartRequiredAfterUpdate()
}

View File

@@ -5,8 +5,18 @@ vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
}))
vi.mock('../lib/appUpdater', () => ({
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE:
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
isRestartRequiredAfterUpdate: vi.fn(() => false),
}))
import { pickFolder } from './vault-dialog'
import { isTauri } from '../mock-tauri'
import {
isRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater'
describe('pickFolder', () => {
beforeEach(() => {
@@ -37,4 +47,11 @@ describe('pickFolder', () => {
await pickFolder()
expect(window.prompt).toHaveBeenCalledWith('Enter folder path:')
})
it('blocks the native folder picker when a restart is required after update install', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(true)
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
})
})

View File

@@ -5,6 +5,41 @@
*/
import { isTauri } from '../mock-tauri'
import {
isRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater'
export class NativeFolderPickerBlockedError extends Error {
constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) {
super(message)
this.name = 'NativeFolderPickerBlockedError'
}
}
export function isNativeFolderPickerBlockedError(
error: unknown,
): error is NativeFolderPickerBlockedError {
return error instanceof NativeFolderPickerBlockedError
}
export function formatFolderPickerActionError(
action: string,
error: unknown,
): string {
if (isNativeFolderPickerBlockedError(error)) {
return error.message
}
const message =
typeof error === 'string'
? error
: error instanceof Error
? error.message
: ''
return message ? `${action}: ${message}` : action
}
/**
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
@@ -12,6 +47,10 @@ import { isTauri } from '../mock-tauri'
*/
export async function pickFolder(title?: string): Promise<string | null> {
if (isTauri()) {
if (isRestartRequiredAfterUpdate()) {
throw new NativeFolderPickerBlockedError()
}
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
directory: true,