Swift re-write for QuickPkg and some thoughts on LLM aided coding

I have created a new version of quickpkg, you can get it here.

If all you care about is the new version of quickpkg, go follow the link. There are also a few new features. Hope you like them.

Full disclosure: I used Claude Code to create this new version. I recently got access to Claude Code through work and I chose quickpkg as an experiment to understand where modern “agentic” coding tools are and how they fit in my workflows, coding and learning processes.

I have been and (spoiler) remain a skeptic of modern “AI” hypes and the companies whose business this is. I am not a skeptic in regards to there being useful aspects to Large Language Models (LLMs) and machine learning based solutions in general. For example, I have been living in countries where the main language is not my first for more than twenty years and the progress of translation software, both text, visual and audio based has massively simplified that experience recently.

I have been trying out various LLM based tools over the past years. I always got frustrated very quickly. I was told alot, that I was “holding them wrong” but the frustration always seemed to outgrow the benefit in short time. None of the upsides outweighed my concerns on the social, economical, ecological and ethical impact of the tech. (More on that later.) Certainly not enough to purchase any of the subscriptions which would give me access to the better models, which would be so much better, I was repeatedly told.

I have always believed that I should know and understand the things I criticize, so it was time for an experiment.

Why quickpkg?

This seemed like the perfect experimental project to me. quickpkg addresses a very specific problem, that I happen to know quite a bit about. It is simple, but not trivially simple. It is a command line tool, which is far less complex than a tool with a graphical interface.

quickpkg was originally written in Python 2 and when the demise of that version of Python was evident, I put in minimal effort to make it work with Python 3. Re-building it with Swift to remove that dependency had been on my to-do list for a long time, but it never made it high enough on my priority list.

Converting code from one programming language to another is tedious for humans (part of the reason I procrastinated on this) but something that coding assistants are supposedly very good at. On the other hand, building macOS installer packages is something that is woefully under documented, so I expected a bit of struggle there.

How it went: the translation

To prepare the project I created a new branch on the existing repository and created a template Swift Package Manager folder structure for a ‘tool.’ (a command line executable using the swift-argument-parser library) I set the Swift language version in the Package.swift to the 6.0 expecting/hoping that this would make it use the latest Swift concurrency. I told the agent that I want to translate the python code to Swift using swift-argument-parser and the new swift-subprocess package.

The agent went off for a few minutes to analyze the existing project, created a Claude.md file with its findings and presented me with a plan on how it would split the functionality contained in a single python file into various swift files. The plan looked reasonable to me and told it to go ahead and it started its work. I could watch the code it generated and it asked for a few confirmations.

I had to interrupt it at this point, since it apparently had no idea about the swiftlang/subprocress package I had asked to use and kept choosing either an older and long not updated subprocess repo hosted on the Apple GitHub or one from Jamf, which uses Foundation.Process for running shell commands. Then the agent even preferred building its own functions (also with Foundation.Process) instead of using the subprocess package I wanted. I had to explicitly add the swiftlang subprocess repo to the Package.swift myself and reference its documentation before the agent consistently used it, over the alternatives.

Once I had overcome that problem, the rest of the translation went fairly smoothly. It took maybe 10-15 minutes, which is obviously far faster than I could have done it.

Towards the end of that process, I could watch the agent repeatedly compiling the command line tool, and fixing errors that occurred. This seemed a very human approach to me. When the compile succeeded it started running the command line with a local app to test if it actually did something. The only outcome it tested for was whether a pkg file with expected name existed, not if it was a valid installer pkg file. It’s a good start, but there are obviously more things that would need to be tested.

It even ran the correct security command to determine a Developer ID certificate to test the --sign option. Then realized I had documented the command in the ReadMe file for the python tool, which gave me insight into where it got the information from.

The local application the agent chose to re-package was /System/Applications/Calculator.app which is a poor example for many reasons, but works for generating the pkg file. The resulting pkg file is useless because that folder is part of the signed system volume. I wondered for a moment, whether it had picked that up from the ReadMe, too, but I had used /Applications/Numbers.app in those examples. I had Numbers.app installed on the machine I was running this on, so why it didn’t respect that information from the documentation remains a mystery.

Once the agent told me it was ready, I did some more detailed tests, testing a few more input file types and several combinations of options. Since one of the main use cases I use quickpkg for is re-packaging Xcode, which is also the only real-world example of an app delivered in a xip archive, this took a while, even on a M4 MacBook Pro. Overall, about 90 minutes after giving the first set of instructions to Claude, I determined that the translation had worked.

Success?

Remember that Claude had a working python script to start out with. Nevertheless, aside from getting Claude to accept the (admittedly quite new) subprocess repository, this was a smooth process. I could and probably should have written up a list of commands and sample apps to use for testing and Claude would have done those for me, as well, saving some time in between as I invariably got distracted while larger packages built.

At this point, I could have stopped and called it a success. The code works. I can’t tell for sure how long the translation would have taken me manually (more on that later) but I am certain that I wouldn’t be able to do it in 90 minutes, let alone 15.

So, huge gain in efficiency, right?

Technical and cognitive debt

When I mentor people on scripting and coding I always stress that “working” is the most fundamental success criteria and everyone should be proud when they achieve that.

However, passing “it works” is only the first step along the way. If you plan to support and maintain and possibly build on the code going forward, you need to take the time to clean-up, refactor, and document the code. Especially, if you are planning to share the code.

Since the tool was working, I really wanted to publish and share it on my GitHub. But that means that I will be responsible to support the tool and the code going forward. Regardless of how the code for the tool was created, it is now my responsibility. So, I have the obligation to review and understand the code. This is another reason I chose a small project with a limited scope, since I anticipated that I wouldn’t have the time and energy to review and understand the code for a larger project that an agent could have generated in a fairly short time.

I actually started with the code review while I was testing whether the package build process was working as it should. As I said, some of those packages take a long time to build. Unfortunately, I started editing the generated code immediately, without creating a commit in the repo. I regret this now as I cannot link to these first changes.

Most of the code was good. There were a few cases of code repetition, as if a lazy programmer had copy/pasted certain code instead of abstracting it into a function or method. I have certainly been guilty of this a lot. But this is exactly what the “clean up” phase of a project is for.

There was one big four-way if-then clause in the ShellExecutor type, that was partially redundant. It checked for a nil value on workingDirectory and used two different calls to Subprocess.run, even though that function already takes an optional value. Then it did the same check for input resulting in a big unwieldy if-then clause with four calls to Subprocess.run that were only slightly different. Not wrong. The code did the right thing, but it was very hard to read code.

I actually think the entire ShellExecutor type is redundant and comes from the very many projects that use Process to run shell commands and need a wrapper type. At that moment I was happy to fix only the most egregious issues. (I have since refactored and removed the ShellExecutor type for the 2.0.1 release.)

Again, the code was working before. This is cleanup and refactoring to make the code more readable and understandable. I strongly believe more readable, clean code is easier to understand, maintain, and extend at a later time. I value putting in this extra effort, whether I have written the code myself, or get it from somewhere else. This process also forces me to understand the code, not just read over it and nod and feel “that’s good.”

Until this point, I was mostly editing the code myself. The connection from thinking about a code change to editing it myself in the editor is a long-trained habit for me. But then I remembered that I could tell Claude to do the refactoring. This worked surprisingly well. However, for small code changes, it felt slower and more complicated to phrase the change in ‘normal’ english, rather than just applying the change myself.

For example, I told the agent to create an extension on URL to wrap isFileURL and FileManager.shared.fileExists(atPath:) to make all the checks whether a file exists more readable. It did that and replaced all the uses of the less readable FileManager.shared.fileExists(atPath:) method. But I needed three attempts to phrase the request correctly and feel I would have been faster just writing the extension myself and using find and replace.

The run() function the agent originally generated was very long (again, something I have been guilty of a lot) and I asked it to refactor it with functions to make it more readable, and the result was quite good, but I needed to review these changes again to understand them and be sure the code and functionality remained the same and I feel that took at least as much time as doing it myself.

After a bit of refactoring and cleanup, I felt I understood the code that was generated. There was more cleanup to be done, which I put in the 2.0.1 update. But I was itching to add a few features that I wanted an updated version of quickpkg in 2026 to have.

  • quarantine flags are removed from the payload before packaging
  • minimum OS version is picked up from the app bundle and applied to the pkg
  • pkgbuild‘s compression option is set to latest with a command line option to revert to legacy
  • quickpkg now builds distribution packages/product archives by default

These weren’t complicated additions and the agent did those just fine. I really appreciated that it often (but not always) would update the ReadMe file to match the new options. The inconsistency was a bit frustrating.

Packaging the tool

I did try to use Claude to build a script which compiles, packages and notarizes the command line tool, which quickly turned into a frustrating experience. If the LLM could feel frustration I am sure it would have been mutual. Building, signing, and notarizing are famously under-documented tasks, even though my articles on the subject have been around for a while.

I gave up on that and copied the pkgAndNotarize script from another project. I couldn’t let it be and asked Claude for suggestions on how to improve that script and it suggested checking whether the signature certificates and keychain profile entries actually existed, which I thought was a good idea.

However, it konfabulated a notarytool store-credentials --list command to determine whether the keychain entry exists and I didn’t catch that until later, when I actually tried to build the final pkg. That should teach me to trust the LLM at its edge of competence.

Efficiency?

Compared to my earlier experiments with LLMs for coding, I was surprised how far the ‘agentic coding models’ have come. You cannot argue that they are completely useless anymore.

Translating working code from one language to the other is an easier task than generating code from scratch, but still. The fifteen minutes or so it took to generate a working Swift version is impressively fast.

Human developers are generally quite bad at judging how long a task will take. They are also very bad at judging how long it would have taken them with or without LLM support, compared to however they did it. There is research supporting this claim.

So, take my estimates with a grain of salt, but I estimated (before I started on the Claude project) that re-writing quickpkg and adding the new features would take me four to eight hours.

