Hey there! Today I’m going to walk you through creating a practical macOS utility app that leverages the power of command line tools. If you’ve ever wanted to build your own menu bar utility that gives users helpful system information, this tutorial is for you.
In this guide, I’ll show you how to build a Disk Analyzer app that sits in your menu bar, analyzes your disk space usage, and presents the information in a beautiful, user-friendly interface. We’ll combine SwiftUI’s modern interface capabilities with traditional Unix command line tools that have been part of macOS for decades.
What Are Command Line Tools?
Before we dive into building our app, let me explain what command line tools are. If you’ve ever opened Terminal on your Mac, you’ve interacted with command line tools. These are powerful utilities that come built into macOS but don’t have a graphical interface. Instead, they’re typically run by typing commands into Terminal.
The df
command (which stands for “disk free”) is a utility that shows you how much disk space is available on your Mac. When we run it with the -h
flag like this:
df -h
When you add the -h, it shows the disk usage in human readable format. These tools are incredibly powerful but not very user-friendly for most people.
Here’s what happens when you run df
in Terminal:

The output is shown in a table style where the first line is the header. The morst important information is seen in “/Syste/Volumes/Data” line. The line meands that the size of the disk is 926GB of which 467GB is used.
Not very pretty, right? But this is where we come in. As developers, we can create beautiful SwiftUI apps that use these command line tools behind the scenes while presenting the information in a way that’s easy to understand.
What We’ll Build
In this tutorial, I’ll show you how to:
- Access command line tools from your SwiftUI app
- Create a disk space analyzer using the
df
command - Present the information in a modern, user-friendly interface
The final app will look something like this:

Getting Started: The App Structure
Let’s start by understanding the basic structure of our menu bar app. Unlike standard apps that open in a window, menu bar apps live in the system menu bar at the top of your screen.
Here’s how we set up the basic app structure:
import SwiftUI
@main
struct DiskAnalyserApp: App {
var body: some Scene {
MenuBarExtra("Disk Analyzer", systemImage: "externaldrive.connected.to.line.below.fill") {
ContentView()
.frame(width: 400)
}
.menuBarExtraStyle(.window)
}
}
This code creates a menu bar app with a disk icon. When clicked, it displays our ContentView
in a small window with a fixed width of 400 points. The .menuBarExtraStyle(.window)
modifier tells SwiftUI to display our content in a window-style dropdown rather than a traditional menu.
I prefer this approach for utility apps because it gives users a familiar interface while keeping the app unobtrusive. The app doesn’t take up space in the Dock or App Switcher but is always just a click away in the menu bar.
Creating the Main Interface
Now let’s look at our main interface. This will be the content that appears when a user clicks on our menu bar icon.
import SwiftUI
struct ContentView: View {
@StateObject private var fetcher = DiskInfoFetcher()
var body: some View {
LazyVStack(alignment: .leading, spacing: 20) {
Text("Developer Disk Analyzer")
.font(.title3)
.bold()
DiskInfoList(diskInfoFetcher: fetcher)
DiskInfoChart(diskInfoFetcher: fetcher)
}
.padding(20)
.onAppear {
fetcher.loadDiskInfo()
}
}
}
Our ContentView
is straightforward. It creates a DiskInfoFetcher
object (we’ll explore this in detail later), displays a title, and then shows two custom views: DiskInfoList
and DiskInfoChart
. When the view appears, it calls loadDiskInfo()
to fetch the latest disk information.
I’m using a LazyVStack
here because it only renders the views when they’re needed, which can improve performance for complex UIs. For our small app, a regular VStack
would work fine too, but I like to build with scalability in mind.
The Heart of the App: Accessing Command Line Tools
Now let’s dive into the most interesting part of our app: using command line tools to gather system information. This is where macOS development offers unique opportunities that aren’t available on iOS.
The DiskInfoFetcher
class handles all the command line interactions:
class DiskInfoFetcher: ObservableObject {
enum CommandError: Error {
case commandFailed(String)
case parsingFailed
case invalidData
case emptyOutput
}
@Published private(set) var diskInfos = [FormattedDiskInfo]()
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@MainActor
func loadDiskInfo() {
isLoading = true
Task {
do {
diskInfos = try await getDiskInfo()
isLoading = false
} catch {
self.error = error
isLoading = false
}
}
}
// More code follows...
}
This class uses the ObservableObject
protocol so our SwiftUI views can react to changes in the data. It publishes three properties:
diskInfos
: The formatted disk informationisLoading
: A boolean indicating if we’re currently fetching dataerror
: Any error that occurred during the fetch
The loadDiskInfo()
method is marked with @MainActor
to ensure UI updates happen on the main thread. It uses Swift’s modern concurrency system with async
/await
to fetch disk information without blocking the UI.
Let’s look at how we actually execute command line tools:
private func getDiskInfo() async throws -> [FormattedDiskInfo] {
try await Task.detached(priority: .userInitiated) {
let output = try self.execute("df -k -P") // in kilo bytes
print(output)
let disks = try self.parseDfOutput(output)
let formattedDisks = self.formateVolumes(for: disks)
return formattedDisks
}.value
}
private func execute(_ command: String) throws -> String {
let process = Process()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.arguments = ["-c", command]
process.executableURL = URL(fileURLWithPath: "/bin/bash")
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let output = String(data: data, encoding: .utf8) else {
throw CommandError.invalidData
}
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw CommandError.commandFailed(output)
}
return output
}
The getDiskInfo()
method runs in a detached task to avoid blocking the UI thread. It calls the Unix df
command, which stands for “disk free” and reports file system disk space usage.
In the execute()
method, I’m using the Process
and Pipe
classes from Foundation to run shell commands and capture their output. This is a powerful technique that lets you leverage all the command line tools built into macOS.
One important thing to note: I’m running the command through /bin/bash
with the -c
flag, which allows us to execute complex shell commands. For security reasons, you should be careful about what commands you run, especially if they include user input.
The output is a text file that looks something like:

