From bf3caab862b403ee984177d89ac8f01a3c1f2017 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 7 Jun 2026 15:00:51 +0200 Subject: [PATCH] fix: recover blocked native folder picker --- src/utils/vault-dialog.test.ts | 15 ++++++++++ src/utils/vault-dialog.ts | 54 +++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/utils/vault-dialog.test.ts b/src/utils/vault-dialog.test.ts index 5336776f..430cbca3 100644 --- a/src/utils/vault-dialog.test.ts +++ b/src/utils/vault-dialog.test.ts @@ -9,6 +9,7 @@ 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), + markRestartRequiredAfterUpdate: vi.fn(), })) const openMock = vi.fn() @@ -21,12 +22,16 @@ import { pickFolder } from './vault-dialog' import { isTauri } from '../mock-tauri' import { isRestartRequiredAfterUpdate, + markRestartRequiredAfterUpdate, RESTART_REQUIRED_FOLDER_PICKER_MESSAGE, } from '../lib/appUpdater' describe('pickFolder', () => { beforeEach(() => { vi.restoreAllMocks() + vi.clearAllMocks() + openMock.mockReset() + vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false) }) 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) }) + 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 () => { vi.mocked(isTauri).mockReturnValue(true) vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false) @@ -98,6 +112,7 @@ describe('pickFolder', () => { const secondRequest = pickFolder('Open vault folder') await expect(secondRequest).resolves.toBeNull() + await new Promise(resolve => setTimeout(resolve, 0)) expect(openMock).toHaveBeenCalledTimes(1) resolveOpen?.('/Users/test/restored-vault') diff --git a/src/utils/vault-dialog.ts b/src/utils/vault-dialog.ts index a431191b..98c38346 100644 --- a/src/utils/vault-dialog.ts +++ b/src/utils/vault-dialog.ts @@ -7,9 +7,12 @@ import { isTauri } from '../mock-tauri' import { isRestartRequiredAfterUpdate, + markRestartRequiredAfterUpdate, RESTART_REQUIRED_FOLDER_PICKER_MESSAGE, } from '../lib/appUpdater' +const NS_OPEN_PANEL_UNAVAILABLE_MARKER = 'unexpected NULL returned from +[NSOpenPanel openPanel]' + export class NativeFolderPickerBlockedError extends Error { constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) { super(message) @@ -23,6 +26,16 @@ export function isNativeFolderPickerBlockedError( 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( action: string, error: unknown, @@ -31,12 +44,7 @@ export function formatFolderPickerActionError( return error.message } - const message = - typeof error === 'string' - ? error - : error instanceof Error - ? error.message - : '' + const message = errorMessage(error) return message ? `${action}: ${message}` : action } @@ -77,6 +85,28 @@ function normalizePickedFolderPath(selected: string | string[] | null): string | let folderPickerRequestInFlight = false +async function pickNativeFolder(title?: string): Promise { + 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). * Returns the selected folder path, or null if the user cancelled. @@ -87,17 +117,7 @@ export async function pickFolder(title?: string): Promise { folderPickerRequestInFlight = true try { if (isTauri()) { - if (isRestartRequiredAfterUpdate()) { - 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 await pickNativeFolder(title) } return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:')) } finally {