Now that I have seen and reviewed the generated code, I could re-create that much faster than my original estimate. Had I done the translation by hand before I put the agent to that task, the prompts would have been different and my review of the generated code would have been faster, because I would have had an idea of what to expect. So, either way, there is no fair control test.

Fifteen minutes compared to four to eight hours. I can see how someone might get excited at this point, call it a day and claim a huge efficiency gain.

There is a word for trusting the output of a coding agent without testing and verification: “vibe coding.” I consider it an horrendous lack of standards.

It took me more than an hour to verify that the generated code was actually doing what it was supposed to. I consider this really important since it generates package installers that install files on potentially thousands of devices. I might have been able to save some time by giving the agent more detailed instructions on how to test. Automating tests is good. But it wouldn’t have been much faster and defining the tests would have taken quite some time, as well. Re-packaging Xcode simply takes a long time and is an essential test. Also, I would still have had to verify that the agent was performing and evaluating the tests properly.

Then it took me another three to four hours to understand, review, and cleanup the code.

I would have had to test, review, cleanup the code if I had done the translation myself, but much of that would have happened during the re-writing, so that is part of my original estimate. And, of course, I understand and trust the code I wrote myself much better than code I get from elsewhere.

I do not dare to declare my code as always perfect, but neither is LLM generated code, so that’s a fair comparison. When I have to debug issues in the future though, I will be faster understanding the issue when it is my own code, or when I invested the time to review, understand, and clean up the code.

In the end we have five and half hours of time spent with Claude versus the four to eight hours estimate without. Much less exciting.

There’s a lot of dicussion that could be had here. How good is my estimate? Would I be more efficient with an agent if I spent more time learning the tool and how to write proper prompts? Will future models or agents be much better? Is it necessary to review and understand the generated code, as long as it works?

A comparison

Indulge me for a moment. I will get back to the topic.

For my lunch break, I usually go for a walk. There is a shopping area nearby, with a super market and a bakery, so I usually pick up some groceries. Depending on how much time I have available, I walk either a 2km, 3km or 5km loop. This gets me out of the house for some scenery, sun (weather permitting), and fresh air, provides some exercise, allows—no, forces—me to disconnect for a while from whatever I am doing at the desk and screens. It keeps the groceries stocked and I also get something nice from the bakery for lunch.

I could go get the groceries with the car. It’d be faster and take less time, so if that is your metric, it would be “more efficient.”

Yet I have no desire at all to replace my walk with a car trip. Less time is not what I value for my lunch break.

A car trip would have several downsides. Instead of a relaxing walk through parks and backstreets, I’d have to focus on the road and traffic, bikes, and pedestrians while driving and looking for a parking spot. I wouldn’t get the exercise, little as it is. I wouldn’t get a mental break, which I know will reduce my focus and productivity in the afternoon and evening. I couldn’t enjoy the sun. (Or rain, as it may be.) A car trip would also use far more energy and be more of a burden on the environment.

If I really wanted to optimize my grocery shopping for time spent, I could go to the big super market once per week and not leave the house at all during the week.

It’s not that taking a walk or the car, or going to the big store once a week are “better” or “worse” solutions. Each is an optimization for a different goal. Each has a different metric, different values that it is more optimal for.

quickpkg is a simple project. This was an intentional choice for this experiment, since I didn’t want to spend too much time on it. The quickpkg rewrite was also the first time I used the new Subprocess package in one of my projects, so one of my goals was to learn how that worked. Had I let the agent use the old Process way of launching shell commands that it wanted to use initially, or had I not reviewed and cleaned up the generated code afterwards, I would have learned nothing about the new Subprocess package.

There are other code projects I am currently working on, which are far more complex than quickpkg. Yet I feel no desire to use the assistance of an agent on these projects. For these projects, my main goal is to have full ownership and understanding of the code and their workflows. I am learning a lot about how I can control aspects of the system with Swift code and the macOS native frameworks. A lot of this is new to me, or I am re-visiting things that I thought I knew from a different perspective and challenging my knowledge.

Obviously, a result has to be delivered eventually, but gathering knowledge about the system and how to code these particular problems and exploring the limits of what is possible and, more importantly, what is not possible, has been a goal of these projects from the very beginning. In the course of this project we have already found some limitations we hadn’t antipicated, but also found solutions we had thought completely out of reach when we started planning.

If I didn’t challenge myself to explore the possibilities and craft the code and design the workflows, I believe the project would be far less useful than it is right now. I also believe I will be better at my profession and at implmenting future projects because of these experiences.

Keep in mind that as a consultant in the Mac management and system administration space, we live very much on the edge of things that are commonly documented. Since LLMs work on probabalistic data from large data sets, they get worse when there is less documentation. I could tell that Claude was fairly solid with common tasks, such as building a command line tool and refactoring code, but started konfabulating with pkgbuild and notarytool. When your project is more within well-documented domains, you will have better results.

This is also the reason I don’t use LLMs for writing. For me, the process of writing is a fundamental part of sorting out, challenging, and clarifying the half formed ideas contained in my head. I also generally enjoy the process, or at least gain satisfaction from the finished text. I would not and could not ask another person to do this process for me. How could I ask a machine? Why would I ask a machine?

Why would I take a car trip for my lunch break?

The upside

However, I will admit that I have used the built-in Xcode LLM functionality on a few occasions and found it helpful.

The first situation was a gnarly SwiftUI layout problem that I couldn’t find a solution for on the web. When I asked the Xcode 26 ChatGPT integration, it built a solution that worked, even though it seemed quite elaborate. Just last week, I found a weird crash that would happen when the window was resized a certain way and I couldn’t understand why. I fed the crash log back into the ChatGPT assistant and it pointed to a recursion generated by the interaction of the generated layout code and a seemingly unrelated, different view object. The suggestions to fix the issue from the assistant turned out to be dead ends, but it would have taken me much longer to identify the problem without the agent’s analysis. (I was able to remove the problem by reviewing, refactoring and simplifying the code. At least I hope so…)

When you have the ‘Coding Intelligence’ enabled in Xcode, there will be a “Generate Fix for this Issue” button next to the error and those can be very helpful to explain obscure compiler errors. SwiftUI certainly generates a few of those. Even though I rarely use the suggested fixes, the explanations of the issues usually are very helpful.

I believe it says a more about the sad state of modern IDEs, systems and frameworks when you need a large language model built with thousands of GPUs and hundreds of billions of tokens to understand a crash log or compiler error, than it does about the supposed “intelligence” of the model. But I will admit that has saved me a ton of time and frustration.

Should we focus on improving the frameworks, logs, and developer environments, rather than building monstrous data centers? Well, I guess that depends, like my lunch break walk, on what you are optimizing for…

Conclusion

I have been talking about efficiency and how we measure it, or don’t. I have not addressed all the other externalities that concern me with regards to LLMs and the general AI business these days.

My example illustrates that different solutions can be “best” when you are valuing different outcomes. I think a lot of the discussion of around coding agents and LLM help in general is based on a mismatch of values.

You may care more about “immediate time spent” with no concern for future ramifications and time you may have to spend later on improving the code. Technical and cognitive debt may not be part of your metrics. (They are difficult to measure.) You may not value the habit of building a tool as a means to learn about a particular topic. You may not care about the exploitative practices of the AI industry, which gathered and stole source material from where ever they could with no regards to ownership and licensing and now want to re-sell the digested slop back to us. You may not care about the unintentional—or sometimes fully intentional—political, ethnical, sexist and countless other biases in the data models. You may not care about the impact to your personal learning and growth, and education in general. You may not care how the next generation of experts is supposed to build their experience. You may not care about the ecological impact of the industry and the massive data centers they are planning to build. You may not care about the skewed and possibly fraudulent economics as the infusion of absolutely insane amounts of venture capital is papering over the actual costs. You may be starting to care about the secondary economical impacts of the bubble, as prices for RAM and other components are sky rocketing.

You may disagree on some, or even all, of these points, which will change your evaluation of this technology.

The benefits you gain from this technology also depend very much on what you are using it for. The more data about a certain topic the LLM has ingested, the better the recommendations will be. When you ask it for code to build web solutions and related automations, the recommendations will be much better than when you ask it about building package installers for macOS, since there are orders of magnitude more data for the former, than the latter.

The agent was very prone to inventing options for pkgbuild, productbuild and notarytool, even after I had instructed it to consider the man pages. This is a very important warning for people using agents to write automations in the Mac Admins space. Also, for the same reason, LLMs are “weak” on recent developments, so you may get code that would have worked fine five years ago, but doesn’t take modern changes to macOS and Apple platform deployment into account.

I am glad I did this experiment. For the first time, working with the agent felt really useful. I am not sure I would have ever overcome the writer’s block inherent in the tedious process of translating code. Using the agent to overcome that block was freeing. I experienced the wonder of a fascinating new technology. I can see how that can overshadow the concerns.

I believe the technology has merit. There is undoubtedly a usefulness to it. But in the current form, I think it is irresponsible to focus solely on the technical features and ignore all the other negative side effects. The benefits, when put under scrutiny are much smaller than they initially appear.

I have to hope that society will eventually find a way to build and use these tools in an effective, ethical, and responsible way. I don’t believe this is the case today. I don’t think the benefits outweigh the downsides. For now, I will continue to stay away.

Swift Argument Parser: Exiting and Errors

I have introduced the Swift Argument Parser package before. I have been using this package in many of my tools, such as swift-prefs, utiluti, emoji-list and, just recently, translate.

Argument Parser is a Swift package library which adds complex argument, option, and flag parsing to command line tools built with Swift. The documentation is generally quite thorough, but a few subjects are a little, well…, under-documented. This may be because the implementations are obvious to the developers and maintainers.

One subject which confused me, was how to exit early out of the tool’s logic, and how to do so in case of an error. Now that I understand how it works, I really think the solution is quite brilliant, but it is not immediately obvious.

Our example

Swift error handling use values of the Error type that are thrown in the code. Surrounding code can catch the error or pass it to higher level code. Argument Parser takes errors thrown in your code and exits your code early.

