fix: recover blocked native folder picker
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user