also looking at this
Claude/app issues review e7f207
#16
8 files
+112
-35
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -71,6 +71,16 @@ A manifest is strictly validated and cannot run code, reach the network or read | ||
| 71 | 71 | and `{clipboard}` are filled in when a snippet is used. `function ea(){return new Xo}` stays literal. The |
| 72 | 72 | clipboard is read only if the snippet asks for it. |
| 73 | 73 | |
| 74 | **Granting Automation.** The Automation pane has no way to add an app. Launchpad appears | |
| 75 | there only after macOS has asked, and each app it controls (Finder, System Events, Contacts, | |
| 76 | Calendar) is its own switch under the Launchpad row. So the first grant is the prompt itself: | |
| 77 | choose OK. If you chose Don't Allow, open System Settings > Privacy & Security > Automation and | |
| 78 | switch 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 | |
| 80 | again. Emptying the Trash also depends on the Finder warning in Finder > Settings > Advanced: | |
| 81 | leave it on and answer the Finder dialog, or turn it off and let Launchpad's own confirmation | |
| 82 | stand alone. | |
| 83 | ||
| 74 | 84 | **Builds.** Direct downloads are hardened and carry the Apple events entitlement, which macOS |
| 75 | 85 | requires before it will even ask for the Automation permission. A sandboxed Mac App Store |
| 76 | 86 | build does not have that entitlement, and the sandbox can also stop Spotlight file search, so |
| @@ -21,6 +21,18 @@ export const cleanField = (value: string | undefined) => { | ||
| 21 | 21 | export interface ScriptOutcome { ok: boolean, result?: string, error?: { code?: number, message?: string } } |
| 22 | 22 | export interface ScriptFailure { message: string, permission?: 'accessibility' | 'automation' } |
| 23 | 23 | |
| 24 | /** A failure the person can act on, carrying the privacy pane that settles it when there is one. */ | |
| 25 | export class PermissionError extends Error { | |
| 26 | constructor(message: string, readonly permission?: 'accessibility' | 'automation') { super(message) } | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * The Automation pane has no way to add an app: a row for Launchpad appears only once macOS has asked, and each app | |
| 31 | * Launchpad controls is a separate switch underneath it. So the advice names both the prompt and the switch, instead of | |
| 32 | * sending someone to a pane that may still be empty. | |
| 33 | */ | |
| 34 | const 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.` | |
| 35 | ||
| 24 | 36 | /** |
| 25 | 37 | * What to tell someone when a script did not run. Newer Craft hosts report the AppleScript error number, which separates |
| 26 | 38 | * a refused permission from a script that broke; older hosts report only that it failed, so the permission hint stays the |
| @@ -28,12 +40,14 @@ export interface ScriptFailure { message: string, permission?: 'accessibility' | | ||
| 28 | 40 | */ |
| 29 | 41 | export function describeScriptFailure(app: string, outcome: ScriptOutcome): ScriptFailure { |
| 30 | 42 | 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' } | |
| 43 | const automation: ScriptFailure = { message: automationAdvice(app), permission: 'automation' } | |
| 32 | 44 | if (code === undefined) return automation |
| 33 | 45 | if (code === -1743 || code === -1744) return automation |
| 34 | 46 | if (code === -25211 || code === -1719) return { message: 'Allow Launchpad under System Settings > Privacy & Security > Accessibility, then try again.', permission: 'accessibility' } |
| 35 | 47 | if (code === -128) return { message: 'The request was cancelled.' } |
| 36 | 48 | if (code === -600) return { message: `${app} is not running and could not be opened.` } |
| 49 | // 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. | |
| 50 | if (code === -1712) return { message: `${app} is waiting for an answer. Switch to ${app}, finish what it is asking, then try again.` } | |
| 37 | 51 | const detail = outcome.error?.message?.trim() |
| 38 | 52 | return { message: detail ? `${app} could not do that: ${detail}` : `${app} could not do that (error ${code}).` } |
| 39 | 53 | } |
| @@ -2,7 +2,7 @@ import { app, appleScript, clipboard, globalShortcuts, nativeWindow, nativeTray, | ||
| 2 | 2 | import { createExecutor } from './execution' |
| 3 | 3 | import { createApplicationIconLoader } from './application-icons' |
| 4 | 4 | import { clipboardDecision } from './clipboard' |
| 5 | import { describeScriptFailure } from './applescript' | |
| 5 | import { PermissionError, describeScriptFailure } from './applescript' | |
| 6 | 6 | import { contactOpenScript } from './contacts' |
| 7 | 7 | import { eventOpenScript } from './calendar' |
| 8 | 8 | import { createSources } from './sources' |
| @@ -72,10 +72,14 @@ export async function registerLauncher(show: () => void): Promise<() => void> { | ||
| 72 | 72 | export const behavior = { paste: false } |
| 73 | 73 | |
| 74 | 74 | const runScript = (source: string) => appleScript.execute(source) |
| 75 | async 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. */ | |
| 76 | async function script(source: string | null, app: string, refusal: string) { | |
| 76 | 77 | if (!source) throw new Error(refusal) |
| 77 | 78 | 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 | } | |
| 79 | 83 | } |
| 80 | 84 | |
| 81 | 85 | /** 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> { | ||
| 86 | 90 | } |
| 87 | 91 | // The palette is hiding; wait for macOS to hand focus back before sending the keystroke. |
| 88 | 92 | 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.') | |
| 90 | 94 | } |
| 91 | 95 | |
| 92 | 96 | export const execute = createExecutor({ |
| @@ -98,9 +102,9 @@ export const execute = createExecutor({ | ||
| 98 | 102 | openUrl: url => shell.openExternal(url), |
| 99 | 103 | openPath: path => shell.openPath(path), |
| 100 | 104 | }), |
| 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.'), | |
| 104 | 108 | expandText: text => expandSnippet(text, { now: new Date(), uuid: () => crypto.randomUUID(), clipboard: () => clipboard.readText().then(value => value ?? '') }), |
| 105 | 109 | pasteText: async () => { if (behavior.paste) await pasteIntoFrontmost() }, |
| 106 | 110 | }) |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { describeScriptFailure } from './applescript' | |
| 1 | import { PermissionError, describeScriptFailure } from './applescript' | |
| 2 | 2 | import type { ScriptOutcome } from './applescript' |
| 3 | 3 | import { contactResults, contactSearchScript, parseContacts } from './contacts' |
| 4 | 4 | import { eventResults, parseEvents, upcomingEventsScript } from './calendar' |
| @@ -6,9 +6,7 @@ import { parseWindows, windowListScript, windowResults } from './windows' | ||
| 6 | 6 | import type { Result } from './search' |
| 7 | 7 | |
| 8 | 8 | /** A failure the person can act on, with the permission to visit when there is one. */ |
| 9 | export class SourceError extends Error { | |
| 10 | constructor(message: string, readonly permission?: 'accessibility' | 'automation') { super(message) } | |
| 11 | } | |
| 9 | export class SourceError extends PermissionError {} | |
| 12 | 10 | |
| 13 | 11 | export interface SourcePorts { |
| 14 | 12 | appleScript: (source: string) => Promise<ScriptOutcome> |
| @@ -1,8 +1,11 @@ | ||
| 1 | import { PermissionError, describeScriptFailure } from './applescript' | |
| 2 | import type { ScriptOutcome } from './applescript' | |
| 1 | 3 | import type { Result } from './search' |
| 2 | 4 | |
| 3 | 5 | /** Commands are fixed here. Nothing a user types, and nothing from a result's content, is ever run as code. */ |
| 4 | 6 | export 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 } | |
| 6 | 9 | | { type: 'spawn', command: string, args: readonly string[] } |
| 7 | 10 | | { type: 'url', url: string } |
| 8 | 11 | | { type: 'path', path: string } |
| @@ -17,20 +20,20 @@ export interface SystemCommand { | ||
| 17 | 20 | plan: SystemPlan |
| 18 | 21 | } |
| 19 | 22 | |
| 20 | const script = (source: string): SystemPlan => ({ type: 'applescript', source }) | |
| 23 | const script = (source: string, app: string): SystemPlan => ({ type: 'applescript', source, app }) | |
| 21 | 24 | const pane = (id: string): SystemPlan => ({ type: 'url', url: `x-apple.systempreferences:${id}` }) |
| 22 | 25 | |
| 23 | 26 | const actions: readonly SystemCommand[] = [ |
| 24 | 27 | { id: 'sleep', title: 'Sleep', subtitle: 'System · Put this Mac to sleep', aliases: ['suspend', 'nap'], plan: { type: 'spawn', command: '/usr/bin/pmset', args: ['sleepnow'] } }, |
| 25 | 28 | { 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'] } }, |
| 26 | 29 | { 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') }, | |
| 34 | 37 | ] |
| 35 | 38 | |
| 36 | 39 | const panes: readonly [id: string, title: string, aliases: readonly string[], pane: string][] = [ |
| @@ -89,18 +92,24 @@ export function systemCommandResults(): readonly Result[] { | ||
| 89 | 92 | } |
| 90 | 93 | |
| 91 | 94 | export interface SystemPorts { |
| 92 | appleScript: (source: string) => Promise<{ ok: boolean }> | |
| 95 | appleScript: (source: string) => Promise<ScriptOutcome> | |
| 93 | 96 | spawn: (command: string, args: readonly string[]) => Promise<void> |
| 94 | 97 | openUrl: (url: string) => Promise<void> |
| 95 | 98 | openPath: (path: string) => Promise<void> |
| 96 | 99 | } |
| 97 | 100 | |
| 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 | */ | |
| 99 | 105 | export async function runSystemPlan(command: SystemCommand, ports: SystemPorts): Promise<void> { |
| 100 | 106 | const { plan } = command |
| 101 | 107 | if (plan.type === 'applescript') { |
| 102 | 108 | 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 | } | |
| 104 | 113 | } |
| 105 | 114 | else if (plan.type === 'spawn') await ports.spawn(plan.command, plan.args) |
| 106 | 115 | else if (plan.type === 'url') await ports.openUrl(plan.url) |
| @@ -71,6 +71,8 @@ const resultScrollTop = state(0) | ||
| 71 | 71 | const resultViewportHeight = state(450) |
| 72 | 72 | const loading = state(true) |
| 73 | 73 | const error = state('') |
| 74 | /** The privacy pane that would settle the current error, when the failure names one. */ | |
| 75 | const errorPermission = state('') | |
| 74 | 76 | const status = state('Ready when you are') |
| 75 | 77 | const editor = state(false) |
| 76 | 78 | const aliasApplication = state<Result | null>(null) |
| @@ -309,10 +311,11 @@ async function reload() { | ||
| 309 | 311 | |
| 310 | 312 | function focusSearch() { batch(() => { query.set(''); category.set('all'); selected.set(0) }); void nextTick(() => search.current?.focus()) } |
| 311 | 313 | function chooseCategory(value) { batch(() => { query.set(''); category.set(value); selected.set(0) }); void nextTick(() => search.current?.focus()) } |
| 312 | function fail(reason) { error.set(reason.message || String(reason)) } | |
| 314 | function clearError() { batch(() => { error.set(''); errorPermission.set('') }) } | |
| 315 | function fail(reason) { batch(() => { error.set(reason.message || String(reason)); errorPermission.set(reason.permission || '') }) } | |
| 313 | 316 | function openSettings(checkUpdates = false) { |
| 314 | 317 | settings.set(true) |
| 315 | error.set('') | |
| 318 | clearError() | |
| 316 | 319 | if (settingsSection() === 'general') { |
| 317 | 320 | void refreshLogin() |
| 318 | 321 | if (checkUpdates === true) void updateLaunchpad() |
| @@ -544,7 +547,7 @@ effect(() => { | ||
| 544 | 547 | onDestroy(() => githubRequest?.abort()) |
| 545 | 548 | async function connectAccount() { |
| 546 | 549 | 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() } | |
| 548 | 551 | catch (reason) { fail(reason) } |
| 549 | 552 | finally { saving.set(false) } |
| 550 | 553 | } |
| @@ -713,7 +716,7 @@ async function run(item = active()) { | ||
| 713 | 716 | const command = systemCommand(item.content) |
| 714 | 717 | if (command?.confirm && !await stxConfirm(command.confirm)) return |
| 715 | 718 | } |
| 716 | error.set('') | |
| 719 | clearError() | |
| 717 | 720 | saving.set(true) |
| 718 | 721 | try { |
| 719 | 722 | await execute(item, query(), duration => recordExecution(item, duration)) |
| @@ -746,7 +749,7 @@ function newCommand() { | ||
| 746 | 749 | content.set('') |
| 747 | 750 | alias.set('') |
| 748 | 751 | editor.set(true) |
| 749 | error.set('') | |
| 752 | clearError() | |
| 750 | 753 | void nextTick(() => commandTitle.current?.focus()) |
| 751 | 754 | } |
| 752 | 755 | function editCommand() { |
| @@ -755,7 +758,7 @@ function editCommand() { | ||
| 755 | 758 | aliasApplication.set(item) |
| 756 | 759 | applicationAliases.set(records()[item.key]?.aliases || '') |
| 757 | 760 | editor.set(true) |
| 758 | error.set('') | |
| 761 | clearError() | |
| 759 | 762 | void nextTick(() => aliasInput.current?.focus()) |
| 760 | 763 | return |
| 761 | 764 | } |
| @@ -767,7 +770,7 @@ function editCommand() { | ||
| 767 | 770 | content.set(item.content) |
| 768 | 771 | alias.set(item.alias || '') |
| 769 | 772 | editor.set(true) |
| 770 | error.set('') | |
| 773 | clearError() | |
| 771 | 774 | void nextTick(() => commandTitle.current?.focus()) |
| 772 | 775 | } |
| 773 | 776 | async function saveCommand() { |
| @@ -903,7 +906,7 @@ onDestroy(() => { cleanup(); cleanupTray() }) | ||
| 903 | 906 | <button type="button" class="new" @click="newCommand()" title="New command (Command N)" aria-label="New command">+</button> |
| 904 | 907 | <button type="button" class="tab" @click="openSettings()" title="Settings (Command ,)" aria-label="Settings"><span class="i-f7-gear-alt" aria-hidden="true"></span></button> |
| 905 | 908 | </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> | |
| 907 | 910 | <div :if="category() === 'github'" class="flex gap-2 items-center px-5 github-tools"> |
| 908 | 911 | <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> |
| 909 | 912 | <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 | ||
| 1169 | 1172 | .empty-detail { color: var(--muted); font-size: 12px; line-height: 1.6; } |
| 1170 | 1173 | .text-action { color: var(--accent); font-size: 11px; } |
| 1171 | 1174 | .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; } | |
| 1172 | 1177 | .editor { position: absolute; inset: 0; background: var(--surface); z-index: 10; display: flex; flex-direction: column; min-height: 0; } |
| 1173 | 1178 | .editor h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.5px; } |
| 1174 | 1179 | .editor label { display: block; font-size: 12px; margin: 8px 0 4px; color: var(--ink); } |
| @@ -78,13 +78,16 @@ test('events parse local ISO times, sort by start and describe their day', () => | ||
| 78 | 78 | |
| 79 | 79 | test('a failed script is explained by its error number, and older hosts keep the permission hint', () => { |
| 80 | 80 | 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' }) | |
| 82 | 84 | expect(failure(-1743)).toMatchObject({ permission: 'automation' }) |
| 83 | 85 | expect(failure(-1744)).toMatchObject({ permission: 'automation' }) |
| 84 | 86 | expect(failure(-25211)).toMatchObject({ permission: 'accessibility', message: expect.stringContaining('Accessibility') }) |
| 85 | 87 | expect(failure(-1719)).toMatchObject({ permission: 'accessibility' }) |
| 86 | 88 | expect(failure(-128)).toEqual({ message: 'The request was cancelled.' }) |
| 87 | 89 | 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.' }) | |
| 88 | 91 | expect(failure(-1728, "Can't get window 3.")).toEqual({ message: "Contacts could not do that: Can't get window 3." }) |
| 89 | 92 | expect(failure(-1728)).toEqual({ message: 'Contacts could not do that (error -1728).' }) |
| 90 | 93 | expect(failure(undefined, 'only a message')).toMatchObject({ permission: 'automation' }) |
| @@ -45,5 +45,25 @@ test('a system plan runs through exactly one port and explains a refused script' | ||
| 45 | 45 | await runSystemPlan(systemCommand('screen-saver')!, ports) |
| 46 | 46 | await runSystemPlan(systemCommand('toggle-mute')!, ports) |
| 47 | 47 | 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 | ||
| 54 | test('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.`) | |
| 49 | 69 | }) |