nanda mochammad
Apple Developer Concepts

Building a macOS menu bar app with SwiftUI: a real SSH server monitor

12 min read

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.

Why menu bar apps fit this job

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.

Project setup

Create the project and add the SSH package

Start a fresh app and wire in the one dependency:

  1. File → New → Project → macOS → App. Set Product Name to ServerMonitor, interface SwiftUI, language Swift.
  2. Select the target, open the General tab, and set the Minimum Deployments target to macOS 26.0.
  3. File → Add Package Dependencies…, paste the Citadel Git URL https://github.com/orlandos-nl/Citadel.git, and add the Citadel library product to the ServerMonitor target.

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.

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.

SSH from Swift

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) ?? "—"
    }
}
Diagram: the SwiftUI view reads an @Observable model on the main actor, which awaits an SSH service running inside its own actor, which talks to the remote server over SSH. UI updates flow back the same path. MAIN ACTOR · UI SSH ACTOR · ISOLATED SWIFTUI MenuBarExtra popover + label @OBSERVABLE ServerMonitor state + poll Task ACTOR SSHService deep module REMOTE Server top · df · free reads await exec renders ServerStats @MainActor, no locks owns the connection plain shell A simple interface in front; all SSH, parsing, and reconnection complexity hidden behind the actor boundary.
The shape of the app: the SwiftUI view reads an @Observable model on the main actor, which awaits an actor-isolated SSH service. Every byte of network and connection state lives behind the actor's boundary.
Concurrency done right

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 }
}
Sequence diagram of one polling cycle: opening the menu starts a polling Task on the @Observable model, which awaits the SSH actor; the actor runs commands over SSH, returns parsed stats, the model publishes and the view updates, then the loop sleeps; closing the menu cancels the Task so no work happens while hidden. MenuBarExtra ServerMonitor SSHService menu opened → startPolling() await fetchStats() exec top · df · free (SSH) ServerStats (parsed) update @Observable state SwiftUI re-renders sleep 3s, then loop menu closed → task.cancel() loop ends, zero polling while hidden
One polling cycle: opening the menu starts a Task on the @MainActor model, which awaits the actor's snapshot (three SSH commands in parallel), then publishes stats back to the view.
The popover

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
        }
    }
}
Resilience

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.

State diagram of the connection: disconnected connects to connected; a fetch failure or timeout moves connected to stale; stale retries with backoff back to connecting; the menu bar label honestly reflects each state instead of pretending the data is fresh. Disconnected label: dimmed dot Connecting label: spinner Connected label: live CPU % Stale label: greyed + age connect ok timeout retry with backoff auth fails · give up Never show old numbers as if live.
The connection as an explicit state machine: disconnected → connecting → connected, with a fetch failure or timeout dropping to reconnecting and looping back, so the UI always reflects the real situation.

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
    }
}
Polish

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
ContentA list of controlsAny SwiftUI view
Charts / custom layoutNoYes
Feels likeA system menuA small popover app
Best forQuick actionsDashboards like this one

Where does each responsibility go?

Placing state & work

Network / SSH I/Ooff the main actor→ actor service
Shared stats the UI showsobservable→ @Observable @MainActor model
Expensive pollingcost control→ run only while the menu is open

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