Skip to content

Commit d986bad

Browse files
author
pavelhov
committed
feat(macos): clarify companion and proxy exit
1 parent 8e6a91b commit d986bad

11 files changed

Lines changed: 582 additions & 100 deletions

File tree

‎app/Sources/MenuBarCore/LaunchAtLogin.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,11 @@ public enum DesktopStartupMode: String, Equatable, Sendable {
5050
}
5151
switch mode {
5252
case .desktop:
53-
return serviceManaged
54-
? "Desktop · server protected in background"
55-
: "Desktop · app starts the server"
53+
// Both startup paths outlive the companion: services are launchd-owned,
54+
// while the app fallback uses spawnDetachedProxyStart() and unref().
55+
return "Desktop · Quit Menu Bar leaves proxy running"
5656
case .headless:
57-
return "Headless · server starts without the menu app"
57+
return "Headless · proxy runs without the menu bar"
5858
case .off:
5959
return "Off · start OpenCodex manually"
6060
}

‎app/Sources/MenuBarCoreTests/LaunchAtLoginSuite.swift‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,27 @@ enum LaunchAtLoginSuite {
171171
DesktopStartupMode.resolve(loginStatus: .disabled, serviceManaged: false),
172172
.off
173173
)
174+
t.equal(
175+
DesktopStartupMode.summary(
176+
loginStatus: .enabled,
177+
serviceManaged: true
178+
),
179+
"Desktop · Quit Menu Bar leaves proxy running"
180+
)
181+
t.equal(
182+
DesktopStartupMode.summary(
183+
loginStatus: .enabled,
184+
serviceManaged: false
185+
),
186+
"Desktop · Quit Menu Bar leaves proxy running"
187+
)
188+
t.equal(
189+
DesktopStartupMode.summary(
190+
loginStatus: .disabled,
191+
serviceManaged: true
192+
),
193+
"Headless · proxy runs without the menu bar"
194+
)
174195
t.equal(
175196
DesktopStartupMode.summary(
176197
loginStatus: .requiresApproval,

‎app/Sources/MenuBarUI/AppDelegate.swift‎

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import AppKit
22
import MenuBarCore
33

4-
public final class AppDelegate: NSObject, NSApplicationDelegate {
4+
public final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
55
private var statusItem: NSStatusItem?
66
private let panel = PopoverPanel()
77
private let controller = PopoverViewController()
@@ -22,6 +22,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
2222
public override init() { super.init() }
2323

2424
public func applicationDidFinishLaunching(_ notification: Notification) {
25+
installApplicationMenu()
2526
do {
2627
let installation = try ProxyDiscovery.discover()
2728
let client = try ProxyClient(installation: installation)
@@ -66,7 +67,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
6667
controller.onStart = { [weak self] in self?.startProxy() }
6768
controller.onStop = { [weak self] in self?.stopProxy() }
6869
controller.onRestart = { [weak self] in self?.restartProxy() }
69-
controller.onQuit = { NSApp.terminate(nil) }
70+
controller.onQuitMenuBar = { [weak self] in self?.quitMenuBar(nil) }
71+
controller.onStopAndQuit = { [weak self] in self?.stopOpenCodexAndQuit(nil) }
7072
controller.onLaunchAtLoginChange = { [weak self] enabled in
7173
self?.setLaunchAtLogin(enabled)
7274
}
@@ -152,6 +154,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
152154
controller.setRestartEnabled(snapshot.state.isRunning)
153155
controller.setLifecycleControlsEnabled(true)
154156
}
157+
updateApplicationMenu()
155158
}
156159

157160
// MARK: - Actions
@@ -246,18 +249,49 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
246249
}
247250
}
248251

252+
private func installApplicationMenu() {
253+
NSApp.mainMenu = ApplicationMenuFactory.make(
254+
target: self,
255+
quitAction: #selector(quitMenuBar(_:)),
256+
stopAndQuitAction: #selector(stopOpenCodexAndQuit(_:))
257+
)
258+
updateApplicationMenu()
259+
}
260+
261+
private func updateApplicationMenu() {
262+
NSApp.mainMenu?.items.first?.submenu?.update()
263+
}
264+
265+
/// Safe default: close only the companion. The proxy is an independent process and
266+
/// remains available to connected clients.
267+
@objc private func quitMenuBar(_ sender: Any?) {
268+
NSApp.terminate(nil)
269+
}
270+
271+
public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
272+
if menuItem.action == #selector(stopOpenCodexAndQuit(_:)) {
273+
return LifecycleActionAvailability.canStopAndQuit(
274+
state: latest?.state,
275+
controlsAllowed: !lifecycleInFlight && !restartInFlight
276+
)
277+
}
278+
return true
279+
}
280+
249281
/// Finder launch is the app-level start contract. It uses the fixed TS helper and
250282
/// keeps the menu app alive even when startup fails, so Start remains available.
251283
private func ensureProxyOnLaunch() {
252284
guard !lifecycleInFlight, !restartInFlight else { return }
253285
lifecycleInFlight = true
286+
updateApplicationMenu()
254287
controller.setLifecycleControlsEnabled(false)
255288
Task { [actions, coordinator] in
256289
let outcome = await actions?.ensure() ?? .failed("Lifecycle control is unavailable.")
257290
await coordinator?.forceRefresh()
258291
await MainActor.run { [weak self] in
259292
guard let self else { return }
260293
self.lifecycleInFlight = false
294+
self.updateApplicationMenu()
261295
if case .failed(let message) = outcome {
262296
self.controller.showResult(message, isError: true)
263297
}
@@ -269,6 +303,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
269303
private func startProxy() {
270304
guard !lifecycleInFlight, !restartInFlight else { return }
271305
lifecycleInFlight = true
306+
updateApplicationMenu()
272307
controller.setLifecycleControlsEnabled(false)
273308
controller.showResult("Starting OpenCodex…", isError: false)
274309
Task { [actions, coordinator] in
@@ -277,6 +312,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
277312
await MainActor.run { [weak self] in
278313
guard let self else { return }
279314
self.lifecycleInFlight = false
315+
self.updateApplicationMenu()
280316
switch outcome {
281317
case .running:
282318
self.controller.showResult("OpenCodex started.", isError: false)
@@ -292,36 +328,42 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
292328

293329
private func stopProxy() {
294330
guard !lifecycleInFlight, !restartInFlight else { return }
331+
guard confirm(.stopProxy) else { return }
332+
performStop(quitWhenStopped: false)
333+
}
295334

296-
let alert = NSAlert()
297-
alert.messageText = "Stop the OpenCodex proxy?"
298-
alert.informativeText =
299-
"Active Codex, Claude, OpenCode, and subagent requests will be interrupted. The menu bar app will stay open."
300-
alert.alertStyle = .warning
301-
alert.addButton(withTitle: "Stop Proxy")
302-
alert.addButton(withTitle: "Cancel")
303-
304-
panel.isPresentingModal = true
305-
NSApp.activate(ignoringOtherApps: true)
306-
let confirmed = alert.runModal() == .alertFirstButtonReturn
307-
panel.isPresentingModal = false
308-
guard confirmed else {
309-
panel.makeKeyAndOrderFront(nil)
310-
return
311-
}
335+
/// Explicit destructive exit: stop routing first and terminate the companion only
336+
/// after the lifecycle helper confirms that the proxy and service are stopped.
337+
@objc private func stopOpenCodexAndQuit(_ sender: Any?) {
338+
guard !lifecycleInFlight, !restartInFlight else { return }
339+
guard confirm(.stopAndQuit) else { return }
340+
performStop(quitWhenStopped: true)
341+
}
312342

343+
private func performStop(quitWhenStopped: Bool) {
313344
lifecycleInFlight = true
345+
updateApplicationMenu()
314346
controller.setLifecycleControlsEnabled(false)
315347
controller.showResult("Stopping OpenCodex…", isError: false)
316348
Task { [actions, coordinator] in
317349
let outcome = await actions?.stop() ?? .failed("Lifecycle control is unavailable.")
318-
await coordinator?.forceRefresh()
350+
let shouldTerminate = quitWhenStopped
351+
&& StopAndQuitPolicy.shouldTerminate(after: outcome)
352+
if !shouldTerminate { await coordinator?.forceRefresh() }
319353
await MainActor.run { [weak self] in
320354
guard let self else { return }
321355
self.lifecycleInFlight = false
356+
self.updateApplicationMenu()
357+
if shouldTerminate {
358+
NSApp.terminate(nil)
359+
return
360+
}
322361
switch outcome {
323362
case .stopped:
324-
self.controller.showResult("Proxy stopped. The menu app is still running.", isError: false)
363+
self.controller.showResult(
364+
"Proxy stopped. The menu bar app is still open.",
365+
isError: false
366+
)
325367
case .running:
326368
self.controller.showResult("OpenCodex is still running.", isError: true)
327369
case .failed(let message):
@@ -336,26 +378,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
336378
/// reports success after ActionCoordinator confirms a replacement process.
337379
private func restartProxy() {
338380
guard !restartInFlight, !lifecycleInFlight else { return }
339-
340-
let alert = NSAlert()
341-
alert.messageText = "Restart OpenCodex?"
342-
alert.informativeText =
343-
"Active turns will drain, then OpenCodex will come back on the same port."
344-
alert.alertStyle = .warning
345-
alert.addButton(withTitle: "Restart")
346-
alert.addButton(withTitle: "Cancel")
347-
348-
panel.isPresentingModal = true
349-
NSApp.activate(ignoringOtherApps: true)
350-
let confirmed = alert.runModal() == .alertFirstButtonReturn
351-
panel.isPresentingModal = false
352-
353-
guard confirmed else {
354-
panel.makeKeyAndOrderFront(nil)
355-
return
356-
}
381+
guard confirm(.restartProxy) else { return }
357382

358383
restartInFlight = true
384+
updateApplicationMenu()
359385
controller.setRestartEnabled(false)
360386
controller.setLifecycleControlsEnabled(false)
361387
controller.showResult("Restart accepted…", isError: false)
@@ -365,6 +391,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
365391
await coordinator?.forceRefresh()
366392
await MainActor.run { [weak self] in
367393
self?.restartInFlight = false
394+
self?.updateApplicationMenu()
368395
switch outcome {
369396
case .restarted:
370397
self?.controller.showResult("OpenCodex restarted.", isError: false)
@@ -380,6 +407,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
380407
}
381408
}
382409
}
410+
411+
private func confirm(_ confirmation: LifecycleConfirmation) -> Bool {
412+
let alert = confirmation.makeAlert()
413+
panel.isPresentingModal = true
414+
NSApp.activate(ignoringOtherApps: true)
415+
let response = alert.runModal()
416+
panel.isPresentingModal = false
417+
if response != confirmation.confirmationResponse, panel.isShown {
418+
panel.makeKeyAndOrderFront(nil)
419+
}
420+
return response == confirmation.confirmationResponse
421+
}
383422
}
384423

385424
/// Pure deep-link helpers shared with tests.

0 commit comments

Comments
 (0)