An example: in my 2024 MacSysAdmin presentation “Swift – The Eras Tour (Armin’s Version),” I built a command line tool to set the macOS wallpaper tool live on stage. To replicate that, follow these steps:

In terminal, cd to a location where you to store the project and create a project folder:

$ mkdir wallpapr
$ cd wallpapr

Then use the swift package command to create a template command line tool project that uses Argument Parser:

$ swift package init --type tool

The project will look like this:

📝 Package.swift
📁 Sources
   📁 wallpapr
      📝 wallpapr.swift

Xcode can handle Swift Package Manager projects just fine. You can open the project in Xcode from the command line with:

$ xed .

Or you can open Sources/wallpapr/wallpapr.swift in your favored text editor.

Replace the default template code with this:

import Foundation
import ArgumentParser
import AppKit

@main
struct wallpapr: ParsableCommand {
  @Argument(help: "path to the wallpaper file")
  var path: String

  mutating func run() throws {
    let url = URL(fileURLWithPath: path)
    for screen in NSScreen.screens {
      try NSWorkspace.shared.setDesktopImageURL(url, for: screen)
    }
  }
}

This is even more simplified than what I showed in the presentation, but will do just fine for our purposes.

You can build and run this command with:

$ swift run wallpaper /System/Library/CoreServices/DefaultDesktop.heic
Building for debugging...
[1/1] Write swift-version--58304C5D6DBC2206.txt
Build of product 'wallpapr' complete! (0.16s)

(I’ll be cutting the SPM build information for brevity from now on.)

This is the path to the default wallpaper image file. You can of course point the tool to another image file path.

Just throw it

When you enter a file path that doesn’t exist, the following happens:

swift run wallpapr nosuchfile 
Error: The file doesn’t exist.

This is great, but where does that error come from? The path argument is defined as a String. ArgumentParser will error when the argument is missing, but it does not really care about the contents.

The NSWorkspace.shared.setDesktopURL(,for:), however, throws an NSError when it cannot set the wallpaper, though. That NSError has a errorDescription property, which ArgumentParser picks up and displays, with a prefixed Error:.

This is useful. By just marking the run() function of ParsableCommand as throws and adding the try to functions and methods which throw, we get pretty decent error handling in our command line tool with no extra effort.

Custom errors and messages

Not all methods and functions will throw errors, though. If they do, the error messages might not be helpful or too generic for the context. In more complex tools (and, honestly, nearly everything will be more complex than this simple example) you want to provide custom messages and custom error handling, so you will need custom errors.

Since we have seen that ArgumentParser deals very nicely with thrown errors, let’s define our own.

Add this custom error enum to the wallpaper.swift (anywhere, either right after the import statements or at the very end):

enum WallpaprError: Error {
  case fileNotFound
}

Then add this extra guard statement at the beginning of the run() function:

    guard FileManager.default.fileExists(atPath: path) else {
      throw WallpaprError.fileNotFound
    }

The code checks that the file path given in the path argument actually exists, instead of relying on the functionality in the setDesktopURL(,for:) method. When you run this code, we get our custom error:

$ swift run wallpapr nosuchfile
Error: fileNotFound

This is nice, but fileNotFound is a good name to use in the code, but not very descriptive. We could add more description with a print just before throw statement, but we already saw that the NSError thrown by setDesktopURL() had a detailed description. How do we add one of those to our custom error?

Turns out there are two ways. Either the custom error conforms to LocalizedError and implements errorDescription (which is what NSError does) or you implement CustomStringConvertible and implements description (or both).

There are many good reasons to implement CustomStringConvertible on your types anyway, since description is also used in the debugger and the print statement. There are also situations where you might want a different message for the error description, so it is good to have options. For our example, we just going to implement CustomStringConvertible. Change the code for the WallpaprError enum to:

enum WallpaprError: Error, CustomStringConvertible {
  case fileNotFound

  var description: String {
    switch self {
    case .fileNotFound: "no file exists at that path!"
    }
  }
}

And when you run again, you see the custom message:

$ swift run wallpapr nosuchfile
Error: no file exists at that path!

Note that the error message is written to standard error.

Clean exits

In some workflows, you may want to exit the script early, but without an error. (An exit code of 0.) When you try to use exit(0), you will get an error since ArgumentParser overloads that function. Instead, ArgumentParser provides a CleanExit error that you can throw:

throw CleanExit.message("set wallpaper to \(path)")

Generally it is best to just let the run() function complete for a successful exit, but there are situations where this comes in handy.

Custom Exit Codes

ArgumentParser generally does the right thing and returns a 0 exit code upon successful completion of the tool and an exit code of 1 (non-zero represents failure) when an error is thrown. It also returns an exit code of 64 when it cannot parse the arguments. According to sysexits this represents a bad entry of options and arguments.

(You can customize your shell prompt to show the exit code of the previous command.)

In complex tools, you may want to return other exit codes mentioned in that man page, or custom errors for certain situations. ArgumentParser does have a built-in option: you can throw the ExitCode() error with a custom code. For example, we can replace our custom error with

throw ExitCode(EX_NOINPUT)

This will return an exit code of 66, but now we have lost the custom error message. This is long standing missing feature of ArgumentParser (see discussion in this forum thread), but it is fairly easy to provide a workaround.

Add this extension to your tool:

extension ParsableCommand {
  func exit(_ message: String, code: Int32 = EXIT_FAILURE) throws -> Never {
    print(message)
    throw ExitCode(code)
  }
}

And then you can use this to get a custom message and a custom exit code.

try exit("no file exists at that path!", EX_NOINPUT)

Updates: Setup Manager and utiluti

Setup Manager 1.3

We have released Setup Manager 1.3 today. You can see the release notes and download the pkg installer here.

Most of the changes to Setup Manager in the update do not change the workflow directly. The focus for this update was to improve logging and information provided for trouble-shooting.

With the 1.3 update, Setup Manager provides richer logging information. You will find some entries in the Setup Manager log that were not initiated by the Setup Manager workflow, but are still very relevant to troubleshooting the enrollment workflow. You can see all installation packages that are installed during the enrollment, as well as network changes. This allows an admin to see when managed App Store installations or other installations initiated from the MDM or Jamf App Installers are happening in the enrollment workflow.

These can be very helpful to determine what might be delaying or interrupting certain other installations.

When we started building the “enrollment tool we wanted to use ourselves” more than two years ago, we chose to build a full application, rather than a script-based solution which remote controls some interface. One of the immediate benefits is that we could make the user interface richer and more specialized. Localizing the app into different languages was easier, too. Setup Manager adds Polish localization, bringing the total number of languages to ten!

(We use the help of volunteers from the community to localize to other languages, if you want to help localize Setup Manager into your language, please contact me.)

There was another goal, which took a bit longer to realize.

Swift apps allow us to dive deeper into the capabilities and information available in the operating system. A full blown app is also more capable at analyzing and displaying multiple sources of information at the same time. For example, Setup Manager will display a big warning when the battery level drops below a critical threshold.

These kinds of workflows and user interfaces would be nearly impossible or, at the very least, extremely complex to build and maintain with shell scripts. In this case, Setup Manager is monitoring and parsing other log files and summarizing them down to some important events in the background, while it is working through its main purpose of running through the action list from the profile.

This feature will not be seen by most users or even techs who are sitting in front of the Mac, waiting for the base installation to finish. But when you are trouble shooting problems during your enrollment workflow, these extra log entries can be very insightful. Even during testing, it unveiled some surprises in our testing environments.

We hope you like the new features. But, we are also not done yet and have plenty more ideas planned for Setup Manager!

utiluti 1.2

Since we are talking updates, I have also released an update to my CLI tool to set default apps for urls and file types (uniform type identifiers/UTI). utiluti 1.2 adds a manage verb which can read a list of default app assignments from plist files or a configuration profile. You can see the documentation for the new manage verb here and download the latest pkg installer here.

This allows you to define lists of default apps and push them with your device management system. Then you can run utiluti from a script in the same management system. This should greatly simplify managing default apps.

Note, that while you can set the default browser with utiluti, whether you are using the manage option or not, the system will prompt the user to confirm the new default browser. For this use case, you will want to put the utiluti command in a context where the user is prepared and ready for that extra dialog (such as a Self Service app). There are other tools, such as Graham Gilbert’s make-default CLI tool, which bypass the system dialog. In my experience, tools like this work well in fairly clean setup and require a logout or reboot after the change. This might fit your workflow, but you need to test.

I hope utiluti will find a place in your MacAdmin’s toolbox!

New tool: utiluti sets default apps

A while back I wrote a post on the Jamf Tech Thoughts blog about managing the default browser on macOS. In that post I introduced a script using JXA to set the default application for a given url scheme. (like http, mailto, ssh etc.) The beauty of using JXA/osascript is that it doesn’t require the installation of an extra tool.

However, there was a follow-up comment asking about default apps for file types, i.e. which app will open PDF files or files with the .sh file extension. Unfortunately, Apple has not bridged those AppKit APIs to AppleScript/JXA, which means it is not possible to use them in a script without dependencies.

Back then, I started working on a command line tool which uses those APIs. I didn’t really plan to publish it, since there were established tools, like duti, cdef and SwiftDefaultApp that provided the functionality. It was a chance to experiment and learn more about Swift Argument Parser. Then life and work happened and other projects required more attention.

A recent discussion on the Mac Admins Slack reminded me of this. Also, none of the above mentioned tools have been updated in the past years. As far as I can tell, none of them have been compiled for the Apple silicon platform. They don’t provide installation pkgs either, which complicates their distribution in a managed deployment.

So, I dusted off the project, cleaned it up a bit, and added a ReadMe file and a signed and notarized installation pkg. The tool is called utiluti (I am a bit proud of that name).

You can use utiluti to set the default app for an url scheme:

$ utiluti url set mailto com.microsoft.Outlook
set com.microsoft.Outlook for mailto

or to set the default app to open a uniform type identifier (UTI):