Parsing Command Line Output
After executing the command, we need to parse its output into a usable format:
private func parseDfOutput(_ output: String) throws -> [DiskInfo] {
let lines = output.components(separatedBy: .newlines)
guard lines.count > 1 else { throw CommandError.emptyOutput }
// Skip header line
let dataLines = lines.dropFirst()
return dataLines.compactMap { line -> DiskInfo? in
let components = line.split(separator: " ", omittingEmptySubsequences: true)
guard components.count >= 5 else { return nil }
return DiskInfo(
filesystem: String(components[0]),
size: Int64(components[1]) ?? 0, // Now directly in KB
used: Int64(components[2]) ?? 0,
available: Int64(components[3]) ?? 0,
capacity: Int(components[4].replacingOccurrences(of: "%", with: "")) ?? 0,
mountPoint: components[5...].joined(separator: " ")
)
}
}
The df
command outputs a table of information. The first line is a header, and subsequent lines contain data about each filesystem. We split the output into lines, skip the header, and then parse each line into a DiskInfo
object.
This parsing logic is specific to the df
command’s output format. If you’re working with a different command, you’ll need to adjust the parsing logic accordingly.
After parsing the raw data, we format it for display:
private func formateVolumes(for infos: [DiskInfo]) -> [FormattedDiskInfo] {
var results = [FormattedDiskInfo]()
let total = infos.systemVolume?.size ?? 0
let system = infos.systemVolume?.used ?? 0
results.append(
FormattedDiskInfo(title: "System",
size: system,
totalSize: total)
)
let available = infos.systemVolume?.available ?? 0
results.append(
FormattedDiskInfo(title: "Available",
size: available,
totalSize: total)
)
let userData = infos.dataVolume?.used ?? 0
results.append(
FormattedDiskInfo(title: "User Data",
size: userData,
totalSize: total)
)
return results
}
This method creates a simplified representation of the disk usage that’s easier for users to understand. Instead of showing every filesystem, we focus on the three categories most users care about: System, Available space, and User Data.
Data Models
Let’s take a quick look at our data models. First, the raw DiskInfo
structure that closely matches the output of the df
command:
struct DiskInfo: Identifiable {
let filesystem: String
let size: Int64
let used: Int64
let available: Int64
let capacity: Int
let mountPoint: String
var id: String { self.mountPoint }
var isSystemVolume: Bool {
mountPoint == "/"
}
var isDataVolume: Bool {
mountPoint == "/System/Volumes/Data"
}
}
// MARK: - Analysis Extension
extension Array where Element == DiskInfo {
var systemVolume: DiskInfo? {
first { $0.isSystemVolume }
}
var dataVolume: DiskInfo? {
first { $0.isDataVolume }
}
}
And then our user-friendly FormattedDiskInfo
structure:
struct FormattedDiskInfo: Identifiable {
let id = UUID()
let title: String
let size: Int64
let totalSize: Int64
var percentage: Double {
Double(size) / Double(totalSize)
}
var formattedSize: String {
ByteCountFormatter.string(fromByteCount: size * 1024, countStyle: .file)
}
var formattedTotalSize: String {
ByteCountFormatter.string(fromByteCount: totalSize * 1024, countStyle: .file)
}
static var example: FormattedDiskInfo {
FormattedDiskInfo(title: "System",
size: 11 * 1024,
totalSize: 924 * 1024)
}
}
I’ve added computed properties to calculate percentages and format byte counts into human-readable strings. The ByteCountFormatter
is a great Foundation class that handles the complexity of displaying file sizes in appropriate units (KB, MB, GB, etc.).
Building the UI Components for macOS with SwiftUI
Now let’s look at how we display this information. First, the list view:
struct DiskInfoList: View {
@ObservedObject var diskInfoFetcher: DiskInfoFetcher
var body: some View {
GroupBox {
VStack(alignment: .leading, spacing: 8) {
if diskInfoFetcher.isLoading {
ProgressView("Analyzing disk space...")
.padding()
.frame(maxWidth: .infinity)
} else if let error = diskInfoFetcher.error {
Text("Error: (error.localizedDescription)")
.foregroundStyle(.pink)
.padding()
} else {
if let disk = diskInfoFetcher.diskInfos.first {
HStack {
Text("Your total disk space:")
Spacer()
Text(disk.formattedTotalSize)
.font(.system(.body, design: .monospaced))
}
Divider()
}
ForEach(diskInfoFetcher.diskInfos) { disk in
DiskInfoRow(diskInfo: disk)
}
}
}
} label: {
HStack {
Text("Disk Space Overview")
Spacer()
Image(systemName: "externaldrive.fill.trianglebadge.exclamationmark")
.renderingMode(.template)
.foregroundStyle(.orange)
Text("Only Updated Once a Day")
}
}
}
}
This view handles three states:
- Loading: Shows a progress indicator
- Error: Displays the error message
- Success: Shows the total disk space and a list of disk info rows
I’m using a GroupBox
to visually group the information, which is a common pattern in macOS apps. The label includes a warning that the information is only updated once a day, which is a good practice to set user expectations.

