Building a macOS menu bar app with SwiftUI: a real SSH server monitor
Hi, Mac developers. In this tutorial we build a small menu bar app in SwiftUI that watches a server over SSH, so you can check on it without opening a terminal.
By the end of this article, you will be able to build a menu bar app with MenuBarExtra, talk to a server over SSH from Swift, keep the connection safe behind an actor, show the connection state honestly in the UI, and stop polling when nobody is looking.
I wanted to see my server’s CPU without opening a terminal. So I built a tiny app that lives in my menu bar. It SSHes into the box every few seconds and shows me load, disk, and memory at a glance. It took an evening. Most of that evening went into doing the concurrency properly and being honest when the connection drops.
This is the build, end to end. It is also a good excuse to show how one well-placed actor keeps a whole 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, where you read status and rarely interact. It is always there, one click away, with no window to manage. MenuBarExtra first shipped in macOS 13, so you do not need AppKit for it. It is a SwiftUI scene, and the whole app is SwiftUI from top to bottom.
This tutorial targets Xcode 26.5+ and macOS 26 (Tahoe). There are two reasons for that floor. Building against the macOS 26 SDK gives you Liquid Glass on the popover and controls for free. It also 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 add 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 is everything you need before writing code. The macOS template already gives you a group named ServerMonitor with ServerMonitorApp.swift and ContentView.swift inside it. 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. 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 is your known-good starting point.
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, 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. 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 do not wrap the whole popover in anything.
Talking to the server with Citadel
There is 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. 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. The actor 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. Networking stays off the main actor. UI state stays on it. A clean await sits 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. 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. 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. It is 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. Plan for that. Model the connection as an explicit state machine, bound a hung command with a timeout, and 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. It calls the same fetchSnapshot() worker but races it against an eight-second timeout, so a dead socket cannot 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. First, add launch-at-login with SMAppService.mainApp.register() (from the ServiceManagement framework) in the App’s init(), so the monitor is always there. Second, stop polling when nobody is looking, which saves battery. With the .window style, the content view appears and disappears with the popover. So tie the poll loop to that lifecycle. Here is the entry point with both added. 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, all day, to draw 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
Recap
The goal was a small menu bar app that watches a server over SSH, built in a way that stays safe and honest. The finished app is maybe two hundred lines, but the lessons reach past menu bar apps:
MenuBarExtrawith the.windowstyle gives you a real SwiftUI popover, andLSUIElementkeeps the app out of the Dock.- Citadel talks SSH from pure Swift, so there is no need to shell out to the
sshbinary. - Put the mutable, shared, dangerous state (the SSH connection) behind an
actor. Keep the UI a thin@Observableobserver on the main actor. - Model the connection as a state machine, bound every fetch with a timeout, and show stale data as stale.
- Tie expensive polling to visibility. Start it when the popover opens and stop it when it closes.
The compiler checks the actor boundary for you, so most concurrency bugs never compile.
I hope this writing helps you. Thanks for reading.
Cited sources