$ utiluti type set public.plain-text com.barebones.bbedit
set com.barebones.bbedit for public.plain-text

There are bunch of other options, you can read the details in the ReadMe or in the command line with utiluti help.

The functionality is quite basic, but please provide feedback if there are features you’d like to have added.

Swift Command Line Tools and Argument Parser — Part 1

When building tools in Swift, I usually start with a command line tool. This allows me to ignore the complexity of creating a user interface while figuring out the underlying APIs and data models.

Technically, command line tools have a user interface, as well. They print output to pipes, standard out or standard error for data, progress, status or other information. They should provide information using the exit status. They take input from standard in, pipes, environment variables, or from command line arguments.

While there are many subtleties to consider with all of these, these “interfaces” are still less complex to handle than a full user interface built with AppKit, UIKit or SwiftUI.

Swift provides the functionality to deal with files, outputs, pipes with APIs. This post will not cover those. This post will focus on getting arguments from the command line.

Note: this tutorial was written using Swift 5.10, Xcode 15.4, swift-argument-parser 1.5 on macOS 14.6.1. The details and user interfaces may change with different versions, but the fundamentals should remain.

CommandLine.arguments

The built-in way to get the arguments for your process is with CommandLine.arguments which returns an array of String objects. CommandLine.arguments is quite basic, but can be suitable for simple use cases.

Create a project directory named CLArgs and use swift package init to create a swift package to build an executable:

> mkdir CLArgs
> cd CLArgs
> swift package init --type executable

This will create a skeleton project, you will find a basic “Hello, world” code in Sources/main.swift. Replace the print statement there with:

import Foundation
let arguments = CommandLine.arguments.dropFirst()
guard let name = arguments.first
else {
  print("command requires an argument!")
  exit(1)
}
print("Hello, \(name)")

Note: You can use your favorite text editor or IDE to edit SPM projects. You can also use Xcode. When you run xed . in the Swift package directory, Xcode will open the Swift package in a project view. You can edit, build and run the package in Xcode or use Xcode for editing and build and run it from the command line.

In terminal, build and run the project with

> swift run CLArgs Armin

This tells the swift to build the CLArgs target defined in Package.swift and run it with the argument Armin. You should see this output:

> swift run CLArgs Armin
Building for debugging...
[1/1] Write swift-version-39B54973F684ADAB.txt
Build of product 'CLArgs' complete! (0.11s)
Hello, Armin

Let’s look at the code in detail.

let arguments = CommandLine.arguments.dropFirst()

CommandLine.arguments returns an array of strings. By convention, the first argument (arguments[0]) contains the path to the executable. In most situations, you will not be interested in this first argument. One straightforward way to deal with this is to ‘drop’ the first element of the array right away.

guard let name = arguments.first
else {
  print("command requires an argument!")
  exit(1)
}

We get the first element of the arguments array. Additional arguments are simply ignored. When no arguments are provided, this will return nil and guard statement will trigger, where we print an error message and exit the code with a non-zero value, signaling a failure.

print("Hello, \(name)")

The actual point of this sample code: print a greeting with the name.

In this simplest of examples, we spend a majority of the code on preparing the arguments and verifying that they meet our requirements.

CommandLine.arguments will serve you well for simple needs and quick command line tools. However, you will quickly notice that a robust command line tool needs to verify the existence of certain arguments, whether the value matches certain criteria, and print error messages and usage directions when the arguments don’t match the expectations. Many command line tools also have flags and options with short and long forms that need to be processed.

This turns into a lot of code very quickly.

Swift Argument Parser

Enter Swift Argument Parser. A package that provides “straightforward, type-safe argument parsing for Swift.”

You could modify the Package.swift file in our CLArgs project to import Swift Argument Parser but there is an even easier way to start. Back out of the CLArgs project directory and create a new one:

> cd ..
> mkdir SwiftArg
> cd SwiftArg
> swift package init --type tool

When you inspect the Package.swift file in this new project, you will see that it is already linked to the Swift Argument Parser package. Sources/SwiftArgs.swift contains another “Hello, world” template code, but using Swift Argument Parser.

import ArgumentParser
@main
struct SwiftArgs: ParsableCommand {
  mutating func run() throws {
    print("Hello, world!")
  }
}

The struct here implements the ParsableCommand protocol which allows us to use all the nice functionality from the ArgumentParser library. It is also marked with the @main tag, which tells the compiler to run the main() function in this when the binary is launched. The main() function is implemented by ParsableCommand which, well, parses the arguments and then launches the run() function.

Swift Package Manager vs Xcode projects

You can open and edit Swift Package Manager projects in Xcode with the xed . command. Recent Xcode versions know how to work with SPM projects without needing to create an Xcode project file. Xcode will use the configurations in the Package.swift file. This is useful when you like to work in Xcode, but want the project to remain transferable to other editors or IDEs.

There is a weird quirk. When you build and/or run the project from within Xcode it will use the default Xcode build directory (default is ~/Library/Developer/Xcode/DerivedData/). This is different from the location that the swift build or swift run commands in Terminal use (.build in the package directory). This can lead to longer build times and confusion.

You can also use swift-argument-parser and other packages within Xcode projects. This can be necessary if you are building the command line as a target within a larger Xcode project. Maybe you want to use some of Xcode’s advanced features for managing projects, like build phases and Archives. Or maybe you just prefer working in Xcode.

To create a command line with ArgumentParser in Xcode, create a new Project and select the ‘Command Line Tool’ template for macOS. Once the new project is created, select ‘Add Package Dependencies…’ from the File menu. Locate ‘swift-argument-parser’ in the ‘Apple Swift Packages’ collection or just enter the URL in the search field and click ‘Add Package…’ (twice)

Then, you have to delete the main.swift file from the template and create a new SwiftArgs.swift with this code:

import Foundation
import ArgumentParser
@main
struct SwiftArgs: ParsableCommand {
  mutating func run() throws {
    print("Hello, world!")
  }
}

This is the same as the template code created with the swift package init --type tool from above.

When testing and running the command line tool in Xcode will will want to pass arguments into the binary. You can do so by editing the scheme. Choose Product > Scheme > Edit Scheme… from the menu or click on the target icon (the one with the command line icon in the center of the menu bar) and select Edit Scheme… Make sure you are on the ‘Run’ section in that dialog and select the ‘Arguments’ tab. Here you can add, remove, enable or disable the arguments that Xcode passes into your tool when you run it from Xcode.

Continually changing the arguments in the scheme editor can be tedious. You can also use ‘Show Build Folder in Finder’ from the ‘Product’ menu, open the Products/Debug folder in Terminal by dragging that folder on to the Terminal icon in the Dock and run the built command from there with ./SwiftArgs

Whichever way you prefer to create and work with your project, the rest of this tutorial will work the same way.

Using Swift Argument Parser

Right now, we are just running print("Hello, world!"), which is quite underwhelming. Let’s step this up just a little bit:

@main
struct SwiftArgs: ParsableCommand
  @Argument var name: String
  func run() {
    print("Hello, \(name)")
  }
}

First we create a property called name of type Stringwith the @Argument property wrapper. This tells the ArgumentParser library, that we want this variable filled with an argument from the command line. When the run() function is called, we can “just use” the name property, like any other Swift property.

When you run this, something interesting happens: we get an error!

> swift run SwiftArgs
Error: Missing expected argument '<name>'
USAGE: swift-args <name>
ARGUMENTS:
  <name>
OPTIONS:
  -h, --help              Show help information.

When you check the exit code of the command with echo $? you see it return an error code of 64. This means it was missing arguments or got malformed arguments. As should be good practice for command line tools, our tool did print a help message, describing what it was expecting. Here we see that our SwiftArgs command expects a single argument giving a name.

Run the command again, but with an argument:

> swift run SwiftArgs Armin
Hello, Armin

Now everything works as expected. When our tool launches, ArgumentParser grabs the argument, places it in our name property and executes the run() function in our struct that implements ParsableCommand. Since ArgumentParser errors out with the help message, when an argument is missing or too many arguments are present, we can be certain that the name variable is populated when our code runs.

Command Configuration

There is a small detail that is bugging me, though. The help message generated by ArgumentParser deduced that the name of binary should be swift-args instead of SwiftArgs, but the binary name is SwiftArgs, which is the name of the directory we initialized the project in. This is because of different naming standards for Swift types and command line tools. You can change the name of the executable created in the Package.swift file in line 15 under .executableTarget.

