11import AppKit
22import 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