cmux NEXT: an automatic update no longer pulls you into the app; it relaunches behind the app you are in

TE
11d ago
+429-26

Leo: "and stop opening up my next dev. Why you keep opening up next dev? Who's doing that? Like I'm not looking at it right now. So stop automatically putting me in there."

Cause

cmux NEXT DEV (the tip app) was running in the background while the user worked in cmux NIGHTLY. On every new tip build its updater installed in the background and relaunched at once, and the relaunched app came to the front:

  • 13:37:30 PT updater log: background install: accepting 1.0.0-tip.40389692094, then update relaunch is immediate; the daemon preserves live sessions (automatic=true); 13:37:53 will install 40389692094; the new process starts with PPID 1.
  • launchservicesd: SETFRONT: pid=<new DEV> ... foreground=1 oldFrontPid=<NIGHTLY>; the new process: BringForward: ... bringForward=1 foreground=1 launchedByLS=1.
  • The same relaunch-to-front happened with every tip build that day: 10:05, 10:15, 10:50, 10:55, 11:10, 11:46, 11:56, 12:11, 12:36, 13:07, 13:27, 13:37.
  • Ruled out: the deep-link scheme (DEV claims only cmux-next-dev:), and launch items or LaunchAgents (none for DEV).

Sparkle's installer agent relaunches with -[NSWorkspace openURL:] (Sparkle 2.9.6 InstallerProgressAppController relaunchApplication), which activates the app, and the launch then calls WindowActivation.activateApp() itself.

Fix (all NEXT builds)

An update never pulls the user into the app.

  • UpdateRelaunchFocus (CmuxNextUpdater): just before Sparkle relaunches (updaterWillRelaunchApplication), if the install was not clicked (automaticInstallInFlight, the idle policy) and this app was not frontmost when the install started (sampled in updaterPrepareAutomaticInstall) nor is at the relaunch, a small record goes in the app's defaults: the target build (from updaterWillInstallUpdate), the time, and the frontmost app's pid and bundle id. A click (Restart to Update, the update card, Sparkle's install-on-quit "Restart Now"), or the user being in the app, clears the record: those relaunches activate as before.
  • At launch (CmuxNextApp.main, before NSApp runs) the record is read and cleared. It applies only to the build it names and for 90 s; anything else is stale and does nothing.
  • The relaunch's startup runs on the existing no-activate guard (InputVerificationService.startNoActivateGuard(relaunch:), NoActivateKeyboardGuard): the app refuses its own activation (CmuxApplication.refusesActivation, now also scoped to this startup), and an activation without user intent (the LaunchServices one) goes back to the recorded app. Windows that open or are raised while the app refuses activation are ordered front without becoming key (WindowActivation.plan(startsBehind:), read from NSApp through ActivationRefusing); in an inactive app they stay behind the active one, with no key focus, no activation and no Dock bounce. The startup ends after 8 s, or at once when the user chooses cmux (a click in it, Cmd-Tab or a click that activates it, a Dock click, which also activates); then the guard stops, its observers are removed and activation is allowed again. CMUX_NEXT_NO_ACTIVATE=1 launches behave as before.
  • The classic app and the shared CmuxUpdater package are unchanged.

An update never starts an app that was not running: Sparkle relaunches only when the app was alive when the installer started (_applicationInitiallyAlive) and the install asked to relaunch (_shouldRelaunch); the install-on-quit path installs with no relaunch. This PR does not change either.

Tests

UpdateRelaunchFocusTests (new): automatic vs clicked vs frontmost decision table; a record applies only to its build while fresh (wrong build, too old, future-dated are ignored); consume returns once and clears stale and unreadable records; the updater writes the record (with the frontmost app) for an automatic install in a background app, writes none when the app is frontmost, and clears an older one on a clicked install. WindowActivationStartsBehindTests (new): present/raise come in without the keys, focus and no-activate launches keep their plans. A test that an install started while the app was frontmost activates. NoActivateKeyboardGuardTests gains a test for the user-chose callback and the stopped guard. Ran on the fleet with NoActivateKeyboardGuardTests and SilentBackgroundUpdateTests; app compile-check on the fleet.

No capture: the change has no visible UI, and the fleet's capture route cannot stage a real Sparkle install-and-relaunch from a feed; the behaviour shows only on the next tip build relaunching behind another app.

Risk: a relaunch that should activate and does not. Also, LaunchServices' own activation of the relaunched process happens before any app code runs; the app hands it back at once (at guard start or on didBecomeActive), so for that moment the new process is frontmost. That needs automaticInstallInFlight set on a clicked install (the click path clears it) or NSApp.isActive false while the user is in the app. Also, a Dock click on the app in the first 8 s after a background relaunch may be handed back once before the reopen re-activates it.

Agent session: agent session

🤖 Generated with Claude Code