We could change the name to something completely different here, say apdemo for ‘Argument Parser Demo. When you apply that change inPackage.swift` it changes the name of the binary, but the auto-generated help message does not pick that up. It still use the auto-generated name.

> swift run apdemo --help
USAGE: swift-args <name>
ARGUMENTS:
  <name>
OPTIONS:
  -h, --help              Show help information.

(Isn’t it neat that ArgumentParser automatically implements --help and -h flags?)

We could change the name of our struct, which will work in simple situations. But you will have a situation where the struct name will not match what you want for the executable name. There is a way to tell ArgumentParser exactly what we want, though.

Insert this code below the struct SwiftArgs line and above the @Argument:

  static let configuration = CommandConfiguration(
    commandName: "apdemo"
  )

When you now look at the generated help again, the command name matches:

OVERVIEW: apdemo - swift-argument-parser tutorial tool
USAGE: apdemo <name>
ARGUMENTS:
  <name>
OPTIONS:
  -h, --help              Show help information.

There is more information we can provide in the command configuration. Extend the CommandConfiguration initializer like this:

  static let configuration = CommandConfiguration(
    commandName: "apdemo",
    abstract: "apdemo - swift-argument-parser tutorial tool",
    version: "0.1"
  )

and run the command to get the help message again.

OVERVIEW: apdemo - swift-argument-parser tutorial tool
USAGE: apdemo <name>
ARGUMENTS:
  <name>
OPTIONS:
  --version               Show the version.
  -h, --help              Show help information.

The abstract appears as an ‘overview’ above the help message and we now see a new option --version. When you run the tool with that option, you will not be surprised to see the 0.1 provided in the configuration, but it is useful nonetheless.

There are more fields you can provide in the CommandConfiguration: discussion allows you to provide a long form description of the command. usage allows you to override the auto-generated usage text. There are some more that we will explore later.

More Arguments

You can add more @Arguments and they will be filled in order from the arguments provided at the command line. Add another property with the @Argument wrapper:

  @Argument var name: String
  @Argument var age: Int
  func run() {
    print("Hello, \(name)!")
    print("You are \(age) years old.")
  }

When you run the tool without any arguments, you can inspect the updated help message. The usage and arguments area now shows both expected arguments. When you run the tool with a single argument, you get an abbreviated help, showing only the missing argument. When you provide a name and a number as command line arguments, everything works as expected.

But what if you provide two strings?

> swift run apdemo Armin lalala            
Error: The value 'lalala' is invalid for '<age>'
Help:  <age>  
Usage: apdemo <name> <age>
  See 'apdemo --help' for more information.

We declared the age property as an Int, so ArgumentParser expects an integer number for the second argument. When the second argument cannot be parsed into an integer, it shows the error.

Change the type of the age property to a double and run it again with a decimal for the age.

Some Help, please?

name and age might be enough to tell a user of your command line tool what to enter. But I think we should provide a bit more explanation. You can attach a help message to the argument:

  @Argument(help: "a name")
  var name: String
  @Argument(help: "age (integer number)")
  var age: Int

I have broken the property declarations into two lines each for clarity and changed the age back to an Int for simplicity. The help messages will appear next to the argument names in the long and short help messages.

Off to a good start

We have just started to scratch the surface of what swift-argument-parser can do for us. In the next part, we will cover options and flags.

Prefs CLI Tools for Mac Admins

Recently I have been working on some… well… “stuff” that uses custom configuration profiles. Very custom, and since I am testing things, they need to be updated a lot.

The issue with defaults

When you are working with defaults/preferences/settings/property lists on macOS, you will be familiar with the defaults command line tool. But, as useful as defaults can be, it has some downsides.

One of the great advantages of macOS’ preference system is that settings can be provided on multiple levels or domains. In my book “Property Lists, Preferences and Profiles for Apple Administrators, I have identified 19 different levels where settings for a single application can originate.

You will be most familiar with plist files in /Library/Preferences (system), ~/Library/Preferences (user), and managed configuration profiles (managed). When an app or tool requests a setting, the preferences system will merge all those levels together and present only the most relevant value. When the developer uses the system APIs (correctly), they do not have to worry about all the underlying levels, domains and mechanisms very much, but automatically gain support for things like separated system and user level settings files and support for management through configuration profiles.

The macOS defaults command line tool can work with settings on different levels or domains, but will only show the settings from one at a time. By default it only works with the user domain settings stored in ~/Library/Preferences/. When you have settings in multiple levels or from configuration profiles, you may be able to point defaults directly at the files. Or in the case of managed settings from profiles, you have to use a different tool. Either way, you have to determine which setting might override another and which final value might be visible to the app or process.

A new prefs tool

Years back, I had built a python script, called prefs.py, which would not only show the coalesced set of settings but their origin level. When macOS removed Python 2 in macOS 12.3, this tool obviously broke.

While working with preferences and profiles recently, this feature would have been quite useful to debug and verify preferences. I could have adapted the existing tool to work with MacAdmins Python 3, but felt I would learn something from recreating it in Swift. I had already started down that road just a bit for my sample project in this post.

So, you can find the new Swift-based prefs command line tool on GitHub. You can also download a signed and notarized pkg which will install the binary in /usr/local/bin/.

If its most basic form, you run it with a domain or application identifier. It will then list the merged settings for that preference domain, showing the level where the final value came from.

% prefs com.apple.screensaver
moduleDict [host]: {
    moduleName = "Computer Name";
    path = "/System/Library/Frameworks/ScreenSaver.framework/PlugIns/Computer Name.appex";
    type = 0;
}
PrefsVersion [host]: 100
idleTime [host]: 0
lastDelayTime [host]: 1200
tokenRemovalAction [host]: 0
showClock [host]: 0
CleanExit [host]: 1

I find this useful when researching where services and applications store their settings and also to see if a custom configuration profile is set up and applying correctly. There is a bit of documentation in the repo’s ReadMe and you can get a description of the options with prefs --help.

plist2profile

Another tool that would have been useful to my work, but that was also written in python 2 is Tim Sutton’s mcxToProfile. Back in the day, this tool was very useful when transitioning from Workgroup Manager and mcx based management to the new MDM and configuration profile based methods. If you have a long-lived management service, you will probably find some references to mcxToProfile in the custom profiles.

Even after Workgroup Manager and mcx based settings management was retired, Tim’s tool allowed to create a custom configuration profile from a simple property list file. Configuration Profiles require a lot of metadata around the actual settings keys and values, and mcxToProfile was useful in automating that step.

Some management systems, like Jamf Pro, have this feature built in. Many other management systems, however, do not. (Looking at you Jamf School.) But even then creating a custom profile on your admin Mac or as part of an automation, can be useful.

So, you probably guessed it, I also recreated mcxToProfile in Swift. The new tool is called plist2profile and available in the same repo and pkg. I have focused on the features I need right now, so plist2profile is missing several options compared to mcxToProfile. Let me know if this is useful and I might put some more work into it.

That said, I added a new feature. There are two different formats or layouts that configuration profiles can use to provide custom setting. The ‘traditional’ layout goes back all the way to the mcx data format in Workgroup Manager. This is what mcxToProfile would create as well. There is another, flatter format which has less metadata around it. Bob Gendler has a great post about the differences.

From what I can tell, the end effect is the same between the two approaches. plist2profile uses the ‘flatter’, simpler layout by default, but you can make it create the traditional mcx format by adding the --mcx option.

Using it is simple. You just need to give it an identifier and one or more plist files from which it will build a custom configuration profile:

% plist2profile --identifier example.settings com.example.settings.plist

You can find more instructions in the ReadMe and in the commands help with plist2profile --help

Conclusion

As I had anticipated, I learned a lot putting these tools together. Not just about the preferences system, but some new (and old) Swift strategies that will be useful for the actual problems I am trying to solve.

I also learnt more about the ArgumentParser package to parse command line arguments. This is such a useful and powerful package, but their documentation fails in the common way. It describes what you can do, but not why or how. There might be posts about that coming up.

Most of all, these two tools turned out to be useful to my work right now. Hope they will be useful to you!

Build a notarized package with a Swift Package Manager executable

One of the most popular articles on this blog is “Notarize a Command Line Tool with notarytool.” In that post I introduced a workflow for Xcode to build and notarize a custom installer package for a command line tool. This workflow also works with apps and other projects that require a customized installer package building workflow. I use it in many of my own projects.

But Xcode is not the only way to build Swift binaries. Especially for command line tools, you can also use Swift Package Manager. This provides a mostly command line based interface to building and organizing your project, which you might prefer if you want to use an IDE that is not Xcode, or have Swift projects that need to run cross-platform.

I also have an older article on building a command line tool with Swift Package Manager. But then, I did not create an installer package or notarize the resulting binary.

Placing the binary in an installer package file is the best way to distribute a binary as you can control where in the file system the binary is installed. Notarizing the pkg file is necessary when you are distributing a command line tool, since it enables installations without scary dialogs or handling quarantine flags.

Also, some of the behavior of Swift Package Manager (SPM) and Xcode have changed since the previous posts. So, this article will introduce an updated workflow using Swift Package Manager tools and how to sign, package and notarize a command line tool for distribution.

Note on nomenclature: Swift Package Manager projects are called ‘packages.’ On macOS, installer files (with the pkg file extension) are also called ‘packages.’ We will be using SPM to build a notarized installation package (a pkg file) from a Swift package project. This is confusing. There is not much I can do about that other than using ‘installer package’ and ‘Swift package project’ to help distinguish.

Prerequisites

I wrote this article using Xcode 14.3.1 and Swift 5.8.1. It should also work with somewhat older or newer versions of Xcode and Swift, but I have not tested any explicitly.

Since I said earlier that using Swift Package Manager allows us to not use Xcode and maybe even build a cross-platform project, you may be wondering why we need Xcode. While we don’t need Xcode for our project, it is one way of installing all the tools we need, most importantly the swift and notarytool binaries. You get those from Developer Command Line tools, as well. We will also see that we can combine Xcode with the command line Swift Package Manager workflow, which I find a very useful setup.

To submit a binary to Apple’s notarization process you will need a Personal or Enterprise Apple Developer account, and access to the Developer ID Application and Developer ID Installer certificates from that account. A free Apple Developer account does not provide those certificates, but they are necessary for notarization

You can follow the instructions in the Xcode article on how to get the certificates and how to configure notarytool with an application specific password. If you had already done this previously you should be able to re-use all of that here. When you reach the ‘Preparing the Xcode Project’ section in that article, you can stop and continue here. Apple also has some documentation on how to configure notarytool.

The sample code we will be using will only work on macOS as it uses CoreFoundation functions. Installer packages and notarization are features of macOS, too, so this is not really a problem here. You can use this workflow to build macOS specific signed binaries and notarized installation pkg files from a cross-platform Swift package project. This will work as long as you keep in mind that the tools to sign, package and notarize only exist and/or work on macOS.

The sample code

We will build the same simple sample tool as in the last article. The prf command (short for ‘pref’ or ‘preference’) reads a default setting’s effective value using the CFPreferencesCopyAppValue function.

The macOS defaults command will read preferences, but only from the user level, or from a specified file path. This ignores one of the main features of macOS’ preferences system as it will not show if a value is being managed by a different preference level, such as the global domain, a file in /Library/Preferences, or (most importantly for MacAdmins) a configuration profile.

You can learn all about preferences and profiles in my book “Property Lists, Preferences and Profiles for Apple Administrators.”

We will build a really simple command line tool, named prf which shows the effective value of a setting, no matter where the value comes from. You could make this tool far more elaborate, but we will keep it simple, since the code is not the actual topic for this article.

We will also be using the Swift Argument Parser package to parse command line arguments and provide a help message. We could build this simple tool without using Argument Parser, but using an external package module is one of the strengths of using Swift Package Manager.

Create the Swift Package project

With all the preparations done, it is time to create our Swift package. We will do all the work in the shell, so open Terminal or your other favorite terminal app and navigate to the directory where you want to create the project.

> cd ~/Projects

Then create a new directory with the name swift-prf. This will contain all the files from the Swift package project. Change directory into that new directory. All following commands will assume this project directory is the current working directory.

> mkdir swift-prf
> cd swift-prf

Then run the swift tool to setup the template structure for our command line tool or ‘executable.’

> swift package init --type executable 
Creating executable package: swift-prf
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/main.swift

You can inspect the hierarchy of files that the init tool created in the Finder (open .) or in your preferred editor or IDE.

.gitignore
Package.swift
Sources
    main.swift

`

