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 0000000..920bf1d Binary files /dev/null and b/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon.png differ diff --git a/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@2x.png b/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@2x.png new file mode 100644 index 0000000..617a0dc Binary files /dev/null and b/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@2x.png differ 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 0000000..30f3044 Binary files /dev/null and b/App/Assets.xcassets/MenuBarIcon.imageset/menubar-icon@3x.png differ diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index ace7693..2fc036c 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -10303,6 +10303,40 @@ } } }, + "No Running Containers": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有正在运行的容器" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中のコンテナなし" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay contenedores en ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun conteneur en cours d'exécution" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışan Konteyner Yok" + } + } + } + }, "No Selection": { "localizations": { "zh-Hans": { @@ -10813,6 +10847,40 @@ } } }, + "Open Capsule": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 Capsule" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Capsuleを開く" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Capsule" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir Capsule" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Capsule'ü Aç" + } + } + } + }, "Open in Terminal": { "localizations": { "zh-Hans": { @@ -11935,6 +12003,40 @@ } } }, + "Quit Capsule": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出 Capsule" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Capsuleを終了" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Salir de Capsule" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quitter Capsule" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Capsule'den Çık" + } + } + } + }, "Raw JSON": { "localizations": { "zh-Hans": { @@ -13567,6 +13669,40 @@ } } }, + "Running Containers": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行中的容器" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中のコンテナ" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contenedores en ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conteneurs en cours d'exécution" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışan Konteynerler" + } + } + } + }, "Save": { "localizations": { "zh-Hans": { @@ -15301,6 +15437,40 @@ } } }, + "Services: %@": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "服务:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サービス: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Servicios: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Services : %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hizmetler: %@" + } + } + } + }, "Session log": { "localizations": { "zh-Hans": { diff --git a/App/Sources/CapsuleMain.swift b/App/Sources/CapsuleMain.swift index 021e372..ab251ff 100644 --- a/App/Sources/CapsuleMain.swift +++ b/App/Sources/CapsuleMain.swift @@ -12,6 +12,11 @@ import SwiftUI @main struct CapsuleMain: App { + // Keeps Capsule resident in the menu bar after its last window closes (the app quits only + // on an explicit terminate). The policy itself lives in `CapsuleAppDelegate`, in the + // `CapsuleApp` module, so this shim stays logic-free. + @NSApplicationDelegateAdaptor(CapsuleAppDelegate.self) private var appDelegate + init() { // Install the automation service before any scene renders, so an App Intent or // AppleScript command invoked during a headless Shortcuts/Siri launch resolves it. diff --git a/Sources/CapsuleApp/CapsuleAppDelegate.swift b/Sources/CapsuleApp/CapsuleAppDelegate.swift new file mode 100644 index 0000000..115767b --- /dev/null +++ b/Sources/CapsuleApp/CapsuleAppDelegate.swift @@ -0,0 +1,36 @@ +// +// CapsuleAppDelegate.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The application delegate — deliberately tiny. It exists for one policy decision that +// SwiftUI's `App` type cannot express declaratively: what happens when the user closes the +// last window. Lives in `CapsuleApp` (not the `@main` shim) so it stays testable; the shim +// only attaches it via `@NSApplicationDelegateAdaptor`. + +import AppKit + +/// Owns Capsule's "stay resident in the menu bar" lifecycle policy. +/// +/// By default a SwiftUI app terminates once its last window closes. Capsule instead keeps +/// running behind its `MenuBarExtra` (see `CapsuleMenuBarContent`) so the container runtime +/// stays reachable; the app quits only on an *explicit* terminate — the menu bar's "Quit +/// Capsule", the standard app-menu ⌘Q, or the language-relaunch flow in +/// `GeneralSettingsView`. None of those go through the last-window-closed heuristic, so they +/// all keep working. +/// +/// Reopening after a close is left to SwiftUI's default reopen handling: `openWindow(id:)` +/// from the menu bar re-creates the main window, and a Dock-icon click re-creates it too. If +/// a future macOS ever regresses that, add +/// `applicationShouldHandleReopen(_:hasVisibleWindows:)` returning `true` here. +@MainActor +public final class CapsuleAppDelegate: NSObject, NSApplicationDelegate { + public override init() { + super.init() + } + + public func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> 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)) + } +}