Building macOS Utility Apps with Command Line Tools

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.

⬇️ Download project files

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:

  1. Access command line tools from your SwiftUI app
  2. Create a disk space analyzer using the df command
  3. 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 information
  • isLoading: A boolean indicating if we’re currently fetching data
  • error: 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:

  1. Loading: Shows a progress indicator
  2. Error: Displays the error message
  3. 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:

  1. A title
  2. A list of disk usage categories with progress bars
  3. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

⬇️ Download project files

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!

Leave a Comment

Subscribe to My Newsletter

Want the latest iOS development trends and insights delivered to your inbox? Subscribe to our newsletter now!

Newsletter Form