Building a macOS menu bar app with SwiftUI: a real SSH server monitor
I wanted to see my server’s CPU without opening a terminal. So I built a tiny app that lives in my menu bar, SSHes into the box every few seconds, and shows me load, disk, and memory at a glance. It took an evening, and most of that evening was spent on the two things tutorials skip: doing the concurrency properly, and being honest when the connection drops.
This is the build, end to end, and a good excuse to show how a single well-placed actor keeps an entire category of bugs out of your app.
Always visible, one click away, and now pure SwiftUI
A menu bar app is the right shape for anything you want to glance at: status, not interaction. It’s always there, one click away, with no window to manage. MenuBarExtra first shipped in macOS 13, so you don’t need AppKit for it. It’s a SwiftUI scene, and the whole thing is SwiftUI from top to bottom.
This tutorial targets Xcode 26.5+ and macOS 26 (Tahoe). Two reasons for the floor: building against the macOS 26 SDK gives you Liquid Glass on the popover and controls for free, and it keeps the code on the current concurrency and @Observable model. The popover content here uses a little Liquid Glass directly, which also needs the macOS 26 SDK.
Create the project and add the SSH package
Start a fresh app and wire in the one dependency:
- File → New → Project → macOS → App. Set Product Name to
ServerMonitor, interface SwiftUI, language Swift. - Select the target, open the General tab, and set the Minimum Deployments target to macOS 26.0.
- File → Add Package Dependencies…, paste the Citadel Git URL
https://github.com/orlandos-nl/Citadel.git, and add theCitadellibrary product to theServerMonitortarget.
That’s everything you need before writing code. The macOS template already gives you a group named ServerMonitor with ServerMonitorApp.swift and ContentView.swift inside it, and every new file in this article goes in that same group. To add one, right-click the ServerMonitor group → New File from Template… → Swift File, and name it exactly as the block’s header comment says (MonitorTypes.swift, ServerMonitor.swift, MonitorModel.swift, DashboardView.swift, ConnectionState.swift).
Check: press ⌘B. The untouched macOS template, with the Citadel package resolved, should build clean before you change a line. That’s your known-good starting line.
MenuBarExtra, and hiding the Dock icon
The macOS template generated a ServerMonitorApp.swift (and a ContentView.swift) for you. Open ServerMonitorApp.swift and replace its whole contents with this. Choose the .window style when you want real UI (a dashboard) rather than a plain menu of buttons:
// ServerMonitor/ServerMonitorApp.swift
@main
struct ServerMonitorApp: App {
@State private var model = MonitorModel()
var body: some Scene {
MenuBarExtra("Server", systemImage: "server.rack") {
DashboardView()
.environment(model)
}
.menuBarExtraStyle(.window)
}
}
Then delete the generated ContentView.swift (right-click it → Delete → Move to Trash). DashboardView, which you build later, replaces it.
To make it a true menu bar utility with no Dock icon and no main window, you set the LSUIElement flag. Modern Xcode App templates ship without an Info.plist file, so set it on the target instead: select the project in the navigator → the ServerMonitor target → the Info tab → hover any row → click + → type Application is agent (UIElement) → set its value to YES. Now the app lives only in the menu bar.
MenuBarExtra itself has existed since macOS 13, so the scene API is nothing new. What’s new here is the floor: this build targets macOS 26, and once you build against the macOS 26 SDK the popover and its controls render with Liquid Glass automatically. You don’t wrap the whole popover in anything.
Talking to the server with Citadel
There’s no need to shell out to the ssh binary: Citadel is a pure-Swift SSH client. The important design decision is where the connection lives. An SSH connection is mutable, long-lived, shared state, precisely the thing that causes data races if a dozen UI updates touch it at once. First, the small supporting types the rest of the code refers to. Create a new file for them:
// ServerMonitor/MonitorTypes.swift
import Foundation
enum ConnectionState {
case disconnected, connecting, connected, reconnecting
}
struct ServerStats {
let load: String
let disk: String
let memory: String
}
enum MonitorError: Error {
case notConnected, timedOut
}
Now the SSH connection itself goes inside an actor, which serialises every access for free:
// ServerMonitor/ServerMonitor.swift
import Citadel
actor ServerMonitor {
private var client: SSHClient?
func connect(host: String, user: String, key: String) async throws {
client = try await SSHClient.connect(
host: host,
authenticationMethod: .rsa(privateKey: key),
hostKeyValidator: .acceptAnything(), // use a real validator in production
reconnect: .never
)
}
// The public entry the UI calls. We harden it with a timeout in the
// Resilience section below; for now it just calls the worker.
func snapshot() async throws -> ServerStats {
try await fetchSnapshot()
}
// The actual fetch — three commands in parallel, parsed into one snapshot.
private func fetchSnapshot() async throws -> ServerStats {
guard let client else { throw MonitorError.notConnected }
async let load = run(client, "uptime")
async let disk = run(client, "df -h /")
async let mem = run(client, "free -m")
return ServerStats(load: parseLoad(try await load),
disk: parseDisk(try await disk),
memory: parseMem(try await mem))
}
// Run one command over SSH and return its stdout as text.
private func run(_ client: SSHClient, _ command: String) async throws -> String {
let buffer = try await client.executeCommand(command)
return String(buffer: buffer)
}
// Minimal parsers — adapt the slicing to your server's exact output.
private func parseLoad(_ raw: String) -> String {
raw.components(separatedBy: "load average:").last?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "—"
}
private func parseDisk(_ raw: String) -> String {
raw.split(separator: "\n").last?
.split(separator: " ", omittingEmptySubsequences: true)
.dropLast().last.map(String.init) ?? "—"
}
private func parseMem(_ raw: String) -> String {
raw.split(separator: "\n").first { $0.hasPrefix("Mem:") }?
.split(separator: " ", omittingEmptySubsequences: true).last.map(String.init) ?? "—"
}
}
An actor for the connection, @Observable for the UI
The UI model lives on the main actor and is @Observable; it owns no SSH state at all. It only awaits the actor and publishes the result. That split is the key idea: networking off the main actor, UI state on it, a clean await between them.
// ServerMonitor/MonitorModel.swift
@MainActor @Observable
final class MonitorModel {
var stats: ServerStats?
var connection: ConnectionState = .disconnected
private let monitor = ServerMonitor()
private var pollTask: Task<Void, Never>?
func startPolling() {
guard pollTask == nil else { return }
pollTask = Task {
while !Task.isCancelled {
do {
stats = try await monitor.snapshot()
connection = .connected
} catch {
connection = .reconnecting
}
try? await Task.sleep(for: .seconds(5))
}
}
}
func stopPolling() { pollTask?.cancel(); pollTask = nil }
}
DashboardView: reading the model, with a touch of glass
The view is a thin observer. It reads the @Observable model from the environment, switches on the connection state, and lays the stats out as cards. The popover chrome itself is already Liquid Glass on macOS 26, and you get that for free from the SDK. So the only glass you add by hand is on the custom stat cards and the reconnect control floating above the content.
Group the cards in a GlassEffectContainer so their glass blends as a single surface, and give each card its own .glassEffect(). The reconnect button uses .buttonStyle(.glass).
// ServerMonitor/DashboardView.swift
import SwiftUI
struct DashboardView: View {
@Environment(MonitorModel.self) private var model
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if let stats = model.stats {
GlassEffectContainer(spacing: 12) {
HStack(spacing: 12) {
StatCard(label: "Load", value: stats.load, tint: .blue)
StatCard(label: "Memory", value: stats.memory, tint: .teal)
}
}
.opacity(model.connection == .connected ? 1 : 0.5)
} else {
Text("Waiting for first reading…")
.foregroundStyle(.secondary)
.font(.callout)
}
Button("Reconnect", systemImage: "arrow.clockwise") {
model.stopPolling()
model.startPolling()
}
.buttonStyle(.glass)
}
.padding(16)
.frame(width: 280)
}
private var header: some View {
HStack(spacing: 8) {
Circle()
.fill(model.connection.color)
.frame(width: 9, height: 9)
Text(model.connection.label)
.font(.headline)
Spacer()
}
}
}
// One stat, rendered as a glass card above the popover content.
private struct StatCard: View {
let label: String
let value: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label.uppercased())
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.title3.monospacedDigit())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.glassEffect(.regular.tint(tint.opacity(0.18)), in: .rect(cornerRadius: 12))
}
}
The card’s .glassEffect(.regular.tint(…), in: .rect(cornerRadius: 12)) does the work here: a regular glass material, lightly tinted per stat, clipped to a rounded rectangle. The GlassEffectContainer around the row lets the two cards’ glass merge where they meet instead of reading as two separate panes.
This assumes the model exposes display strings and a couple of helpers on ConnectionState for the header dot:
// ServerMonitor/ConnectionState.swift
import SwiftUI
extension ConnectionState {
var label: String {
switch self {
case .disconnected: "Disconnected"
case .connecting: "Connecting…"
case .connected: "Connected"
case .reconnecting: "Reconnecting…"
}
}
var color: Color {
switch self {
case .connected: .green
case .reconnecting,
.connecting: .yellow
case .disconnected: .secondary
}
}
}
Reconnect, time out, and never lie to the user
Servers go away. Wi-Fi drops. The difference between a toy and a tool is what happens then. Model the connection as an explicit state machine, bound a hung command with a timeout, and, most importantly, show stale data as stale instead of presenting an hour-old number as current.
Now upgrade the placeholder snapshot() from earlier: replace it with this version, which calls the same fetchSnapshot() worker but races it against an eight-second timeout, so a dead socket can’t hang the UI.
// ServerMonitor/ServerMonitor.swift — replace the earlier snapshot()
// Bound every fetch so a dead socket can't hang the UI forever
func snapshot() async throws -> ServerStats {
try await withThrowingTaskGroup(of: ServerStats.self) { group in
group.addTask { try await self.fetchSnapshot() }
group.addTask { try await Task.sleep(for: .seconds(8)); throw MonitorError.timedOut }
let first = try await group.next()!
group.cancelAll()
return first
}
}
Launch at login, and don’t poll into the void
Two finishing touches. Add launch-at-login with SMAppService.mainApp.register() (from the ServiceManagement framework) in the App’s init(), so the monitor is always there. And the one that matters for your battery: stop polling when nobody’s looking. With the .window style, the content view appears and disappears with the popover, so gate the poll loop on its lifecycle. Here’s the entry point with both folded in. Open ServerMonitorApp.swift and replace it with this final version:
// ServerMonitor/ServerMonitorApp.swift — final version
import SwiftUI
import ServiceManagement
@main
struct ServerMonitorApp: App {
@State private var model = MonitorModel()
init() {
try? SMAppService.mainApp.register() // launch at login
}
var body: some Scene {
MenuBarExtra("Server", systemImage: "server.rack") {
DashboardView()
.environment(model)
.onAppear { model.startPolling() } // menu opened
.onDisappear { model.stopPolling() } // menu closed — stop the SSH chatter
}
.menuBarExtraStyle(.window)
}
}
Polling a server every five seconds, 24/7, to render a popover nobody has open is wasteful. Tie the work to visibility and the app costs almost nothing when idle.
| MenuBarExtra style | .menu | .window |
|---|---|---|
| Content | A list of controls | Any SwiftUI view |
| Charts / custom layout | No | Yes |
| Feels like | A system menu | A small popover app |
| Best for | Quick actions | Dashboards like this one |
Where does each responsibility go?
Placing state & work
The finished app is maybe two hundred lines, but the lesson scales past menu bar apps: push your mutable, shared, dangerous state behind an actor, keep the UI a thin observer of it, and tie expensive work to visibility. Do that and concurrency stops being scary, because the compiler is on your side.
Cited sources