You can open this package project in Xcode. In older versions of Xcode you had to run a special swift package command to generate the Xcode project, but now, Xcode can open Swift package projects directly. Use xed (the ‘Xcode text editor invocation tool’) to open the current directory in Xcode.

> xed .

There is a pre-filled .gitignore (which will be hidden in Finder and probably your IDE), a Package.swift, and a Sources directory with a single main.swift inside. If you want to use git (or another version control) system, now is the time to initialize with git init.

Build the project with swift build and/or run it with swift run. Not surprisingly, the template prints Hello, world!.

> swift build
Building for debugging...
[3/3] Linking swift-prf
Build complete! (0.92s)
> swift run  
Building for debugging...
Build complete! (0.11s)
Hello, world!

After building, there will also be a .build directory (also hidden in Finder, unless you toggle the visibility of invisible files using shift-command-.) which contains all the interim files. In the debug folder, you can find the swift-prf executable. You can run it directly:

> .build/debug/swift-prf
Hello, world!

You can clean all the generated pieces from the .build directory with swift package clean. This will leave some empty folders behind but remove all the interim and final products. This means the next build is going to take much longer, but this can be helpful after reconfiguring the Package.swift file or when the compiler gets confused.

Sidenote: when you use Xcode to edit your Swift package project, and choose Build or Run from the Xcode interface, then it will build and run in a different location (~/Library/Developer/Xcode/DerivedData/swift-prf-<random-letters>/Build). You need to be aware of this when you alternate between Xcode and the command line.

Configuring the Package

The Package.swift file contains the configuration for a Swift package project. You can see that the executable package template has a single target named swift-prf that builds from the files in Sources.

To change the name of the executable file, change the value of the name: of the .executableTarget to just prf. There is another name: earlier in the file, that sets the name of the entire project, you can leave that being swift-prf. They do not have to match.

Then build the project in the command line and run it directly:

> swift build
Building for debugging...
[3/3] Linking prf
Build complete! (0.51s)
> .build/debug/prf          
Hello, world!

We want to add the Swift Argument Parser package to our project as a dependency, so we can use its functionality in our code. For that, we will have to add a ‘dependency’ to the project and then to the target, as well. Modify the Package.swift file to match this:

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
  name: "swift-prf",
  products: [
    .executable(name: "prf", targets: ["prf"]),
  ],
  dependencies: [
    .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"),
  ],
  targets: [
    .executableTarget(
      name: "prf",
      dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")],
      path: "Sources")
  ]
)

This means that our project uses the package available at the given URL, and our target is going to use the specific product (or module or framework) named ArgumentParser from that package. Some packages have several products combined out of several targets.

You can find more information on the format of the Package.swift file in this overview, and the full documentation.

The next time you build after this change, it will download the repository, build and link to toward your executable. That might take a while. The next build should be much faster again. Also, a Package.resolved file will appear in the project. This file caches the current versions of the included packages protecting you from unexpected changes when a package repo dependency updates. You can force Swift Package Manager to update the file with swift package update.

Sprinkle some code

Now that we have the Swift package project prepared, we can add the code to actually do something.

First, let’s keep the ‘Hello, world!’ for a moment, but put it in the right struct to use ArgumentParser. Change main.swift to:

import Foundation
import ArgumentParser
@main
struct PRF: ParsableCommand {
  func run() {
    print("Hello, world!")
  }
}

This should build and run fine from the command line with swift build and swift run. However, when you open this now in Xcode, you will see an error: 'main' attribute cannot be used in a module that contains top-level code

This comes from a long-running issue in Swift. In older versions of Swift it appears on the command line, as well. The work-around is easy though. It only seems to appear when the @main designator is the main.swift file. We can rename our main file to PRF.swift.

You may want to close the Xcode project window before you do this because this can confuse Xcode. If you manage to get Xcode into a confused state where the project in Xcode does not match what is on disk any more, quit Xcode and delete the .swiftpm/xcode directory, which is where Xcode keeps its generated files.

> mv Sources/main.swift Sources/PRF.swift

Now the project should build and run the same with the Swift Package Manager tools and in Xcode.

Now we can add the ‘full’ code for our tool. Keep in mind that the goal of this tutorial is not to learn how to write complex swift code for command line tools, but to learn the infrastructure requires to create and distribute them, so this code is intentionally simple and basic.

import Foundation
import ArgumentParser
@main
struct PRF: ParsableCommand {
  static var configuration = CommandConfiguration(
    commandName: "prf",
    abstract: "read effective preference value",
    version: "1.0"
  )
  @Argument(help: "the preference domain, e.g. 'com.apple.dock'")
  var domain: String
  @Argument(help: "the preference key, e.g. 'orientation'")
  var key: String
  func run() {
    let plist = CFPreferencesCopyAppValue(key as CFString, domain as CFString)
    print(plist?.description ?? "<no value>")
  }
}

When you compare that to the code from the last article, there are a few differences. We are using the @main attribute to designate the main entry point for the code (this was added in Swift 5.3) and I have added some help text to the tool and argument declarations.

When you use Swift Argument Parser, you should study the documentation on adding help to [commands](I have added some help text to the tool and argument declarations. ) and flags, arguments and options. (To be honest, you should read the entire documentation, a lot has changed since the last article.)

When you now run the tool:

> swift run  
Building for debugging...
[3/3] Linking prf
Build complete! (0.54s)
Error: Missing expected argument '<domain>'
OVERVIEW: read effective preference value
USAGE: prf <domain> <key>
ARGUMENTS:
  <domain>                the preference domain, e.g. 'com.apple.dock'
  <key>                   the preference key, e.g. 'orientation'
OPTIONS:
  --version               Show the version.
  -h, --help              Show help information.

We get the help text generated by Swift Argument Parser with the extra information we provided in the code.

If you want to provide the arguments to the swift run you have to add the executable name, as well:

> swift run prf com.apple.dock orientation       
Building for debugging...
Build complete! (0.11s)
left

