ReviewOS

also looking at this

stacks/launchpad

Claude/app issues review e7f207

#16
Merged glennmichael123 wants to merge claude/app-issues-review-e7f207 into main
8 files +112 -35
docs/search.mdmodified+10-0
Changes to docs/search.md
@@ -71,6 +71,16 @@ A manifest is strictly validated and cannot run code, reach the network or read
7171and `{clipboard}` are filled in when a snippet is used. `function ea(){return new Xo}` stays literal. The
7272clipboard is read only if the snippet asks for it.
7373
74**Granting Automation.** The Automation pane has no way to add an app. Launchpad appears
75there only after macOS has asked, and each app it controls (Finder, System Events, Contacts,
76Calendar) is its own switch under the Launchpad row. So the first grant is the prompt itself:
77choose OK. If you chose Don't Allow, open System Settings > Privacy & Security > Automation and
78switch that app on under Launchpad. If Launchpad is not listed at all, nothing has asked yet;
79`tccutil reset AppleEvents com.stacksjs.launchpad` clears the record so the next attempt asks
80again. Emptying the Trash also depends on the Finder warning in Finder > Settings > Advanced:
81leave it on and answer the Finder dialog, or turn it off and let Launchpad's own confirmation
82stand alone.
83
7484**Builds.** Direct downloads are hardened and carry the Apple events entitlement, which macOS
7585requires before it will even ask for the Automation permission. A sandboxed Mac App Store
7686build does not have that entitlement, and the sandbox can also stop Spotlight file search, so
resources/functions/applescript.tsmodified+24-2
Changes to resources/functions/applescript.ts
@@ -18,9 +18,26 @@ export const cleanField = (value: string | undefined) => {
1818 return text === 'missing value' ? '' : text
1919}
2020
21export interface ScriptOutcome { ok: boolean, result?: string, error?: { code?: number, message?: string } }
21/**
22 * `error` is what a host that reports AppleScript error numbers returns. `reason` is what the Craft runtime returns
23 * when the bridge itself could not start, such as `NSAppleScript unavailable`: no Apple event is ever sent, so nothing
24 * is refused and nothing appears in the Automation pane. Craft's own `AppleScriptResult` declares neither.
25 */
26export interface ScriptOutcome { ok: boolean, result?: string, reason?: string, error?: { code?: number, message?: string } }
2227export interface ScriptFailure { message: string, permission?: 'accessibility' | 'automation' }
2328
29/** A failure the person can act on, carrying the privacy pane that settles it when there is one. */
30export class PermissionError extends Error {
31 constructor(message: string, readonly permission?: 'accessibility' | 'automation') { super(message) }
32}
33
34/**
35 * The Automation pane has no way to add an app: a row for Launchpad appears only once macOS has asked, and each app
36 * Launchpad controls is a separate switch underneath it. So the advice names both the prompt and the switch, instead of
37 * sending someone to a pane that may still be empty.
38 */
39const automationAdvice = (app: string) => `Launchpad needs your permission to control ${app}. Choose OK when macOS asks, or, if you answered Don't Allow before, switch ${app} on under Launchpad in System Settings > Privacy & Security > Automation.`
40
2441/**
2542 * What to tell someone when a script did not run. Newer Craft hosts report the AppleScript error number, which separates
2643 * a refused permission from a script that broke; older hosts report only that it failed, so the permission hint stays the
@@ -28,12 +45,17 @@ export interface ScriptFailure { message: string, permission?: 'accessibility' |
2845 */
2946export function describeScriptFailure(app: string, outcome: ScriptOutcome): ScriptFailure {
3047 const code = typeof outcome.error?.code === 'number' ? outcome.error.code : undefined
31 const automation: ScriptFailure = { message: `Allow Launchpad to control ${app} under System Settings > Privacy & Security > Automation, then try again.`, permission: 'automation' }
48 const automation: ScriptFailure = { message: automationAdvice(app), permission: 'automation' }
49 // A script that never ran was never refused, so sending someone to a privacy pane would only waste their time.
50 const reason = outcome.reason?.trim()
51 if (code === undefined && reason) return { message: `Launchpad could not run that script: ${reason}. The script never reached ${app}, so no permission is missing.` }
3252 if (code === undefined) return automation
3353 if (code === -1743 || code === -1744) return automation
3454 if (code === -25211 || code === -1719) return { message: 'Allow Launchpad under System Settings > Privacy & Security > Accessibility, then try again.', permission: 'accessibility' }
3555 if (code === -128) return { message: 'The request was cancelled.' }
3656 if (code === -600) return { message: `${app} is not running and could not be opened.` }
57 // A dialog of the app's own, such as the Finder warning before the Trash is emptied, holds the event open until it times out.
58 if (code === -1712) return { message: `${app} is waiting for an answer. Switch to ${app}, finish what it is asking, then try again.` }
3759 const detail = outcome.error?.message?.trim()
3860 return { message: detail ? `${app} could not do that: ${detail}` : `${app} could not do that (error ${code}).` }
3961}
resources/functions/native.tsmodified+11-7
Changes to resources/functions/native.ts
@@ -2,7 +2,7 @@ import { app, appleScript, clipboard, globalShortcuts, nativeWindow, nativeTray,
22import { createExecutor } from './execution'
33import { createApplicationIconLoader } from './application-icons'
44import { clipboardDecision } from './clipboard'
5import { describeScriptFailure } from './applescript'
5import { PermissionError, describeScriptFailure } from './applescript'
66import { contactOpenScript } from './contacts'
77import { eventOpenScript } from './calendar'
88import { createSources } from './sources'
@@ -72,10 +72,14 @@ export async function registerLauncher(show: () => void): Promise<() => void> {
7272export const behavior = { paste: false }
7373
7474const runScript = (source: string) => appleScript.execute(source)
75async function script(source: string | null, refusal: string) {
75/** `app` is the application the script talks to, so a refusal names the Automation switch that has to be on. */
76async function script(source: string | null, app: string, refusal: string) {
7677 if (!source) throw new Error(refusal)
7778 const outcome = await runScript(source)
78 if (!outcome.ok) throw new Error(`${refusal} ${describeScriptFailure('that app', outcome).message}`)
79 if (!outcome.ok) {
80 const { message, permission } = describeScriptFailure(app, outcome)
81 throw new PermissionError(`${refusal} ${message}`, permission)
82 }
7983}
8084
8185/** Type Command V into the app that regains focus when the palette hides. Needs the Accessibility permission. */
@@ -86,7 +90,7 @@ export async function pasteIntoFrontmost(): Promise<void> {
8690 }
8791 // The palette is hiding; wait for macOS to hand focus back before sending the keystroke.
8892 await new Promise(resolve => setTimeout(resolve, 180))
89 await script('tell application "System Events" to keystroke "v" using command down', 'Launchpad could not paste.')
93 await script('tell application "System Events" to keystroke "v" using command down', 'System Events', 'Launchpad could not paste.')
9094}
9195
9296export const execute = createExecutor({
@@ -98,9 +102,9 @@ export const execute = createExecutor({
98102 openUrl: url => shell.openExternal(url),
99103 openPath: path => shell.openPath(path),
100104 }),
101 focusWindow: token => script(windowFocusScript(token), 'Launchpad could not switch to that window.'),
102 openContact: id => script(contactOpenScript(id), 'Launchpad could not open that contact.'),
103 openEvent: uid => script(eventOpenScript(uid), 'Launchpad could not open that event.'),
105 focusWindow: token => script(windowFocusScript(token), 'System Events', 'Launchpad could not switch to that window.'),
106 openContact: id => script(contactOpenScript(id), 'Contacts', 'Launchpad could not open that contact.'),
107 openEvent: uid => script(eventOpenScript(uid), 'Calendar', 'Launchpad could not open that event.'),
104108 expandText: text => expandSnippet(text, { now: new Date(), uuid: () => crypto.randomUUID(), clipboard: () => clipboard.readText().then(value => value ?? '') }),
105109 pasteText: async () => { if (behavior.paste) await pasteIntoFrontmost() },
106110})
resources/functions/sources.tsmodified+2-4
Changes to resources/functions/sources.ts
@@ -1,4 +1,4 @@
1import { describeScriptFailure } from './applescript'
1import { PermissionError, describeScriptFailure } from './applescript'
22import type { ScriptOutcome } from './applescript'
33import { contactResults, contactSearchScript, parseContacts } from './contacts'
44import { eventResults, parseEvents, upcomingEventsScript } from './calendar'
@@ -6,9 +6,7 @@ import { parseWindows, windowListScript, windowResults } from './windows'
66import type { Result } from './search'
77
88/** A failure the person can act on, with the permission to visit when there is one. */
9export class SourceError extends Error {
10 constructor(message: string, readonly permission?: 'accessibility' | 'automation') { super(message) }
11}
9export class SourceError extends PermissionError {}
1210
1311export interface SourcePorts {
1412 appleScript: (source: string) => Promise<ScriptOutcome>
resources/functions/system-commands.tsmodified+21-12
Changes to resources/functions/system-commands.ts
@@ -1,8 +1,11 @@
1import { PermissionError, describeScriptFailure } from './applescript'
2import type { ScriptOutcome } from './applescript'
13import type { Result } from './search'
24
35/** Commands are fixed here. Nothing a user types, and nothing from a result's content, is ever run as code. */
46export type SystemPlan =
5 | { type: 'applescript', source: string }
7 /** `app` is the application the event is addressed to, so a refusal can name the switch that has to be on. */
8 | { type: 'applescript', source: string, app: string }
69 | { type: 'spawn', command: string, args: readonly string[] }
710 | { type: 'url', url: string }
811 | { type: 'path', path: string }
@@ -17,20 +20,20 @@ export interface SystemCommand {
1720 plan: SystemPlan
1821}
1922
20const script = (source: string): SystemPlan => ({ type: 'applescript', source })
23const script = (source: string, app: string): SystemPlan => ({ type: 'applescript', source, app })
2124const pane = (id: string): SystemPlan => ({ type: 'url', url: `x-apple.systempreferences:${id}` })
2225
2326const actions: readonly SystemCommand[] = [
2427 { id: 'sleep', title: 'Sleep', subtitle: 'System · Put this Mac to sleep', aliases: ['suspend', 'nap'], plan: { type: 'spawn', command: '/usr/bin/pmset', args: ['sleepnow'] } },
2528 { id: 'lock-screen', title: 'Lock Screen', subtitle: 'System · Turn off the display; a password is asked if Lock Screen settings require one', aliases: ['lock', 'lock mac', 'secure'], plan: { type: 'spawn', command: '/usr/bin/pmset', args: ['displaysleepnow'] } },
2629 { id: 'screen-saver', title: 'Start Screen Saver', subtitle: 'System · Start the screen saver now', aliases: ['screensaver'], plan: { type: 'path', path: '/System/Library/CoreServices/ScreenSaverEngine.app' } },
27 { id: 'restart', title: 'Restart', subtitle: 'System · Restart this Mac', aliases: ['reboot'], confirm: 'Restart this Mac now? Apps with unsaved work will ask to save.', plan: script('tell application "System Events" to restart') },
28 { id: 'shut-down', title: 'Shut Down', subtitle: 'System · Turn off this Mac', aliases: ['power off', 'turn off', 'shutdown'], confirm: 'Shut down this Mac now? Apps with unsaved work will ask to save.', plan: script('tell application "System Events" to shut down') },
29 { id: 'log-out', title: 'Log Out', subtitle: 'System · Sign out of this account', aliases: ['logout', 'sign out'], confirm: 'Log out of this account now? Apps with unsaved work will ask to save.', plan: script('tell application "System Events" to log out') },
30 { id: 'empty-trash', title: 'Empty Trash', subtitle: 'System · Permanently delete everything in the Trash', aliases: ['trash', 'clear trash', 'bin'], confirm: 'Permanently delete everything in the Trash? This cannot be undone.', plan: script('tell application "Finder" to empty trash') },
31 { id: 'eject-disks', title: 'Eject All Disks', subtitle: 'System · Eject every removable disk', aliases: ['eject', 'unmount'], plan: script('tell application "Finder" to eject (every disk whose ejectable is true)') },
32 { id: 'toggle-dark-mode', title: 'Toggle Dark Mode', subtitle: 'System · Switch macOS between light and dark', aliases: ['dark mode', 'light mode', 'appearance'], plan: script('tell application "System Events" to tell appearance preferences to set dark mode to not dark mode') },
33 { id: 'toggle-mute', title: 'Toggle Mute', subtitle: 'System · Mute or unmute sound output', aliases: ['mute', 'unmute', 'silence', 'volume off'], plan: script('set volume output muted (not (output muted of (get volume settings)))') },
30 { id: 'restart', title: 'Restart', subtitle: 'System · Restart this Mac', aliases: ['reboot'], confirm: 'Restart this Mac now? Apps with unsaved work will ask to save.', plan: script('tell application "System Events" to restart', 'System Events') },
31 { id: 'shut-down', title: 'Shut Down', subtitle: 'System · Turn off this Mac', aliases: ['power off', 'turn off', 'shutdown'], confirm: 'Shut down this Mac now? Apps with unsaved work will ask to save.', plan: script('tell application "System Events" to shut down', 'System Events') },
32 { id: 'log-out', title: 'Log Out', subtitle: 'System · Sign out of this account', aliases: ['logout', 'sign out'], confirm: 'Log out of this account now? Apps with unsaved work will ask to save.', plan: script('tell application "System Events" to log out', 'System Events') },
33 { id: 'empty-trash', title: 'Empty Trash', subtitle: 'System · Permanently delete everything in the Trash', aliases: ['trash', 'clear trash', 'bin'], confirm: 'Permanently delete everything in the Trash? This cannot be undone.', plan: script('tell application "Finder" to empty trash', 'Finder') },
34 { id: 'eject-disks', title: 'Eject All Disks', subtitle: 'System · Eject every removable disk', aliases: ['eject', 'unmount'], plan: script('tell application "Finder" to eject (every disk whose ejectable is true)', 'Finder') },
35 { id: 'toggle-dark-mode', title: 'Toggle Dark Mode', subtitle: 'System · Switch macOS between light and dark', aliases: ['dark mode', 'light mode', 'appearance'], plan: script('tell application "System Events" to tell appearance preferences to set dark mode to not dark mode', 'System Events') },
36 { id: 'toggle-mute', title: 'Toggle Mute', subtitle: 'System · Mute or unmute sound output', aliases: ['mute', 'unmute', 'silence', 'volume off'], plan: script('set volume output muted (not (output muted of (get volume settings)))', 'macOS') },
3437]
3538
3639const panes: readonly [id: string, title: string, aliases: readonly string[], pane: string][] = [
@@ -89,18 +92,24 @@ export function systemCommandResults(): readonly Result[] {
8992}
9093
9194export interface SystemPorts {
92 appleScript: (source: string) => Promise<{ ok: boolean }>
95 appleScript: (source: string) => Promise<ScriptOutcome>
9396 spawn: (command: string, args: readonly string[]) => Promise<void>
9497 openUrl: (url: string) => Promise<void>
9598 openPath: (path: string) => Promise<void>
9699}
97100
98/** Run one fixed plan. A refused script explains where the permission lives instead of failing silently. */
101/**
102 * Run one fixed plan. A script that does not run is explained by its own error, so a cancelled confirmation, an app
103 * that is waiting on a dialog and a refused permission each say what they are instead of all claiming Automation.
104 */
99105export async function runSystemPlan(command: SystemCommand, ports: SystemPorts): Promise<void> {
100106 const { plan } = command
101107 if (plan.type === 'applescript') {
102108 const outcome = await ports.appleScript(plan.source)
103 if (!outcome.ok) throw new Error(`macOS did not run "${command.title}". Allow Launchpad under System Settings > Privacy & Security > Automation, then try again.`)
109 if (!outcome.ok) {
110 const { message, permission } = describeScriptFailure(plan.app, outcome)
111 throw new PermissionError(`Launchpad could not run "${command.title}". ${message}`, permission)
112 }
104113 }
105114 else if (plan.type === 'spawn') await ports.spawn(plan.command, plan.args)
106115 else if (plan.type === 'url') await ports.openUrl(plan.url)
resources/views/launcher.stxmodified+13-8
Changes to resources/views/launcher.stx
@@ -71,6 +71,8 @@ const resultScrollTop = state(0)
7171const resultViewportHeight = state(450)
7272const loading = state(true)
7373const error = state('')
74/** The privacy pane that would settle the current error, when the failure names one. */
75const errorPermission = state('')
7476const status = state('Ready when you are')
7577const editor = state(false)
7678const aliasApplication = state<Result | null>(null)
@@ -309,10 +311,11 @@ async function reload() {
309311
310312function focusSearch() { batch(() => { query.set(''); category.set('all'); selected.set(0) }); void nextTick(() => search.current?.focus()) }
311313function chooseCategory(value) { batch(() => { query.set(''); category.set(value); selected.set(0) }); void nextTick(() => search.current?.focus()) }
312function fail(reason) { error.set(reason.message || String(reason)) }
314function clearError() { batch(() => { error.set(''); errorPermission.set('') }) }
315function fail(reason) { batch(() => { error.set(reason.message || String(reason)); errorPermission.set(reason.permission || '') }) }
313316function openSettings(checkUpdates = false) {
314317 settings.set(true)
315 error.set('')
318 clearError()
316319 if (settingsSection() === 'general') {
317320 void refreshLogin()
318321 if (checkUpdates === true) void updateLaunchpad()
@@ -544,7 +547,7 @@ effect(() => {
544547onDestroy(() => githubRequest?.abort())
545548async function connectAccount() {
546549 saving.set(true)
547 try { githubViewer.set(await connectGitHub(tokenInput())); tokenInput.set(''); error.set(''); void refreshGitHub() }
550 try { githubViewer.set(await connectGitHub(tokenInput())); tokenInput.set(''); clearError(); void refreshGitHub() }
548551 catch (reason) { fail(reason) }
549552 finally { saving.set(false) }
550553}
@@ -713,7 +716,7 @@ async function run(item = active()) {
713716 const command = systemCommand(item.content)
714717 if (command?.confirm && !await stxConfirm(command.confirm)) return
715718 }
716 error.set('')
719 clearError()
717720 saving.set(true)
718721 try {
719722 await execute(item, query(), duration => recordExecution(item, duration))
@@ -746,7 +749,7 @@ function newCommand() {
746749 content.set('')
747750 alias.set('')
748751 editor.set(true)
749 error.set('')
752 clearError()
750753 void nextTick(() => commandTitle.current?.focus())
751754}
752755function editCommand() {
@@ -755,7 +758,7 @@ function editCommand() {
755758 aliasApplication.set(item)
756759 applicationAliases.set(records()[item.key]?.aliases || '')
757760 editor.set(true)
758 error.set('')
761 clearError()
759762 void nextTick(() => aliasInput.current?.focus())
760763 return
761764 }
@@ -767,7 +770,7 @@ function editCommand() {
767770 content.set(item.content)
768771 alias.set(item.alias || '')
769772 editor.set(true)
770 error.set('')
773 clearError()
771774 void nextTick(() => commandTitle.current?.focus())
772775}
773776async function saveCommand() {
@@ -903,7 +906,7 @@ onDestroy(() => { cleanup(); cleanupTray() })
903906 <button type="button" class="new" @click="newCommand()" title="New command (Command N)" aria-label="New command">+</button>
904907 <button type="button" class="tab" @click="openSettings()" title="Settings (Command ,)" aria-label="Settings"><span class="i-f7-gear-alt" aria-hidden="true"></span></button>
905908 </nav>
906 <div :if="error()" class="error" role="alert">{{ error() }}</div>
909 <div :if="error()" class="error error-banner" role="alert"><span>{{ error() }}</span><button :if="errorPermission()" type="button" class="error-action" @click="openPrivacySettings(errorPermission())">{{ errorPermission() === 'automation' ? 'Open Automation settings' : 'Open Accessibility settings' }}</button></div>
907910 <div :if="category() === 'github'" class="flex gap-2 items-center px-5 github-tools">
908911 <select x-model="githubView" aria-label="GitHub view"><option value="repositories">Repositories</option><option value="issues">Issues</option><option value="pulls">Pull requests</option><option value="assigned">Assigned to me</option><option value="reviews">Review requests</option><option value="notifications">Notifications</option></select>
909912 <input x-model="githubScope" @change="saveScope()" placeholder="owner/repository" aria-label="GitHub repository scope">
@@ -1169,6 +1172,8 @@ kbd { font: 10px -apple-system, system-ui; color: var(--muted); padding: 2px 4px
11691172.empty-detail { color: var(--muted); font-size: 12px; line-height: 1.6; }
11701173.text-action { color: var(--accent); font-size: 11px; }
11711174.error { margin: 6px 20px; padding: 8px 10px; background: var(--error-bg); color: var(--error-ink); border-radius: 5px; font-size: 12px; }
1175.error-banner { display: flex; gap: 10px; align-items: flex-start; justify-content: space-between; }
1176.error-action { flex: none; font-size: 11px; color: var(--error-ink); text-decoration: underline; white-space: nowrap; }
11721177.editor { position: absolute; inset: 0; background: var(--surface); z-index: 10; display: flex; flex-direction: column; min-height: 0; }
11731178.editor h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.5px; }
11741179.editor label { display: block; font-size: 12px; margin: 8px 0 4px; color: var(--ink); }
tests/applescript.test.tsmodified+10-1
Changes to tests/applescript.test.ts
@@ -78,16 +78,25 @@ test('events parse local ISO times, sort by start and describe their day', () =>
7878
7979test('a failed script is explained by its error number, and older hosts keep the permission hint', () => {
8080 const failure = (code?: number, message?: string) => describeScriptFailure('Contacts', { ok: false, error: code === undefined && message === undefined ? undefined : { code, message } })
81 expect(failure()).toMatchObject({ permission: 'automation', message: expect.stringContaining('control Contacts') })
81 // The Automation pane cannot add an app, so the advice has to name the prompt as well as the switch.
82 expect(failure().message).toBe("Launchpad needs your permission to control Contacts. Choose OK when macOS asks, or, if you answered Don't Allow before, switch Contacts on under Launchpad in System Settings > Privacy & Security > Automation.")
83 expect(failure()).toMatchObject({ permission: 'automation' })
8284 expect(failure(-1743)).toMatchObject({ permission: 'automation' })
8385 expect(failure(-1744)).toMatchObject({ permission: 'automation' })
8486 expect(failure(-25211)).toMatchObject({ permission: 'accessibility', message: expect.stringContaining('Accessibility') })
8587 expect(failure(-1719)).toMatchObject({ permission: 'accessibility' })
8688 expect(failure(-128)).toEqual({ message: 'The request was cancelled.' })
8789 expect(failure(-600)).toEqual({ message: 'Contacts is not running and could not be opened.' })
90 expect(failure(-1712)).toEqual({ message: 'Contacts is waiting for an answer. Switch to Contacts, finish what it is asking, then try again.' })
8891 expect(failure(-1728, "Can't get window 3.")).toEqual({ message: "Contacts could not do that: Can't get window 3." })
8992 expect(failure(-1728)).toEqual({ message: 'Contacts could not do that (error -1728).' })
9093 expect(failure(undefined, 'only a message')).toMatchObject({ permission: 'automation' })
94 // Craft reports a bridge that could not start as `reason`, with no error number. Nothing was refused, so nothing is granted.
95 const unavailable = describeScriptFailure('Finder', { ok: false, reason: 'NSAppleScript unavailable' })
96 expect(unavailable.message).toBe('Launchpad could not run that script: NSAppleScript unavailable. The script never reached Finder, so no permission is missing.')
97 expect(unavailable.permission).toBeUndefined()
98 // An error number still wins, because it describes what the event itself did.
99 expect(describeScriptFailure('Finder', { ok: false, reason: 'whatever', error: { code: -1743 } })).toMatchObject({ permission: 'automation' })
91100})
92101
93102// Compiling catches syntax errors without running anything or reading any data. It does not validate app terms:
tests/snippets.test.tsmodified+21-1
Changes to tests/snippets.test.ts
@@ -45,5 +45,25 @@ test('a system plan runs through exactly one port and explains a refused script'
4545 await runSystemPlan(systemCommand('screen-saver')!, ports)
4646 await runSystemPlan(systemCommand('toggle-mute')!, ports)
4747 expect(calls).toEqual(['spawn:/usr/bin/pmset sleepnow', 'url:x-apple.systempreferences:com.apple.Network-Settings.extension', 'path:/System/Library/CoreServices/ScreenSaverEngine.app', expect.stringContaining('script:set volume output muted')])
48 await expect(runSystemPlan(systemCommand('empty-trash')!, ports)).rejects.toThrow('Automation')
48 // A host that reports no error number keeps the permission hint, and it names Finder, which is the switch to turn on.
49 const refused = await runSystemPlan(systemCommand('empty-trash')!, ports).catch(reason => reason)
50 expect(refused.message).toBe(`Launchpad could not run "Empty Trash". Launchpad needs your permission to control Finder. Choose OK when macOS asks, or, if you answered Don't Allow before, switch Finder on under Launchpad in System Settings > Privacy & Security > Automation.`)
51 expect(refused.permission).toBe('automation')
52})
53
54test('a system command that fails for another reason does not blame Automation', async () => {
55 const refuse = (error: { code?: number, message?: string }) => ({
56 appleScript: async () => ({ ok: false, error }),
57 spawn: async () => {},
58 openUrl: async () => {},
59 openPath: async () => {},
60 })
61 const run = (id: string, error: { code?: number, message?: string }) => runSystemPlan(systemCommand(id)!, refuse(error)).catch(reason => reason)
62 const cancelled = await run('empty-trash', { code: -128 })
63 expect(cancelled.message).toBe(`Launchpad could not run "Empty Trash". The request was cancelled.`)
64 expect(cancelled.permission).toBeUndefined()
65 // Finder's own warning before it empties the Trash holds the event open until it times out.
66 expect((await run('empty-trash', { code: -1712 })).message).toContain('Finder is waiting for an answer')
67 expect((await run('restart', { code: -1743 })).message).toContain('control System Events')
68 expect((await run('eject-disks', { code: -1728, message: 'No disks.' })).message).toBe(`Launchpad could not run "Eject All Disks". Finder could not do that: No disks.`)
4969})