Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions App/Assets.xcassets/MenuBarIcon.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
170 changes: 170 additions & 0 deletions App/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions App/Sources/CapsuleMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions Sources/CapsuleApp/CapsuleAppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
91 changes: 91 additions & 0 deletions Sources/CapsuleApp/CapsuleMenuBar.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading