From ad82aade4acd5e23c0143a873c55f7681431eaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9Fcan=20Aslan?= Date: Sun, 5 Jul 2026 10:30:23 +0300 Subject: [PATCH] feat(app): keep Capsule resident in the menu bar until explicit quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the window no longer terminates Capsule. A CapsuleAppDelegate returns applicationShouldTerminateAfterLastWindowClosed = false, so the app stays alive behind a new MenuBarExtra and quits only on an explicit terminate (menu bar Quit, app-menu ⌘Q, or the language relaunch). The menu bar item uses Capsule's own mark as a monochrome template glyph (MenuBarIcon) and offers a basic, sensible set of actions: Open Capsule, a live Running Containers section (click to reveal/select in the window), the runtime status with Start/Stop Services, Settings, Check for Updates, and Quit Capsule. Also converts the main scene from WindowGroup to a single-instance Window so openWindow(id:) focuses the existing window instead of spawning duplicates when reopening from the menu bar. New user-facing strings are localized into zh-Hans, ja, es, fr, tr. Co-Authored-By: Claude Opus 4.8 --- .../MenuBarIcon.imageset/Contents.json | 26 +++ .../MenuBarIcon.imageset/menubar-icon.png | Bin 0 -> 291 bytes .../MenuBarIcon.imageset/menubar-icon@2x.png | Bin 0 -> 427 bytes .../MenuBarIcon.imageset/menubar-icon@3x.png | Bin 0 -> 372 bytes App/Resources/Localizable.xcstrings | 170 ++++++++++++++++++ App/Sources/CapsuleMain.swift | 5 + Sources/CapsuleApp/CapsuleAppDelegate.swift | 36 ++++ Sources/CapsuleApp/CapsuleMenuBar.swift | 91 ++++++++++ Sources/CapsuleApp/CapsuleScene.swift | 20 ++- .../CapsuleAppDelegateTests.swift | 23 +++ 10 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 App/Assets.xcassets/MenuBarIcon.imageset/Contents.json create mode 100644 App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon.png create mode 100644 App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@2x.png create mode 100644 App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@3x.png create mode 100644 Sources/CapsuleApp/CapsuleAppDelegate.swift create mode 100644 Sources/CapsuleApp/CapsuleMenuBar.swift create mode 100644 Tests/CapsuleUnitTests/CapsuleAppDelegateTests.swift diff --git a/App/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/App/Assets.xcassets/MenuBarIcon.imageset/Contents.json new file mode 100644 index 0000000..3438cb3 --- /dev/null +++ b/App/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "menubar-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "menubar-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "menubar-icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon.png b/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..920bf1d303be0f1a3a91de8d90529a4e7e51ea82 GIT binary patch literal 291 zcmeAS@N?(olHy`uVBq!ia0vp^B0wy_!3HG7B;uuk6k~CayA#8@b22Z19E;Ql&op0O z1}z|)gMqOnc$`9hFzXv&2OBT7rd)cc&2u7%6f;d!i_(ecI~TnV2n_h>;*ESXvu*i8jB()l&~}2 zoKhiqQbWsVqH^o2lWEcy7A|pP^Re~NXItQP&Dm_~t5)Vs4^uWXZuiL)xhC1M5#(4; LS3j3^P6;8c2FBtbcPEB*=VV?2ITooAo@u_m z3|c@o2Loe!CIbsd2@p#GF#`kB0w%bK#sX$ITLC1Q8OI+ERIKRf;usRa`8L9lkI_*e zM(>VIV+C{mq>65Si;5<53yV)|4b32N*!KV3f#rWq z4_<0-D7)lbwv^>2=Z)W!_$KUNe6{?{t>tQ4qjOt%UMI!;xstb)+bD+RT45IV=6mr@ zZ=~I|3;yy3E_h#&sb1<)_sK{&BWk8vFW-)D377h#J#9B+72i?Hs6AfrF*tgL*`p`* zCUcJpJ+k7>eH*sDBYJYc=WBky*7I*%@@7lP15W#llv}&1)%Sq{&C}J-Wt~$(69ALI Bo*n=I literal 0 HcmV?d00001 diff --git a/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@3x.png b/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..30f30444fdd8ad6ad4f48bae2ce101010e98b1eb GIT binary patch literal 372 zcmeAS@N?(olHy`uVBq!ia0vp^Hb88^!3HFK?IbyY6k~CayA#8@b22Z19E;Ql&op0O z1}z|)gMqOiVOIn1;tKayDIeDuZ=;5AT^8Hch7S*!fQT(}x-xWE-np{`BlV`qyOdCAHp5 zzb;oES-+dj@{;P=VD~L<^|$WYPtDw`beCEB<>YUlV!YnJyHdVU&gT7}7qXA?rXI+C z`X~K<;DPnR>~>4s*&z yIBGX1;70pomx$eqmfl! Bool { + false + } +} diff --git a/Sources/CapsuleApp/CapsuleMenuBar.swift b/Sources/CapsuleApp/CapsuleMenuBar.swift new file mode 100644 index 0000000..23453b2 --- /dev/null +++ b/Sources/CapsuleApp/CapsuleMenuBar.swift @@ -0,0 +1,91 @@ +// +// CapsuleMenuBar.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The menu attached to Capsule's status-bar item. Because the app stays resident after its +// window closes (see `CapsuleAppDelegate`), this menu is the always-available way back in: +// reopen the window, glance at the running containers, watch / toggle the runtime, reach +// Settings and updates, or quit for real. It lives in `CapsuleApp` — like `CapsuleCommands` +// — because it drives app-lifecycle concerns (`openWindow`, `NSApp`) and the composition +// root's models, which the layered UI module must not own. + +import AppKit +import CapsuleDomain +import CapsuleUI +import SwiftUI + +/// The contents of Capsule's `MenuBarExtra`. Intentionally minimal: a way back to the window, +/// the currently running containers, the runtime's status with a start/stop toggle, Settings, +/// updates, and Quit. +struct CapsuleMenuBarContent: View { + let shell: ShellState + let browserModel: ContainerBrowserModel + let systemModel: SystemStatusModel + let actions: ShellActions + let updater: any UpdaterController + @Environment(\.openWindow) private var openWindow + @Environment(\.openSettings) private var openSettings + + var body: some View { + Button("Open Capsule") { openMainWindow() } + + Divider() + + Section("Running Containers") { + if runningContainers.isEmpty { + Text("No Running Containers") + } else { + ForEach(runningContainers) { container in + Button(container.name) { reveal(container) } + } + } + } + + Divider() + + // Disabled row: a live glance at whether the container runtime is up. + Text("Services: \(String(localized: systemModel.health.localizedStatusLabel))") + Button("Start Services") { actions.recover(.startServices) } + .disabled(systemModel.health.isRunning) + Button("Stop Services") { actions.stopServices() } + .disabled(!systemModel.health.isRunning) + + Divider() + + Button("Settings…") { + NSApp.activate() + openSettings() + } + Button("Check for Updates…") { updater.checkForUpdates() } + .disabled(!updater.canCheckForUpdates) + + Divider() + + Button("Quit Capsule") { NSApp.terminate(nil) } + .keyboardShortcut("q") + } + + /// The running containers, name-sorted — independent of whatever search/filter the window + /// happens to have applied (this reads `allContainers`, not the filtered `rows`). + private var runningContainers: [Container] { + browserModel.allContainers + .filter { $0.state == .running } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + /// Brings the window forward and reveals the given container in the Containers surface. + private func reveal(_ container: Container) { + openMainWindow() + shell.selection = .containers + browserModel.selection = [container.id] + } + + /// Brings the main window back (re-creating it if it was closed) and pulls Capsule to the + /// front — a status-item click does not activate the owning app on its own. + private func openMainWindow() { + openWindow(id: WindowManagement.mainWindowID) + NSApp.activate() + } +} diff --git a/Sources/CapsuleApp/CapsuleScene.swift b/Sources/CapsuleApp/CapsuleScene.swift index 83d8fa8..7faf6dd 100644 --- a/Sources/CapsuleApp/CapsuleScene.swift +++ b/Sources/CapsuleApp/CapsuleScene.swift @@ -110,7 +110,10 @@ public struct CapsuleScene: Scene { } public var body: some Scene { - WindowGroup(id: WindowManagement.mainWindowID) { + // A single `Window` (not a `WindowGroup`): Capsule has one primary surface, so + // `openWindow(id:)` from the menu bar must focus the existing window rather than spawn + // a new one each time (a `WindowGroup` is multi-window and would stack duplicates). + Window("Capsule", id: WindowManagement.mainWindowID) { RootView( shell: shell, systemModel: systemModel, @@ -169,5 +172,20 @@ public struct CapsuleScene: Scene { ) .capsuleAppearance() } + + // Keeps Capsule reachable after the window closes: the app stays resident (see + // `CapsuleAppDelegate`) and lives here in the menu bar until the user quits. "Capsule" + // is the app name — left untranslated — and doubles as the status item's accessibility + // label. `MenuBarIcon` is Capsule's own mark (the three stacked bars) as a monochrome + // template glyph, so the menu bar tints it for the active light/dark appearance. + MenuBarExtra("Capsule", image: "MenuBarIcon") { + CapsuleMenuBarContent( + shell: shell, + browserModel: browserModel, + systemModel: systemModel, + actions: actions, + updater: updater + ) + } } } diff --git a/Tests/CapsuleUnitTests/CapsuleAppDelegateTests.swift b/Tests/CapsuleUnitTests/CapsuleAppDelegateTests.swift new file mode 100644 index 0000000..797a7fb --- /dev/null +++ b/Tests/CapsuleUnitTests/CapsuleAppDelegateTests.swift @@ -0,0 +1,23 @@ +// +// CapsuleAppDelegateTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// + +import AppKit +import XCTest + +@testable import CapsuleApp + +final class CapsuleAppDelegateTests: XCTestCase { + /// The whole point of the delegate: closing the last window must NOT quit Capsule, so it + /// can stay resident behind its menu bar extra until the user explicitly quits. + @MainActor + func testDoesNotTerminateAfterLastWindowClosed() { + let delegate = CapsuleAppDelegate() + + XCTAssertFalse( + delegate.applicationShouldTerminateAfterLastWindowClosed(NSApplication.shared)) + } +}