Or you can run the executable directly from the .build/debug directory. (This will not automatically re-build the command like swift run does.

> .build/debug/prf com.apple.dock orientation
left

Since we provided a version in the CommandConfiguration, ArgumentParser automatically generates a --version option:

> .build/debug/prf --version       
1.0

Now that we have a simple but working tool, we can tackle the main part: we will package and notarize the tool for distribution.

Preparing the binary

When you run swift build or swift run it will compile the tool in a form that is optimized for debugging. This is not the form you want to distribute the binary in. Also, we want to compile the release binary as a ‘universal’ binary, which means it will contain the code for both Intel and Apple silicon, no matter which CPU architecture we are building this on.

The command to build a universal release binary is

> swift build --configuration release --arch arm64 --arch x86_64

When that command is finished, you will find the universal binary file in .build/apple/Products/Release/prf. we can check that it contains the Intel (x86_64) and Apple silicon (arm64) with the lipo tool:

> lipo -info .build/apple/Products/Release/prf
Architectures in the fat file: .build/apple/Products/Release/prf are: x86_64 arm64 

For comparison, the debug version of the binary only contains the platform you are currently on:

> lipo -info .build/debug/prf
Non-fat file: .build/debug/prf is architecture: arm64

Apple’s notarization process requires submitted binaries to fulfill a few restrictions. They need a timestamped signature with a valid Developer ID and have the ‘hardened runtime’ enabled.

Xcode will always sign code it generates, but the swift command line tool does not. We will have to sign it ourselves using the codesign tool. You will need the full name of your “Developer ID Application” certificate for this. (Don’t confuse it with the “Developer ID Installer” certificate, which we will need later.)

You can list the available certs with

> security find-identity -p basic -v

and copy the entire name (including the quotes) of your certificate. Then run codesign:

> codesign --sign "Developer ID Application: Your Name (ABCDEFGHJK)" --options runtime  --timestamp .build/apple/Products/Release/prf

You can verify the code signature with

> codesign --display --verbose .build/apple/Products/Release/prf

Build the installation package

Now that we have prepared the binary for distribution, we can wrap it in an package installer file.

To cover all deployment scenarios, we will create a signed ‘product archive.’ You can watch my MacDevOps presentation “The Encyclopedia of Packages” for all the gory details.

First, create a directory that will contain all the files we want put in the pkg. Then we copy the binary there.

> mkdir .build/pkgroot
> cp .build/apple/Products/Release/prf .build/pkgroot/

Then build a component pkg from the pkgroot:

> pkgbuild --root .build/pkgroot --identifier com.scriptingosx.prf --version 1.0 --install-location /usr/local/bin/ prf.pkg

The --identifier uses the common reverse domain notation. This is what the installer system on macOS uses to determine whether an installation is an upgrade, so you really need to pay attention to keep using the same identifier across different versions of the tool. The --version value should change on every update.

The --install-location determines where the contents of the payload (i.e. the contents of the pkgroot directory) get installed to. /usr/local/bin/ is a useful default for macOS, but you can choose other locations here.

Next, we need to wrap the component pkg inside a distribution package.

> productbuild --package prf.pkg --identifier com.scriptingosx.prf --version 1.0 --sign "Developer ID Installer: Your Name (ABCDEFGHJK)" prf-1.0.pkg

It is important that you use the “Developer ID Installer” certificate here. The --identifier and --version are optional with productbuild but this data required for some (admittedly rare) deployment scenarios, and we want to cover them all.

You can inspect the installer pkg file with a package inspection tool such as the amazing Suspicious Package. The package file should as a signed “Product Archive.”

We don’t need the component pkg anymore, and it’s presence might be confusing, so let’s remove it:

> rm prf.pkg

Note: If you want to learn more about building installation packages, check out my book “Packaging for Apple Administrators”

Notarization

We are nearly there, just two more steps.

It is important to notarize pkgs that will be installed by a user, because otherwise they will get a scary warning that Apple can’t verify the pkg for malicious software.

notarytool submits the installer package to Apple’s Notarization process and returns the results. Use the keychain profile name you set up, following the instructions in the previous article or the instructions from the Apple Developer page.

> xcrun notarytool submit prf-1.0.pkg --keychain-profile notary-example.com --wait

This will print a lot of logging, most of which is self-explanatory. The process might stall at the “waiting” step for a while, depending on how busy Apple’s servers are. You should eventually get status: Accepted.

If you got a different status, or if you are curious, you can get more detail about the process, including rejection reasons, with notarytool log. You will need the ‘Submission ID’ from the submission output:

xcrun notarytool log <submission-uuid> --keychain-profile notary-example.com

As the final step, you should ‘staple’ the notarization ticket to the pkg. This means that the (signed) notarization information is attached to the pkg-file, saving a round trip to Apple’s servers to verify the notarization status when a system evaluates the downloaded installer package file.

xcrun stapler staple prf-1.0.pkg
Processing: /Users/armin/Desktop/swift-prf/prf-1.0.pkg
Processing: /Users/armin/Desktop/swift-prf/prf-1.0.pkg
The staple and validate action worked!

And with that, we have a signed and notarized installation pkg file! You can verify this with spctl:

> spctl --assess --verbose -t install prf-1.0.pkg 
prf-1.0.pkg: accepted
source=Notarized Developer ID

Automation

While it is instructive to do this process manually, it is also quite complex and error-prone. If you have been following this blog for any time, you will know that I don’t stop at detailed step-by-step instructions with explanations.

You can find a script to automate all of these steps here. The enclosing repository includes the entire project (all three files) for your reference.

There is a section at the beginning with variables to modify with the information specific to your environment and project, such as your developer ID information and the name of the credential profile for notarytool. Then there are a few variables, such as the product name, and the installation package identifier.

Run the pkgAndNotarize.sh script from the root of the Swift package project directory.

./pkgAndNotarize.sh

The script creates the installer pkg file in the .build directory. The last line of output is the path to the final, signed, notarized and stapled pkg file.

The script mostly follows the process described above, with a few extras. For example, the script determines the version dynamically by running the tool with the --version option. It also uses the modern compression options I described in this post.

If any of the steps in the script fail, you can determine what exactly failed from the output, error message and error code.

(I belief that this could probably be a makefile, but I have no experience with that (yet). I guess I will need to ‘make’ time for this…)

Conclusion

Apple provides developers and MacAdmins with amazing platforms and tools to build all kinds of powerful apps, tools and automations. But then they don’t really document any of the processes or best practices at all. The amount of searching, guesswork, and frustrating trial and error necessary to piece all of this together for a workflow like this one is quite the shocking condemnation of Apple’s documentation.

There are glimmers of hope. The documentation for the notarization process and notarytool are exemplary.

But they only cover one piece of this puzzle. A developer building a tool has to still figure out how to

  • sign all the binaries properly
  • assemble the binaries and resources necessary into an installation package payload
  • how (and when, and when not) to use pre- and postinstall scripts
  • which kind of package installer to build and which tools to use
  • securely manage the Developer ID certificates (this is especially challenging for developer teams)
  • automate this workflow with Xcode or Swift Package Manager or a CI/CD system

MacAdmins often complain about poorly built installer pkgs, and often for good reasons. But to be fair, there are no best practices and little to no documentation for this from Apple. How are developers supposed to know all of this? Most MacAdmins can define what an installer package should do and not do, but wouldn’t be able to explain to a developer how to build such an installer package, let alone integrate that into their build automations. And most developers don’t even know a MacAdmin to ask about this.

Apple requires that developers create signed and notarized archives for software distribution. And I agree wholeheartedly with their motivations and goals here. But when impose requirements for distribution, you have to make the process of creating the installers the correct way easy, or at least well documented, whether you use Xcode or a different tool set, whether you want to distribute a simple self-contained app, a single command line tool, or a complex suite of tools and resources.

Apple has their work cut out to improve this. Official best practices and sample workflows for installer creation and distribution that consider and respect the requirements of MacAdmins for deployment, have been disgracefully lacking for a long time. The more requirements and security Apple piles on to application and tool distribution, the more desperately they need to provide documentation, best practices and guidance.

Until that happens, you have my paltry scripts.

Build a macOS Application to Run a Shell Command with Xcode and SwiftUI – Part 2

A long time ago, I wrote a post on how to build a simple App using SwiftUI that would run a shell command. Amazingly, the tutorial still works!

This is particularly useful for MacAdmins, because we sometimes want to provide a simple UI for some command or script. However, some things have changed since I wrote this tutorial three years. While the code still “works,” there are some things that can be improved. Also, when I wrote the original post, there were some features I didn’t expand upon, because the post was already very long, and, to be honest, back then, I didn’t really know how they worked, myself.

When I re-visited this earlier because of a question in the MacAdmins Slack, I was really surprised that I didn’t need to update code to make it work. That said, there are the matters I didn’t explain last time. Also Swift has changed in some ways. That means it is time for the second part.

You can get the sample code from the first part as a starting point.

Get the output of the command

In the first part, we ran the say command, which doesn’t have a text output. Often times we want to run shell commands to get information from the command’s standard output (or sometimes standard error). To grab the output of commands, we need to prepare the Process object, which means we cannot use the convenience method Process.run() any more.

The say command has an option to change the voice being used. You can list the names of voices available to the say command by running say -v '?' in Terminal. We’d like to get that output and populate a menu with the available voices in our UI.

Note: the output of the say -v '?' command does not show most of the modern voices available in macOS. Most of the voices listed are quite old, funny, and, to be honest, awful. As has been mentioned before, I am using the say command as a convenient example for a shell command. If you want proper speech synthesis in your app, you should not use the say command but the proper speech synthesis API.

We will start out experimenting with code in a macOS Playground in Xcode and later add it to the app from the first part. When you create a new Playground in Xcode (File > New > New Playground), make sure you select ‘macOS’ for the platform above the template picker and then ‘Blank’ as the template.

Start with this code:

let process = Process()
process.launchPath = "/usr/bin/say"
process.arguments = ["-v", "?"]
let outPipe = Pipe()
process.standardOutput = outPipe
process.terminationHandler = { process in
  let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
  let output = String(data: outData, encoding: .utf8) ?? ""
 print(output)
}
try? process.run()
process.waitUntilExit()

You will recognize some of the code from the first part of this tutorial. We create a Process object and set its launchPath and arguments. Note that the Process.run() convenience method, which we used in the first part, takes a URL to define the executable, but the launchPath is a String containing the path to the executable. We also (this is the new part) create a Pipe and set it as the standardOutput of the process.

The process will run the say command asynchronously. Since we want to work with the output , we have to give the process some code to execute when the command is done. We set this terminationHandler to a closure. The code in this closure grabs the data from pipe we connected to standardOutput of the process, converts it to a String and prints it.

At the end of our code, we tell the process object to run(). The waitUntilExit() is necessary here, because that is all this particular playground code does and we want to be sure that the command and the termination handler get a chance to do their work, before the code ends. In an app, where many things may be happening at the same time, you usually will not use waitUntilExit(). We will see that later when we implement our solution in SwiftUI.

Many outcomes

This code grabs the standard out from the command. Unix commands have different results that can be interesting. Some commands print to standard error instead of (or together with) standard out. All unix commands also have an exit code, which should return 0 for successful runs and a non-zero value for failures. Some commands use different non-zero value to give information for different errors.

All of this is available from Process objects. However, you have to set up the pipes and connections and get the data back in the termination handler, and the code gets quite complicated and tedious to set up. We have the seen the Process type has a convenience method to create and run a command without the pipes. I don’t know why it doesn’t have a convenience method when you are interested in all the data, but the good news is, we can create an extension to build our own.

Replace the code in the playground with the following:

import Foundation

extension Process {
  @discardableResult
  static func launch(
    path: String,
    arguments: [String] = [],
    terminationHandler: @escaping (Int, Data, Data) -> Void
  ) throws -> Process {
    let process = Process()
    let outPipe = Pipe()
    let errorPipe = Pipe()
    process.standardOutput = outPipe
    process.standardError = errorPipe
    process.arguments = arguments
    process.launchPath = path
    process.terminationHandler = { process in
      let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
      let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
      let exitCode = Int(process.terminationStatus)
      terminationHandler(exitCode, outData, errorData)
    }
    try process.run()
    return process
  }
}

let process = try? Process.launch(
  path: "/usr/bin/say", 
  arguments: ["-v", "?"]
) { exitCode, outData, errData in
  let output = String(data: outData, encoding: .utf8) ?? ""
  print(output)
}
process?.waitUntilExit()

You will recognize the code from our first example in the launch function in the extension. But here, we have added the configuration for a second Pipe for standardError, in the closure we get the Data for the standard out, standard error and the exit code and pass them in to the closure passed in a termination handler.

This method simplifies using the Process type. We just have to pass in the path to the command and the arguments array and give a closure that is called when the command completes.

async/await

But it still uses a closure for the termination handler. This breaks our code into different fragments that are not executed in the order they appear in. Since macOS Monterey 12 and iOS 15, Swift has had a concurrency feature called async/await. With this, your code appears in a more natural order.

When a function has the await marker, the system knows to suspend the code at that point until the function returns a result. While this code is “on hold” other threads or tasks can run, such as the UI handling, so your process or app isn’t blocked. When the function returns, the code continues after the function, so the code that processes the output of the function comes in the logical order, which makes it easier to read and understand.

For some reason, the Process type has not yet been updated to use this new feature. We can however, add this functionality using an extension. Add this method to the extension:

  static func launch(
    path: String,
    arguments: [String] = []
  ) async throws -> (exitCode: Int, standardOutput: Data, standardError: Data) {
    try await withCheckedThrowingContinuation { continuation in
      do {
        try launch(path: path, arguments: arguments) { exitCode, outData, errData in
          continuation.resume(returning: (exitCode, outData, errData))
        }
      } catch let error {
        continuation.resume(throwing: error)
      }
    }
  }

If you want to get more detail how async and await work, I recommend the WWDC session “Meet async/await in Swift” from WWDC 2021.

With this, we can change our code to run the say command to:

guard let (exitCode, outData, errData) = try? await Process.launch(
    path: "/usr/bin/say",
    arguments: ["-v", "?"]
) else { exit(0) }

let output = String(data: outData, encoding: .utf8) ?? ""
print(output)

The code is now in a more sensible order. If you can afford macOS Monterey as a minimum system requirement, you should consider adopting async/await.

Now that we have our output of the command, we have to parse out the names of the voices. This code will turn the output of the say into an Array of names, ignoring the language code and sample text:

func parseVoices(_ output: String) -> [String] {
  output
    .components(separatedBy: "\n")
    .map {
      $0
        .components(separatedBy: "#")
        .first?
        .trimmingCharacters(in: .whitespaces)
        .components(separatedBy: CharacterSet.whitespaces)
        .dropLast()
        .filter { !$0.isEmpty }
        .joined(separator: " ")
      ?? ""
    }
    .filter { !$0.isEmpty }
}

So we can add these lines to get an Array of `voices:

let voices = parseVoices(output)
print(voices)

You can find the stages of code for the playground in this gist.

Updating the app

Now that we have assembled all the pieces working in a playground, we can move on to putting these pieces in our SwiftUI app. The goal is to have a popup menu with all the voices above the field where you can enter text.

We will be using some features that were introduced in macOS Monterey 12.0. If you have built the project a while ago with an older version of macOS, the project may be set to build with older versions of macOS. To verify and, if necessary, update the deployment target for the project, select the blue project icon at the very top of the item sidebar, then select the gray “SayThis” icon under the “Targets” section. Then select “General” in the tab list. The second section is called “Minimum Deployments” and should show macOS 12.0 or later. When you change this from an older version, Xcode may prompt to update some other settings in the project, you can accept those.

First, we want to use the extension to the Process type we created in the playground earlier. Create a new file in the project (File > New > New File…), select “Swift File” from the templates, and name it ‘Process-launch’. Copy and paste the extension code from the playground (with both methods) to that file. You could have all the code in a single file, but it is cleaner and more maintainable to have one file per type or extension. This also allows you to copy that file to other projects to use it there.

Next we need two more state variables to track the state of the popup-menu that we are going to add. In the ContentView file, add these two lines right under the existing @State variables:

  @State var voices: [String] = []
  @State var selectedVoice: String = "Samantha"

The first is where we are going to store all the available voices that we parse from the output of say -v '?'. The second variable will store the current selection.

Next, we will insert the popup menu for the voices into the UI. In the body, insert this below the title and above the HStack:

      Picker(selection: $selectedVoice, label: Text("Voice:")) {
          ForEach(voices, id: \.self) { Text($0) }
      }

First we create a Picker view, which is not really proper terminology for macOS. But since SwiftUI is designed to share objects across all platforms, a ‘Picker’ will render as a popup menu in macOS. We attach the selectedVoice state variable to the selection of the picker. Then, we loop through all the items in the voices state variable to add them to the picker.

At this point, you should see the popup menu in the preview and when you run the project. You cannot select anything from it, though, which makes sense as the voices array is empty.

We need to populate the voices array before the UI appears. SwiftUI offers a special modifier to do this. You can attach a .task modifier to any view and its closure will be run asynchronously once, before the view appears for the first time. Add this code to the end of the VStack block, right below the line that reads .frame(maxWidth: .infinity, maxHeight: .infinity):

    .task {
      guard let (_, outData, _) = try? await Process.launch(
        path: "/usr/bin/say",
        arguments: ["-v", "?"]
      ) else { return }

      let output = String(data: outData, encoding: .utf8) ?? ""
      voices = parseVoices(output)
    }

You also need to copy the parseVoices() function from the test playground and add it to the ContentView struct.

This will populate the popup menu with all the voices. Build and run to test. It will still not actual use the selected voice. We need to update the runCommand() method to:

  func runCommand() {
    let arguments = [message, "-v", selectedVoice]
    let executableURL = URL(fileURLWithPath: "/usr/bin/say")
    self.isRunning = true
    try! Process.run(executableURL, arguments: arguments) { _ in
      self.isRunning = false
    }
  }

We have achieved what we set out to do. You can find the project with all the code for this point here.

Refinements

However, there are some refinements and improvements that we can and should still add to the code.

First, the runCommand() function still uses the old convenience handler with a termination handler. We can change it to use our new await Process.launch():

  func runCommand() async {
    let arguments = [message, "-v", selectedVoice]
    self.isRunning = true
    let _ = try? await Process.launch(path: "/usr/bin/say", arguments: arguments)
    self.isRunning = false
  }

This will now generate an error in the Button because we have changed the runCommand() function to be async. Change the code of the Button to

        Button("Say") {
          Task {
            await runCommand()
          }
        }
        .disabled(isRunning)

By wrapping the await runCommand() function in a Task { } we are telling the system that this code should run in the background and not return immediately. This is necessary to run code blocks with async functions from UI elements.

The say command will use the voice set in the system settings when no -v option is given. We want to provide a way to recreate that behavior in our UI. We can add items to the Picker by adding elements before the ForEach:

      Picker(selection: $selectedVoice, label: Text("Voice:")) {
        Text("System Default").tag("System Default")
        Divider()
        ForEach(voices, id: \.self) { Text($0) }
      }

Also change the default value of the selectedVoice variable to "System Default".

Then we have to change the runCommand() method to only use the -v and voice name arguments when their value is not “System Default”

  func runCommand() async {
    var arguments = [message]
    if selectedVoice != "System Default" {
      arguments.append(contentsOf: ["-v", selectedVoice])
    }
    self.isRunning = true
    let _ = try? await Process.launch(path: "/usr/bin/say", arguments: arguments)
    self.isRunning = false
  }

You can find the code for this here.

Out of the Sandbox

The say command provides a fun example to play around with. You will probably already have plenty of ideas that you want to try out. There is one thing that might trip you up here: sandboxed apps do not get access to all available command line tools. If you want to run such ‘prohibited’ commands, you will need to remove the sandbox configuration from the app.

Better Extension

The extension I put together here will work in most situations, but there are conditions where different parts might not work well. Quinn the Eskimo has published this code which goes further and is probably “even more” thread safe.

Launching commands is an “expensive” task. You should always do some research to see if there is some framework that can give you access to the same functionality natively in Swift. Nevertheless, sometimes it might be necessary, and sometimes it might just be easier to call out to an external command.

This tutorial should help you along the way of using these tools effectively.

Swift UI Tutorial – Part 3

The big conferences expect you to provide a topic and a brief description several months in advance. This can be challenging as you have to pick something that you think will still be interesting in eight months or so, even if there is a WWDC and the larger part of the beta phase of a new macOS and iOS version in between. It also has to be able to hold my attention for about eight months, which is not an easy requirement.

I usually try to meet that challenge by choosing something that is complex enough that it has a large likelihood of remaining relevant. For this year’s JNUC, I chose “Use Swift with the Jamf API.” This would very certainly remain relevant, as both Swift and the Jamf Pro API were certain to remain in existence. I was also not expecting too great changes in either and if there were smaller changes—both Swift and the Jamf API update regularly—I was confident I would be able to handle them.

It also covered another goal I have for my presentation: it was something I didn’t really know at the time and wanted to learn more about. I had dabbled with SwiftUI before, most prominently for my 2021 MacSysAdmin Online presentation “Let’s Swift Again” and I had also tried myself with using the Jamf API from Swift. But the new concurrency features of Swift 5.5 looked like they could make the code much more interesting.

My hunch was correct. But, even though the new concurrency features simplified the code, using Swift to retrieve and work with objects from the Jamf API still remained quite a bit more complex than doing so with curl in a shell script. A JNUC session was supposed to fit into 30 minutes.

So, I hatched a crazy plan. To remain in the 30 minutes, I would just superficially introduce the most important concepts in the sessions, and then publish a tutorial and some sample code that would explain the details. Ideally, the tutorial would publish around the same time as JNUC.

Even early in putting together the session slides and sample code, I realized, there is far too much to explain for a single post. So it would become a series of posts. No problem, I have done that before. I suggested the series to Jamf marketing and they were happy to go with it, so I was committed.

I did manage to get the first part done and published in time for JNUC. Then the work that piled up over being away for the conference struck and it took a while to get part two out. Part three was published yesterday. The project is starting to take form and is diving into some really essential, but also exciting features of Swift.

I am polishing part 4 right now and will send it to the great people who run the Jamf blog for editing and more polishing soon-ish. I am working on the sequels, where we finally, actually will get into the SwiftUI part of the tutorial. I expect there to be seven parts in total, though this project may have more surprises for me yet.

I think this worked out well, even though it certainly turned out to be far more complex and far more work than I had originally anticipated. I have certainly learned a lot along the way, so that goal was achieved! I hope you will enjoy this series as much as I did writing it. (Some people like it.)

Next year, maybe I will go for something less complex… maybe…