Skip to content
C.W.K.
Lesson 03 of 04 · published

Quit Restores First — and Explains When It Can't

~14 min · quit, applicationShouldTerminate, two-step, main-actor, updates

Level 0Idle Hands
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Normal Quit releases first; forced termination leaves the durable journal for visible recovery on the next launch." — Tinker architecture, hide invariant 7

A Quitting Actuator Owes a Restore

If Tinker quit with editing mode active, Claude, Voyager and Eagle would stay hidden with nobody running who remembers hiding them. The journal would remember, and the next launch could restore them — but "next launch" might be days away. So when Dad chooses Quit from Tinker's menu, Tinker restores everything it owns first, using the same release path as the emergency command, with its trigger recorded as system.

Why Quit Starts by Cancelling

AppKit asks an app whether it may terminate through applicationShouldTerminate(_:), and the obvious answer for "I need a moment" is .terminateLater, which keeps the app alive until it later calls reply(toApplicationShouldTerminate:). The comment beside Tinker's delegate records the catch. While a terminateLater is pending, AppKit spins a nested run loop, and a main-actor Task started from inside the termination request did not get to run in it — the asynchronous restore was starved. So for an ordinary Quit, Tinker answers .terminateCancel, lets the delegate method return, runs the restore on the main actor in peace, and then calls terminate again itself, this time answering .terminateNow. A guard flag makes sure a second terminate request arriving mid-restore is cancelled rather than started twice.

When Something Can't Be Restored

If the restore leaves something behind — an app refused to show, an uncertain record exists — Tinker does not quit and does not refuse forever. It shows the reason: "Some changes could not be restored and remain in Tinker's journal (…). Choose Quit again within 30 seconds to leave them for the next launch." The first Quit explains; a second Quit within 30 seconds proceeds and leaves the remainder to the durable journal. It is the same two-step shape as Delete and Forget: the destructive outcome takes a second, informed click.

Force Quit and Updates

Force Quit, a crash or kill -9 cannot run any of this. That is why the journal is durable and why the next launch shows what is left: confirmed changes can be restored from the menu, uncertain ones wait in Recovery. It is also why Tinker is never updated by signalling it. The family native deploy asks a running Tinker to quit politely — a quit Apple event, which takes the logout path in the next lesson — and installs over it only after it has left; a copy that does not quit is skipped and keeps its old bundle. Booting out its login agent or sending a signal would end Tinker before it restored what it owns.

Code

An ordinary Quit: cancel, restore, then terminate for real (trimmed)·swift
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
  if readyToTerminate { return .terminateNow }
  if terminating { return .terminateCancel }       // a second request mid-restore
  terminating = true
  let insisted = quitArmedUntil.map { $0 > Date() } ?? false
  Task { @MainActor in
    let clean = await model?.quit() ?? true        // release with trigger .system
    terminating = false
    if clean || insisted {
      model?.stopServices()
      readyToTerminate = true
      sender.terminate(nil)                         // now answered with .terminateNow
    } else {
      quitArmedUntil = Date().addingTimeInterval(30)
      model?.error = "Some changes could not be restored and remain in Tinker's journal. " +
        "Choose Quit again within 30 seconds to leave them for the next launch."
    }
  }
  return .terminateCancel   // return first so the restore task can run
}
The model's quit: nothing owned means nothing to do·swift
/// False when something could not be restored; the durable journal keeps it.
func quit() async -> Bool {
  guard let engine,
        !engine.journal.active.isEmpty || !engine.journal.changes.isEmpty else { return true }
  await release(trigger: .system)
  return engine.journal.changes.isEmpty
}

External links

Exercise

An update script on the signing workbench needs to replace Tinker on an enrolled Mac while editing mode is active there. Write the steps it should take and the condition under which it must give up on that Mac, then list the two shortcuts a hurried script might take and what each would leave behind.
Hint
Ask the running Tinker to quit politely. That request arrives as a quit Apple event, so it takes the logout path of the next lesson: Tinker restores for at most eight seconds and then always leaves, keeping anything unrestored in its journal — the menu's 30-second two-step never applies. Only after it has left, replace the bundle and load the login agent. If it has still not left when the deploy gives up waiting, skip that Mac and keep the old bundle. Shortcut one, a signal or bootout, leaves the apps hidden and the journal as the only memory. Shortcut two, replacing the bundle under a running process, risks a mismatch between the running code and the signed bundle on disk.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.