Each row in the list is handled by a separate view:
struct DiskInfoRow: View {
let diskInfo: FormattedDiskInfo
var body: some View {
VStack(spacing: 4) {
HStack {
Text(diskInfo.title)
.font(.system(.body, design: .rounded))
Spacer()
Text(diskInfo.formattedSize)
.font(.system(.body, design: .monospaced))
}
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.gray.opacity(0.2))
Rectangle()
.fill(progressColor)
.frame(width: geometry.size.width * diskInfo.percentage)
}
}
.frame(height: 6)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
var progressColor: Color {
switch diskInfo.title {
case "System": return .blue
case "Available": return .green
default: return .orange
}
}
}
Each row shows the title, size, and a progress bar indicating what percentage of the total disk space this category occupies. I’m using different colors for each category to make the visualization more intuitive.
Finally, let’s look at the chart view:
import SwiftUI
import Charts
struct DiskInfoChart: View {
@ObservedObject var diskInfoFetcher: DiskInfoFetcher
var body: some View {
Chart(diskInfoFetcher.diskInfos) { info in
SectorMark(
angle: .value(
Text(verbatim: info.title),
info.percentage
),
innerRadius: .ratio(0.6),
angularInset: 1.0
)
.foregroundStyle(
by: .value(
Text(verbatim: info.title),
info.title
)
)
.cornerRadius(3)
.annotation(position: .overlay) {
if info.title != "System" {
Text("(info.percentage * 100, specifier: "%.1f%%")")
.bold()
}
}
}
.chartLegend(position: .trailing, alignment: .center)
.frame(height: 120)
.padding(.trailing, 70)
.frame(maxWidth: .infinity, alignment: .center)
}
}

This view uses the Swift Charts framework (introduced in macOS 13) to create a donut chart of disk usage. Each sector represents a category, with its size proportional to the percentage of disk space it occupies. I’m adding percentage annotations to make the chart more informative.
Putting It All Together
With all these components in place, our app now provides a clean, informative interface for viewing disk usage. When a user clicks the menu bar icon, they see a window with:
- A title
- A list of disk usage categories with progress bars
- A donut chart visualizing the same information
The app demonstrates several important concepts for macOS utility development:
- Using menu bar extras for non-intrusive utilities
- Executing and parsing command line tools
- Presenting system information in a user-friendly way
- Handling loading states and errors
- Using modern SwiftUI features like Charts
Platform-Specific Considerations
When building macOS utilities that use command line tools, there are a few important considerations:
Sandbox Limitations: If you plan to distribute your app through the Mac App Store, be aware that sandboxed apps have limited access to command line tools and system information. You may need to request specific entitlements or consider alternative distribution methods.
Command Line Tool Changes: The output format of command line tools can change between macOS versions. Make your parsing code robust and consider adding version checks for critical differences.
Performance: Running command line tools isn’t free – it spawns new processes and can be relatively slow. Consider caching results and limiting how often you refresh data.
Privacy: Modern macOS versions are increasingly privacy-focused. If your app needs access to sensitive system information, be prepared to request appropriate permissions and explain why you need them.
Conclusion
Building macOS utility apps that leverage command line tools gives you access to powerful system capabilities while still providing users with a friendly, modern interface. In this tutorial, we’ve built a disk analyzer that:
- Lives in the menu bar for easy access
- Uses the
df
command to gather disk usage information - Presents that information in both textual and graphical formats
- Handles loading states and errors gracefully
This pattern can be extended to many other types of utilities – network monitors, CPU usage trackers, file system watchers, and more. The combination of SwiftUI for the interface and command line tools for the functionality gives you the best of both worlds.
I hope this tutorial has given you a solid foundation for building your own macOS utility apps. The ability to access system information through command line tools while presenting it in a beautiful SwiftUI interface opens up countless possibilities for helpful utilities that enhance the macOS experience.
Happy coding!