fix: recover blocked native folder picker

This commit is contained in:
lucaronin
2026-06-07 15:00:51 +02:00
parent 2556907f36
commit bf3caab862
2 changed files with 52 additions and 17 deletions

View File

@@ -9,6 +9,7 @@ vi.mock('../lib/appUpdater', () => ({
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE: 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.', 'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
isRestartRequiredAfterUpdate: vi.fn(() => false), isRestartRequiredAfterUpdate: vi.fn(() => false),
markRestartRequiredAfterUpdate: vi.fn(),
})) }))
const openMock = vi.fn() const openMock = vi.fn()
@@ -21,12 +22,16 @@ import { pickFolder } from './vault-dialog'
import { isTauri } from '../mock-tauri' import { isTauri } from '../mock-tauri'
import { import {
isRestartRequiredAfterUpdate, isRestartRequiredAfterUpdate,
markRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE, RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater' } from '../lib/appUpdater'
describe('pickFolder', () => { describe('pickFolder', () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
vi.clearAllMocks()
openMock.mockReset()
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
}) })
it('returns user input from prompt in browser mode', async () => { it('returns user input from prompt in browser mode', async () => {
@@ -70,6 +75,15 @@ describe('pickFolder', () => {
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
}) })
it('translates an NSOpenPanel panic into the restart-required folder picker error', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
openMock.mockRejectedValue('panic: unexpected NULL returned from +[NSOpenPanel openPanel]')
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
expect(markRestartRequiredAfterUpdate).toHaveBeenCalledOnce()
})
it('normalizes a native single-selection array to its first folder path', async () => { it('normalizes a native single-selection array to its first folder path', async () => {
vi.mocked(isTauri).mockReturnValue(true) vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false) vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
@@ -98,6 +112,7 @@ describe('pickFolder', () => {
const secondRequest = pickFolder('Open vault folder') const secondRequest = pickFolder('Open vault folder')
await expect(secondRequest).resolves.toBeNull() await expect(secondRequest).resolves.toBeNull()
await new Promise(resolve => setTimeout(resolve, 0))
expect(openMock).toHaveBeenCalledTimes(1) expect(openMock).toHaveBeenCalledTimes(1)
resolveOpen?.('/Users/test/restored-vault') resolveOpen?.('/Users/test/restored-vault')

View File

@@ -7,9 +7,12 @@
import { isTauri } from '../mock-tauri' import { isTauri } from '../mock-tauri'
import { import {
isRestartRequiredAfterUpdate, isRestartRequiredAfterUpdate,
markRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE, RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater' } from '../lib/appUpdater'
const NS_OPEN_PANEL_UNAVAILABLE_MARKER = 'unexpected NULL returned from +[NSOpenPanel openPanel]'
export class NativeFolderPickerBlockedError extends Error { export class NativeFolderPickerBlockedError extends Error {
constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) { constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) {
super(message) super(message)
@@ -23,6 +26,16 @@ export function isNativeFolderPickerBlockedError(
return error instanceof NativeFolderPickerBlockedError return error instanceof NativeFolderPickerBlockedError
} }
function errorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
return ''
}
function isUnavailableNativeFolderPicker(error: unknown): boolean {
return errorMessage(error).includes(NS_OPEN_PANEL_UNAVAILABLE_MARKER)
}
export function formatFolderPickerActionError( export function formatFolderPickerActionError(
action: string, action: string,
error: unknown, error: unknown,
@@ -31,12 +44,7 @@ export function formatFolderPickerActionError(
return error.message return error.message
} }
const message = const message = errorMessage(error)
typeof error === 'string'
? error
: error instanceof Error
? error.message
: ''
return message ? `${action}: ${message}` : action return message ? `${action}: ${message}` : action
} }
@@ -77,6 +85,28 @@ function normalizePickedFolderPath(selected: string | string[] | null): string |
let folderPickerRequestInFlight = false let folderPickerRequestInFlight = false
async function pickNativeFolder(title?: string): Promise<string | null> {
if (isRestartRequiredAfterUpdate()) {
throw new NativeFolderPickerBlockedError()
}
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
directory: true,
multiple: false,
title: title ?? 'Select folder',
})
return normalizePickedFolderPath(selected)
} catch (error) {
if (isUnavailableNativeFolderPicker(error)) {
markRestartRequiredAfterUpdate()
throw new NativeFolderPickerBlockedError()
}
throw error
}
}
/** /**
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser). * Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
* Returns the selected folder path, or null if the user cancelled. * Returns the selected folder path, or null if the user cancelled.
@@ -87,17 +117,7 @@ export async function pickFolder(title?: string): Promise<string | null> {
folderPickerRequestInFlight = true folderPickerRequestInFlight = true
try { try {
if (isTauri()) { if (isTauri()) {
if (isRestartRequiredAfterUpdate()) { return await pickNativeFolder(title)
throw new NativeFolderPickerBlockedError()
}
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
directory: true,
multiple: false,
title: title ?? 'Select folder',
})
return normalizePickedFolderPath(selected)
} }
return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:')) return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:'))
} finally { } finally {