also looking at this
fix(app): a fresh install now either works or says why
#8
16 files
+512
-75
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.
| @@ -30,7 +30,7 @@ async function openMessages(config: ReturnType<typeof loadConfig>, appDir: strin | ||
| 30 | 30 | catch (error) { |
| 31 | 31 | const message = error instanceof MessagesAccessError ? error.message : String(error) |
| 32 | 32 | console.error(`[uplink] ${message} Retrying in ${RETRY_OPEN_MS / 1000}s.`) |
| 33 | await writeHeartbeat(appDir, { pid: process.pid, startedAt, lastPollAt: null, lastError: message, allowed: config.allowed, own: [], active: [] }) | |
| 33 | await writeHeartbeat(appDir, { pid: process.pid, startedAt, lastPollAt: null, lastError: message, automation: null, lastSendError: null, allowed: config.allowed, own: [], active: [] }) | |
| 34 | 34 | await Bun.sleep(RETRY_OPEN_MS) |
| 35 | 35 | } |
| 36 | 36 | } |
| @@ -73,24 +73,39 @@ export default defineCommand((cli) => { | ||
| 73 | 73 | keepMessagesOpen() |
| 74 | 74 | |
| 75 | 75 | const messages = await openMessages(config, appDir, startedAt) |
| 76 | const sender = new AppleScriptSender() | |
| 76 | 77 | const uplink = new Uplink({ |
| 77 | 78 | config, |
| 78 | 79 | messages, |
| 79 | sender: new AppleScriptSender(), | |
| 80 | sender, | |
| 80 | 81 | engine: selectedEngine(config), |
| 81 | 82 | store: new ModelStore(`${appDir}/storage/uplink/cursor`), |
| 82 | 83 | }) |
| 83 | 84 | await uplink.start() |
| 84 | 85 | |
| 85 | const beat = (): Promise<number> => writeHeartbeat(appDir, { | |
| 86 | pid: process.pid, | |
| 87 | startedAt, | |
| 88 | lastPollAt: uplink.lastPollAt, | |
| 89 | lastError: uplink.lastError, | |
| 90 | allowed: uplink.allowedHandles, | |
| 91 | own: uplink.ownHandles, | |
| 92 | active: uplink.activeRuns, | |
| 93 | }) | |
| 86 | // The Automation grant is per app, so only this process can prove that | |
| 87 | // Uplink.app may drive Messages. Ask once at start, and again whenever a | |
| 88 | // send has failed, so the doctor and the dashboard report the subject | |
| 89 | // that actually sends rather than whichever terminal ran them. | |
| 90 | let automation = await sender.canSend() | |
| 91 | if (!automation.ok) | |
| 92 | console.error(`[uplink] cannot send through Messages: ${automation.detail}`) | |
| 93 | ||
| 94 | const beat = async (): Promise<number> => { | |
| 95 | if (uplink.lastSendError && automation.ok) | |
| 96 | automation = await sender.canSend() | |
| 97 | return writeHeartbeat(appDir, { | |
| 98 | pid: process.pid, | |
| 99 | startedAt, | |
| 100 | lastPollAt: uplink.lastPollAt, | |
| 101 | lastError: uplink.lastError, | |
| 102 | automation: { ok: automation.ok, detail: automation.detail }, | |
| 103 | lastSendError: uplink.lastSendError, | |
| 104 | allowed: uplink.allowedHandles, | |
| 105 | own: uplink.ownHandles, | |
| 106 | active: uplink.activeRuns, | |
| 107 | }) | |
| 108 | } | |
| 94 | 109 | // .env is read once, at start. Editing it (a new token, say) used to do |
| 95 | 110 | // nothing until someone remembered uplink:restart, and every text in |
| 96 | 111 | // between failed. Under launchd, which restarts us, pick it up on our |
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import type { Server } from 'bun' |
| 3 | 3 | import type { UplinkConfig } from './config' |
| 4 | 4 | import type { EngineId, EngineInstall, EngineProbe, ProbeReason } from './engine' |
| 5 | import type { AutomationState } from './sender' | |
| 5 | 6 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 6 | 7 | import { homedir } from 'node:os' |
| 7 | 8 | import { join } from 'node:path' |
| @@ -161,6 +162,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 161 | 162 | if (settings.openAtLogin && (!existsSync(LAUNCH_AGENT) || !readFileSync(LAUNCH_AGENT, 'utf8').includes(`<string>${process.execPath}</string>`))) |
| 162 | 163 | setOpenAtLogin(true) |
| 163 | 164 | |
| 165 | const sender = new AppleScriptSender() | |
| 164 | 166 | const store = new SqliteStore(DATABASE_PATH) |
| 165 | 167 | const startedAt = Date.now() |
| 166 | 168 | let messages: MessagesDb | null = null |
| @@ -168,6 +170,17 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 168 | 170 | let uplink: Uplink | null = null |
| 169 | 171 | const engines = new Map<string, EngineState>() |
| 170 | 172 | let checkingEngines = false |
| 173 | let automation: AutomationState | null = null | |
| 174 | ||
| 175 | /** | |
| 176 | * Whether macOS will let this app drive Messages. This process is Uplink.app, | |
| 177 | * so unlike `buddy uplink:doctor` under Terminal it is the right subject to | |
| 178 | * ask. The first call raises the prompt, which is why setup calls it rather | |
| 179 | * than leaving it to the first real reply. | |
| 180 | */ | |
| 181 | const checkAutomation = async (): Promise<void> => { | |
| 182 | automation = await sender.canSend() | |
| 183 | } | |
| 171 | 184 | |
| 172 | 185 | /** |
| 173 | 186 | * Probe every engine, not only the selected one: the popover shows the other |
| @@ -198,7 +211,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 198 | 211 | messagesError = error instanceof MessagesAccessError ? 'Uplink needs Full Disk Access to read Messages.' : String(error) |
| 199 | 212 | return |
| 200 | 213 | } |
| 201 | uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: selectedEngine(config), store }) | |
| 214 | uplink = new Uplink({ config, messages, sender, engine: selectedEngine(config), store }) | |
| 202 | 215 | await uplink.start() |
| 203 | 216 | } |
| 204 | 217 | |
| @@ -218,10 +231,16 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 218 | 231 | const keepMessagesOpen = setInterval(() => { |
| 219 | 232 | if (uplink && Bun.spawnSync(['pgrep', '-x', 'Messages']).exitCode !== 0) |
| 220 | 233 | Bun.spawnSync(['open', '-g', '-a', 'Messages']) |
| 234 | // Re-ask while the answer is anything but yes. A refusal only changes in | |
| 235 | // System Settings, and a timeout from a Messages that was still starting | |
| 236 | // should not leave the app stuck in setup for ever. | |
| 237 | if (!automation?.ok) | |
| 238 | void checkAutomation() | |
| 221 | 239 | }, 15_000) |
| 222 | 240 | |
| 223 | 241 | await startWatching() |
| 224 | 242 | void checkEngines() |
| 243 | void checkAutomation() | |
| 225 | 244 | |
| 226 | 245 | const status = () => { |
| 227 | 246 | const recent = store.recentRuns(8).map(run => ({ |
| @@ -273,6 +292,16 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 273 | 292 | detail: allowed.length > 0 ? allowed.join(', ') : 'No handles yet. Sign in to Messages, or add your number.', |
| 274 | 293 | informational: false, |
| 275 | 294 | }, |
| 295 | { | |
| 296 | // The outbound half of the chain. Without this the app reported itself | |
| 297 | // ready while every reply was refused, so a first text produced a run | |
| 298 | // marked done and a phone that heard nothing. | |
| 299 | id: 'automation', | |
| 300 | name: 'Send replies', | |
| 301 | ok: automation?.ok ?? false, | |
| 302 | detail: uplink?.lastSendError ?? automation?.detail ?? 'Not checked yet', | |
| 303 | informational: false, | |
| 304 | }, | |
| 276 | 305 | ] |
| 277 | 306 | // An engine the person has not selected must not hold the app in setup. |
| 278 | 307 | const ready = checks.every(check => check.ok || check.informational) |
| @@ -325,6 +354,15 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 325 | 354 | Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles']) |
| 326 | 355 | return json({ ok: true }) |
| 327 | 356 | |
| 357 | case '/api/automation/test': | |
| 358 | await checkAutomation() | |
| 359 | return json(automation) | |
| 360 | ||
| 361 | case '/api/open/automation': { | |
| 362 | Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation']) | |
| 363 | return json({ ok: true }) | |
| 364 | } | |
| 365 | ||
| 328 | 366 | case '/api/open/install-docs': { |
| 329 | 367 | // The selected engine's own documentation, so the link cannot drift |
| 330 | 368 | // from the command shown beside it. |
| @@ -35,6 +35,10 @@ export interface Heartbeat { | ||
| 35 | 35 | startedAt: number |
| 36 | 36 | lastPollAt: number | null |
| 37 | 37 | lastError: string | null |
| 38 | /** Whether the watcher itself can drive Messages. Null before it has asked. */ | |
| 39 | automation: { ok: boolean, detail: string } | null | |
| 40 | /** Why the last reply could not be delivered, if one could not. */ | |
| 41 | lastSendError: string | null | |
| 38 | 42 | allowed: string[] |
| 39 | 43 | own: string[] |
| 40 | 44 | active: Array<{ prompt: string, startedAt: number, lastActivity: string | null, queued: number }> |
| @@ -150,7 +154,28 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C | ||
| 150 | 154 | ? { name: 'Messages app', ok: true, detail: 'running' } |
| 151 | 155 | : { name: 'Messages app', ok: false, detail: 'not running', fix: 'Open Messages and sign in with your Apple ID' }) |
| 152 | 156 | |
| 153 | checks.push({ name: 'Sending replies', ok: true, detail: 'macOS asks once, on the first reply, to let Uplink control Messages - click Allow' }) | |
| 157 | // The Automation grant is per app: the watcher inside Uplink.app and this | |
| 158 | // command under Terminal are different subjects. Only the watcher's answer is | |
| 159 | // about the thing that will actually send, so ask the heartbeat rather than | |
| 160 | // measuring this process and reporting it as Uplink's. | |
| 161 | if (fresh && heartbeat.automation) { | |
| 162 | checks.push(heartbeat.automation.ok | |
| 163 | ? { name: 'Sending replies', ok: true, detail: heartbeat.automation.detail } | |
| 164 | : { | |
| 165 | name: 'Sending replies', | |
| 166 | ok: false, | |
| 167 | detail: heartbeat.lastSendError ?? heartbeat.automation.detail, | |
| 168 | fix: `Allow ${paths.bundle} to control Messages in System Settings > Privacy & Security > Automation, then ./buddy uplink:restart. macOS asks once; if it was declined, the switch is in that pane.`, | |
| 169 | }) | |
| 170 | } | |
| 171 | else { | |
| 172 | checks.push({ | |
| 173 | name: 'Sending replies', | |
| 174 | ok: false, | |
| 175 | detail: 'not checked: the watcher is not running, and only it can prove Uplink.app may control Messages', | |
| 176 | fix: 'Start it with ./buddy uplink:install, then run this again', | |
| 177 | }) | |
| 178 | } | |
| 154 | 179 | |
| 155 | 180 | return checks |
| 156 | 181 | } |
| @@ -15,8 +15,27 @@ export interface ReplyTarget { | ||
| 15 | 15 | service: string |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | /** | |
| 19 | * Whether macOS will let this process drive Messages. | |
| 20 | * | |
| 21 | * The grant is per app and per process identity: the watcher inside Uplink.app | |
| 22 | * and `buddy uplink:doctor` under Terminal are two different subjects, so a | |
| 23 | * result is only ever about the process that asked. | |
| 24 | */ | |
| 25 | export interface AutomationState { | |
| 26 | ok: boolean | |
| 27 | reason: 'ok' | 'denied' | 'failed' | |
| 28 | detail: string | |
| 29 | } | |
| 30 | ||
| 18 | 31 | export interface Sender { |
| 19 | 32 | send: (target: ReplyTarget, text: string) => Promise<void> |
| 33 | /** | |
| 34 | * Can this process send at all. The first call raises the macOS Automation | |
| 35 | * prompt, which is the point of having it: it has to happen while someone is | |
| 36 | * at the Mac, not on the first real reply to a phone that then hears nothing. | |
| 37 | */ | |
| 38 | canSend: () => Promise<AutomationState> | |
| 20 | 39 | } |
| 21 | 40 | |
| 22 | 41 | const SCRIPT = ` |
| @@ -40,7 +59,51 @@ on run argv | ||
| 40 | 59 | end run |
| 41 | 60 | ` |
| 42 | 61 | |
| 62 | /** | |
| 63 | * Reads from Messages and sends nothing, so it can be run during setup and on | |
| 64 | * demand. Counting accounts needs exactly the Automation permission a real | |
| 65 | * reply needs. | |
| 66 | */ | |
| 67 | const PROBE_SCRIPT = 'tell application "Messages" to count of accounts' | |
| 68 | ||
| 69 | /** macOS error -1743 is "not authorized to send Apple events". */ | |
| 70 | const NOT_AUTHORIZED = /-1743|not authori[sz]ed|not allowed to send|doesn't have permission/i | |
| 71 | ||
| 72 | /** -1712 is an AppleEvent timeout, which a busy or starting Messages produces. */ | |
| 73 | const TIMED_OUT = /-1712|timed out/i | |
| 74 | ||
| 75 | /** Long enough for someone to read the macOS prompt and click Allow. */ | |
| 76 | const PROBE_TIMEOUT_MS = 60_000 | |
| 77 | ||
| 43 | 78 | export class AppleScriptSender implements Sender { |
| 79 | async canSend(): Promise<AutomationState> { | |
| 80 | try { | |
| 81 | const proc = Bun.spawn(['osascript', '-e', PROBE_SCRIPT], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 82 | // The prompt itself blocks the script until someone answers it, and a | |
| 83 | // busy or starting Messages can time out on its own, so this waits | |
| 84 | // generously rather than reporting a refusal that has not happened. | |
| 85 | const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS) | |
| 86 | const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) | |
| 87 | clearTimeout(timer) | |
| 88 | ||
| 89 | if (code === 0) | |
| 90 | return { ok: true, reason: 'ok', detail: 'Uplink can send through Messages' } | |
| 91 | ||
| 92 | const said = stderr.trim() | |
| 93 | if (NOT_AUTHORIZED.test(said)) | |
| 94 | return { ok: false, reason: 'denied', detail: 'macOS has not been allowed to let Uplink control Messages' } | |
| 95 | ||
| 96 | // A timeout is not a refusal. Say so, because the two want different | |
| 97 | // things from the person reading it. | |
| 98 | return TIMED_OUT.test(said) | |
| 99 | ? { ok: false, reason: 'failed', detail: 'Messages did not answer in time. It may still be starting up.' } | |
| 100 | : { ok: false, reason: 'failed', detail: said || `osascript exited ${code}` } | |
| 101 | } | |
| 102 | catch (error) { | |
| 103 | return { ok: false, reason: 'failed', detail: error instanceof Error ? error.message : String(error) } | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 44 | 107 | async send(target: ReplyTarget, text: string): Promise<void> { |
| 45 | 108 | const proc = Bun.spawn( |
| 46 | 109 | ['osascript', '-e', SCRIPT, text, target.chatGuid, target.handle, target.service === 'SMS' ? 'SMS' : 'iMessage'], |
| @@ -89,6 +89,8 @@ export class Uplink { | ||
| 89 | 89 | private timer: ReturnType<typeof setTimeout> | null = null |
| 90 | 90 | lastPollAt: number | null = null |
| 91 | 91 | lastError: string | null = null |
| 92 | /** Why the last reply could not be delivered, for the heartbeat and the popover. */ | |
| 93 | lastSendError: string | null = null | |
| 92 | 94 | private ticking = false |
| 93 | 95 | private stopped = false |
| 94 | 96 | |
| @@ -373,7 +375,16 @@ export class Uplink { | ||
| 373 | 375 | const text = result.authFailure |
| 374 | 376 | ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.` |
| 375 | 377 | : result.ok ? result.text : `That failed: ${result.text}` |
| 376 | await this.reply(active.target, text, { keepRest: true }) | |
| 378 | ||
| 379 | // A run whose answer never left the Mac is not done, whatever the agent | |
| 380 | // did. Recording it as done is how a first install reports itself healthy | |
| 381 | // while the phone hears nothing. | |
| 382 | if (!await this.reply(active.target, text, { keepRest: true })) { | |
| 383 | await this.deps.store.updateRun(active.runId, { | |
| 384 | status: 'failed', | |
| 385 | error: `The answer could not be delivered through Messages: ${this.lastSendError ?? 'unknown error'}`, | |
| 386 | }) | |
| 387 | } | |
| 377 | 388 | } |
| 378 | 389 | |
| 379 | 390 | const next = state.queue.shift() |
| @@ -437,7 +448,7 @@ export class Uplink { | ||
| 437 | 448 | * and easy to lose track of. Short status replies never touch the stored |
| 438 | 449 | * conversation, so one sent mid-run cannot race the run's own save. |
| 439 | 450 | */ |
| 440 | async reply(target: ReplyTarget, text: string, options: { keepRest?: boolean } = {}): Promise<void> { | |
| 451 | async reply(target: ReplyTarget, text: string, options: { keepRest?: boolean } = {}): Promise<boolean> { | |
| 441 | 452 | const parts = chunk(toPlainText(text), this.config.maxChars - this.config.replyPrefix.length) |
| 442 | 453 | const now = parts.slice(0, this.config.maxParts) |
| 443 | 454 | const later = parts.slice(this.config.maxParts) |
| @@ -456,10 +467,13 @@ export class Uplink { | ||
| 456 | 467 | await this.deps.sender.send(target, body) |
| 457 | 468 | } |
| 458 | 469 | catch (error) { |
| 459 | this.log.error(`Reply to ${target.handle} failed: ${error instanceof Error ? error.message : String(error)}`) | |
| 460 | return | |
| 470 | this.lastSendError = error instanceof Error ? error.message : String(error) | |
| 471 | this.log.error(`Reply to ${target.handle} failed: ${this.lastSendError}`) | |
| 472 | return false | |
| 461 | 473 | } |
| 462 | 474 | } |
| 475 | this.lastSendError = null | |
| 476 | return true | |
| 463 | 477 | } |
| 464 | 478 | } |
| 465 | 479 | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { Engine, EngineEvent, EngineProbe, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine' |
| 2 | import type { ReplyTarget, Sender } from '../../../app/Uplink/sender' | |
| 2 | import type { AutomationState, ReplyTarget, Sender } from '../../../app/Uplink/sender' | |
| 3 | 3 | import { Database } from 'bun:sqlite' |
| 4 | 4 | import { mkdtempSync } from 'node:fs' |
| 5 | 5 | import { tmpdir } from 'node:os' |
| @@ -119,10 +119,20 @@ export class FakeChatDb { | ||
| 119 | 119 | |
| 120 | 120 | export class FakeSender implements Sender { |
| 121 | 121 | sent: Array<{ target: ReplyTarget, text: string }> = [] |
| 122 | /** Set to make every send fail, the way a refused Automation grant does. */ | |
| 123 | refuse: string | null = null | |
| 124 | automation: AutomationState = { ok: true, reason: 'ok', detail: 'Uplink can send through Messages' } | |
| 125 | ||
| 122 | 126 | /** Mirror each send into chat.db, the way Messages records it. */ |
| 123 | 127 | constructor(private readonly db?: FakeChatDb) {} |
| 124 | 128 | |
| 129 | async canSend(): Promise<AutomationState> { | |
| 130 | return this.automation | |
| 131 | } | |
| 132 | ||
| 125 | 133 | async send(target: ReplyTarget, text: string): Promise<void> { |
| 134 | if (this.refuse) | |
| 135 | throw new Error(this.refuse) | |
| 126 | 136 | this.sent.push({ target, text }) |
| 127 | 137 | this.db?.add({ chat: target.handle, text, fromMe: true }) |
| 128 | 138 | } |
| @@ -190,6 +190,23 @@ describe('Uplink', () => { | ||
| 190 | 190 | expect(h.engine.runs[1].request.sessionId).toBeNull() |
| 191 | 191 | }) |
| 192 | 192 | |
| 193 | it('does not record a run as done when the reply could not be delivered', async () => { | |
| 194 | const h = await harness() | |
| 195 | // What a refused Messages Automation grant looks like from here. Without | |
| 196 | // this the run was recorded done, the dashboard showed an answer, and the | |
| 197 | // phone heard nothing at all. | |
| 198 | h.sender.refuse = 'Messages refused to send (osascript exit 1): not authorized to send Apple events' | |
| 199 | h.fake.add({ chat: ME, text: 'hello', fromMe: true }) | |
| 200 | await h.uplink.tick() | |
| 201 | h.engine.finish(0, 'Here are the scores') | |
| 202 | await settle() | |
| 203 | ||
| 204 | expect(h.sender.texts()).toEqual([]) | |
| 205 | expect(h.store.runs.get(1)?.status).toBe('failed') | |
| 206 | expect(h.store.runs.get(1)?.error).toContain('could not be delivered') | |
| 207 | expect(h.uplink.lastSendError).toContain('not authorized') | |
| 208 | }) | |
| 209 | ||
| 193 | 210 | it('tells the user which CLI is not logged in, and how to fix it', async () => { |
| 194 | 211 | const h = await harness() |
| 195 | 212 | h.fake.add({ chat: ME, text: 'hello', fromMe: true }) |