Arborist 1.1 is out

What a whirlwind the last week has been since I released Arborist. I’ve gotten lots of positive comments, and it was amazing to see myself featured on Michael Tsai’s blog, the RevenueCat Engineering blog, and Brett Terpstra’s Web Excursions. Thank you to everyone who has talked about Arborist and helped make the launch special!

Today I’m releasing Arborist 1.1 with support for Grove, a customizable terminal picker, big improvements to iCloud login for licensing, and more.

The first feature request I got for Arborist was support for Grove, which is a tool that I had never heard of but found that it complements Arborist quite nicely. The idea is that Grove can keep your main repo and all the feature branches you’re working on grouped together in a top-level directory, with an optional .groverc file that can define things like branch name prefixing and a set of commands to run when creating a new worktree. I think this would be especially helpful for me since I have a monorepo with mise as my main task runner, and Xcodegen as my project generator. I could see using Grove to have Arborist automatically run mise trust and xcodegen to get my new checkout up and running automatically. It’s been a really fun tool to learn about and I’m really happy with how its integration came together in Arborist.

Another feature request was for being able to customize the terminal application opened when resuming a Claude Code chat. Arborist 1.0 only supported the built-in Terminal.app, and 1.1 adds support for Ghostty and iTerm2 as well. I looked into Prompt support because I love Panic’s apps but it doesn’t appear that there is a way to open a new Prompt window to a location on the file system.

The other major feature that I’ve been able to ship is something I mentioned in my licensing blog post about iCloud. In 1.0, Arborist leaned on the Mac having a logged-in iCloud account in order to purchase a license. In 1.1 I’ve been able to add a web login to any iCloud ID (iCloud is still required to buy) so that you can use Arborist on a Mac that either has no logged in iCloud account or has an account other than the one you bought a license on. This has been helpful to me with my Adobe-issued work machine; once I logged in to my personal iCloud account in Arborist my license instantly activated, which is exactly what I was going for.

I’m very happy with how this release turned out, and there’s more in this release too – bug fixes and smaller tweaks – that you can read about in the full release notes.

My New App: Arborist for the Mac

Image

TL;DR: My new app Arborist is now available for download and purchase!

Over the years, as my workflows have evolved, my tools have somewhat struggled to keep up. In any project I work in, I like having multiple checkouts — maybe I’m building a feature, fixing a bug, and doing a code review. Not necessarily at the same time (as there lies madness in the multitasking), but in different places on my drive so I don’t have to stash what I’m doing and restore it later. This workflow evolved from multiple clones to git worktrees. Worktrees are great because they let me create a whole new checkout on my local machine without having to wait for a clone. I love how fast they are, and because they all share a common .git root I can easily move between branches and cherry-pick commits if I need to.

All of this came with a cost I wasn’t expecting as I started accumulating non-trivial shell commands to run in my repo: there’s the Python script at work which pulls binary dependencies, or the long fastlane incantation to make a build, and many others. These commands often require context which works well in a single-tree repo, but when I had multiple checkouts or worktrees I’d have to redo the setup for any location I was working in. I started to wonder if there’s some way to manage all this.

That idea has clanked around in my head for a while and finally I have an answer: Arborist. It’s my new Mac app which lets me take control of worktrees across my system and run any command I want. I’ve been building this app and using it every day for a while now; today it’s available for everyone running macOS 15 and above.

Image

I’m really happy with how Arborist has turned out. It’s chock full of features:

  • Drag and drop your git repos and it automatically picks up all the associated worktrees and lists them right in the sidebar. The current branch is displayed right under the worktree name.

  • Rearrange and collapse sidebar items to get things organized just the way you want.

  • Create and delete worktrees with ease. Tower added worktree support a while back and while I have used that feature I haven’t loved it, because it feels like adding a whole new repository. I wanted to make worktree management seamless, and that’s just how it is in Arborist. Also, AI agents love to use worktrees but they name them the same as the repo so I’ve been stuck at times with 3 or 4 identically named locations and I don’t know why each of them is there. I can take the power back with Arborist.

  • The middle column is the superpower:

    • The commands area stores any terminal commands that you want to run in your repo, and they’ll work across checkouts. I use this for both quick and longer tasks and it’s the killer feature of the whole app in my view. There’s a small console that pops open and shows the terminal output in real time. It’s fantastic!

    • Tools – at the top of the column – are the apps that you use no matter which repo you’re working in. I’m a big fan of Panic’s Nova for my non-Xcode projects, and I want to be able to open any worktree in Nova with a single click. Arborist lets me do just that.

  • The shell environment that both commands and tools run in is fully customizable in Settings, and you can see the environment variables that Arborist uses to run commands and launch tools.

Image

  • The inspector pane on the right side gives me details about the checkout I’ve got selected. The best part about it is the conversation recall for that worktree so I can see the sessions I’ve had with Claude or Codex.

    • Clicking on a conversation launches you right back into that conversation. This proved really valuable to me when I couldn’t find a conversation in Codex but I could resume it in Arborist.

    • The sessions are discovered by reading the local files in their known locations, and no data about these conversations ever leaves your machine. Arborist is 100% privacy-focused.

I’ve worked really hard to make Arborist Mac-Assed as well, with good keyboard support and a squircle-free alternate icon to boot (I freaking love that icon and use it myself on my machine). I’ve loved the Mac platform since the early ’90s and I want to make a Mac app that Mac people will love to use.

In my last post, I wrote about using RevenueCat for my licensing. I’m really happy with how that turned out, and I think the ease of tying a license to an iCloud account will make for incredibly smooth transfers to new machines.

Arborist comes with a 14-day free trial, and after that costs $39 for a full-feature license to v1 and all the updates in the v1 family. If and when a v2 comes, there will be a discount available to v1 owners (I don’t know when a v2 will ship, that’s all way in the future at this point).

I hope you give Arborist a try and that it helps you take control of your workflows.

Licensing a Mac App with RevenueCat

I’ve been hard at work at a new Mac app called Arborist lately, and one of the key decisions has been how to monetize it. Arborist is not an app that can be sandboxed, so putting it in the Mac App Store was never a real consideration (it runs commands entered by the user at arbitrary locations, which means sandboxing is right out). I had thought that I would make it a subscription app at first, but after a lot of thought I settled on a one-time version 1 purchase for $39 USD (just like the Software Days of Yore).

Then came the question: how do I do licensing on my own?

I know there are options out there like Paddle, FastSpring, and Lemon Squeezy that have worked for many apps like mine over the years. But I also wanted something simple for my users. Not to mention Apple Pay was a must-have. I’ve experienced friction more times than I can count when an app doesn’t support Apple Pay, and every time it happens I am ever so slightly less happy with that app because of it. Customers are also more familiar with the smooth in-app purchase experiences provided by the App Store, and if I could achieve something that easy I wanted to do just that.

I also happen to be in a couple of Slack workspaces with the wonderful and friendly Dave DeLong. Dave started working at RevenueCat recently and he also has a passion for good Mac apps. He mentioned in a discussion about Mac app licensing that he would love to work with someone using RevenueCat’s web SDK to power that experience – and my gears started turning.

After collaborating with Dave and some trial and error I was able to land on a somewhat novel – and simple – licensing implementation:

  • RevenueCat serves as the source of truth for whether or not a customer is licensed.

  • I create RevenueCat customer IDs from local iCloud identifiers (more specifically, calling userRecordID() on my CKContainer in CloudKit).

  • When the user makes a purchase, they earn an entitlement that I look for in Arborist to grant them a license.

The big bullet of note there is the second one. I didn’t want to have to spin up a licensing server to send out codes (much less have to store them), and I didn’t want to have an email-based system. I wished for something simple like StoreKit but without using the App Store as a backend. I had to implement some kind of identifier that I could tie easily to a user and I wanted that to be as transparent to the user as possible. Hence, userRecordID() made complete sense. This also allowed for “license sharing” across all of a user’s devices. So as long as a customer is logged in to the same iCloud account on a device that they made a purchase on, they’ll be licensed for Arborist. I like the simplicity there. One other great thing about this is that I don’t have to collect any personally identifying information about my customers directly. Stripe handles the payment process, and the iCloud’s identifiers give nothing away.

The eagle-eyed among you may notice that my entire licensing strategy hinges on the customer being logged in to iCloud. That’s true for my 1.0. I don’t think this is too big of a leap to make for a power-user app like Arborist. But I am working on a way to sign in to iCloud using the web so that folks who don’t have iCloud set up on their machines can still buy a copy. That should land not super long after 1.0, but it does have some more moving parts to it and so I deferred it to post-launch.

Setting up the backend

Getting these details in place was not immediately obvious to me, and I actually went down one path first before partly reverting and going another direction. At first I set up RevenueCat Billing, but that ended up being the route I didn’t want to take. I had never heard the term merchant of record (MoR) before and when I first did I didn’t know it was something to care about (spoiler alert: it very much is). RevenueCat’s product does not take care of being the MoR for me and thus would leave me on the hook for things like taxes and refunds. Being a solo developer embarking on my first direct-sale app this became a dealbreaker.

Thankfully Stripe has a service called Managed Payments which does handle this for me and even better is that RevenueCat plugs right into that system out of the box. So after doing some research it seemed the better way for me to go.

On the RevenueCat side, I will say that I am consistent in forgetting how to set up their chain of values. I have used RevenueCat for Baseplate and Decoder previously but getting set up for Arborist definitely tripped me up.

  1. In RevenueCat, create the entitlement that Arborist will look for to ensure the user is licensed for v1.x.

  2. Add the product in my Stripe dashboard (ensuring it meets the criteria for managed payments).

  3. Back in RevenueCat, I had to set up Stripe as a web provider. There’s a box to check to use Managed Payments when available that I had to check as well.

  4. Create the RevenueCat product by importing it from my Stripe web provider.

  5. Add a new offering which has the product inside with a lifetime duration. This will give me effectively a one-time purchase (there is a subscription “end date” that comes along in the SDK but it’s for 200 years from now so if a customer needs a refund in 200 years I probably won’t be able to help them).

  6. That offering then gets a Web Purchase Link. This URL is what I then use to direct users to purchase in the app. It lets me specify things like the callback URL for when a purchase is complete.

I’m listing these steps as much for your information as I am for Future Me in the hopes that we can all do this a little smoother the next time 😀.

So the whole flow goes from Settings -> License -> Buy -> Safari -> Complete purchase -> Back to Arborist -> Refresh from RevenueCat -> Done. My hope early in the process was to avoid the Safari hopping, but doing so meant that I could not offer Apple Pay. This is a known WebKit bug from 2 years ago about Apple Pay not working in WKWebView. It’s a real bummer because keeping the flow in the app was so smooth when I had it working that way, but I figured the friction of going to Safari to do Apple Pay and then hopping back was much less than having to input all your payment details in the app.

An Aside on Taxes

One thing that has scared the daylights out of me in all this is sales taxes. I’ve never had to collect them before, let alone distribute them back to a government. Throw in dealing with non-US countries and that’s even more terrifying. I was originally looking at Stripe Tax to take care of things for me but something felt strange as I was exploring that route. I couldn’t ever get it working in the sandbox environment and when I did some more research I was still going to have to file on my own. Thankfully I found Managed Payments and from everything I can tell, making Stripe my MoR puts all the burden of these collections and distributions on Stripe. So I’m happy to give them a few extra percent of each sale to handle that for me.

Hooking up the app

The last piece to complete the puzzle is hooking all of this up in the app. It’s important to note that while the RevenueCat SDK is great, it’s also geared towards apps which use StoreKit. For my needs I’m not using StoreKit, so fetching things like my Offering can’t be done with their library. This means that I have to hard-code the URLs I need to hit (and I have both production and sandbox URLs in there for testing). So when a user clicks the buy button, this happens in my licensing view model:

func startCheckout() async {
    guard isStartingCheckout == false else { return }

    isStartingCheckout = true
    actionMessage = nil
    defer { isStartingCheckout = false }

    do {
        let checkoutURL = try await licenseManager.checkoutURL()
        guard NSWorkspace.shared.open(checkoutURL) else {
            throw LicenseCheckoutOpenError.failedToOpenBrowser
        }
        actionMessage = "Checkout opened in your browser."
    } catch {
        displayState = LicenseDisplayState.resolved(
            from: nil,
            previous: displayState,
            error: error
        )
    }
}

There’s some state to handle to show the user that the process has started, we assemble the checkout URL (which looks something like https://pay.rev.cat/{web_link_id}/{user_id}) and that gets kicked out to the system in their browser of choice with the NSWorkspace API call.

When a purchase is successful on the web, RevenueCat’s backend will associate the user ID that I passed in my link above with the entitlement in step 1 above and call back Arborist with the registered deep link I put in the web purchase link. The callback handler then runs a re-fetch of the customer and validates that the entitlement to unlock the license exists:

func handleDeepLinkPurchase() async throws {
    let customerInfo = try await Purchases.shared.customerInfo(fetchPolicy: .fetchCurrent)

    guard
        let entitlement = customerInfo.entitlements[entitlementID],
        entitlement.isActive
    else {
        // Handle a missing entitlement; this means the
        // transaction did not succeed
        return
    }

	// Once we get here we have a validated customer who has
	// made a purchase and the app can be unlocked.
}

Don’t miss the first line of that method: Purchases.shared.customerInfo(fetchPolicy: .fetchCurrent). The fetch policy here is critical because without specifying .fetchCurrent the SDK will return cached data, and if a customer has made a purchase that won’t be reflected instantly like they’ll expect. Instructing the SDK to bust out of its cached data is the thing that will make this process feel seamless to my customers (and yours!).

There’s also code that looks a lot like this which runs on app launch to validate a user against their iCloud ID. If this is their first launch of Arborist and they have bought a license using the same iCloud account on a different Mac then Arborist will be unlocked automatically (there’s no “Restore purchases” button or workflow to even worry about!).

Wrapping Up

I think this process has gone about as smoothly as I could have hoped, given that I have never sold a Mac app directly before. I’ve also never seen anyone attempt to use iCloud as a customer identifier. So there’s still some nerves as I approach July 20, when I’m going to be launching Arborist. I know I’ve done my best to get here and if everything goes sideways on launch day, it’s still just the first day of my journey selling this app.

I want to give a lot of thanks to RevenueCat’s team, particularly Dave DeLong, Ed Shelley, and Delia Behr for being great partners in helping me get set up technically as well as on the content side with this blog post. I’ve long been a fan of RevenueCat’s and for someone running an operation of my size I’ve yet to have to pay them a nickel.

I’ll follow up post-launch with a retrospective for how things have gone and how this licensing plan has worked out. If it’s as transparent as I hope it is I think this will work out nicely.

The Trials and Tribulations of a Blog Engine Update

This weekend has been quite the ordeal. In an effort to distract myself from having to say a permanent goodbye to our beloved cat Molly I decided to dust off the code to Maverick – my blog engine – and update all the things: from Swift 5.3 -> 6.2 and the newest versions of Vapor and its dependencies. It’s been a few years since I’ve worked on Maverick so I figured there was some work to be done.

Boy, was I right. And boy, did it get more complicated than I thought it would.

Honestly, in the whirlwind of emotions I don’t know if I remember all the steps I went though. It did lead to my first Swift Forums post because I was getting crashes I couldn’t make heads or tails of. The first part of it had to deal with Swift’s Docker images (or libraries that I link to in newer versions of Swift, it’s hard for me to tell) using “CPU flags” – a term I’m not familiar with – which my Digital Ocean server doesn’t have.

I was able to solve that by statically linking the Swift standard library in my executable. At least I think that’s what solved it. Then came thread creation crashes, and then Docker permission crashes. What I ended up doing was this:

  • Created a new server, and took the opportunity to automate that process. The automation part wasn’t strictly needed, but it’s one of those things that had been in my head a while so I took the chance to do it.

  • Simplified my SSL approach. If I was going to create a new server to check if it built there, I would need to update my infrastructure because it built all the SSL pieces for me. I didn’t have a way for it not to do that. Instead I went with Cloudflare and removed SSL from the game entirely. This simplified a lot of my spin up process and made it identical whether in production or locally on my Mac. This was a big win.

  • Because It’s Always DNS, I had to point my nameservers at Cloudflare (and Cloudflare at my new server too). My old server was too old – 7 years at this point – so it was time to make the switch there too.

  • Once I deployed the new server I got some Swift runtime crashes in my code (a first for this project) and I had to fix that by updating permissions of some mounts that get made in my app’s container. The problem was Maverick couldn’t read the site’s configuration and crashed. Once the permissions were set, my site was happy again.

There are probably steps along the way that I’m missing, but the gist is that my blog is back after a couple of days down. The next time I update the engine I’ll be dang sure to do it on a test server first. Live and learn.

From Zero to App in 18 hours

Updated to include the prompt that I used

Over the years I’ve always been very impressed when I hear stories of developers building and shipping an app very quickly. But for me that experience had never come together – getting the idea (which I could execute quickly), actually making the app to a standard that I’m happy with as a simple 1.0, updating all the marketing materials, and the 28 other steps that all go in to making a release. It’s a lot of work!

However, on Monday last week things came together. And I’d say it was a highlight of my Christmas break from work.

TL;DR: I’ve got a new Mac app – Decoder. It will use your Mac’s camera to scan any kind of code, barcode, QR code, Starbucks gift card, etc.. Check it out at https://taphouse.io/decoder.

Backstory

My wife Emily had gotten a bill in the mail that had a QR code on it so that you can pay by phone. However (and I’ve had this feeling often as well) entering the data associated with the bill is easier done on the Mac. The problem is that the Mac has no built-in QR code scanner, unlike the iPhone. So I went searching on the App Store for an app that could help her utilize her webcam to scan the code. There are a couple out there but neither seemed all that great.

I’ve been working with AI coding tools more (Claude Code and Codex are my current weapons of choice) and so I created a new git worktree in my monorepo and opened Claude code to it and gave a simple prompt:

I’d like to make a new app for the Mac which turns on the camera and lets the user scan a QR or barcode. Let’s call the app Decoder. Take a look at the pattern of my other apps and make a new one in the Apps directory with an Xcodegen definition file. The app should also follow common Mac patterns and fit in well with the rest of the ecosystem.

Once we have the basic shell up and running we can talk about other features.

The result took just a couple of minutes and out popped an app that interacted with the camera and scanned the code. I showed Emily the app and she confused it with one that I had sent her way (she had already paid the bill, I was just having fun). Her surprised look when I said that I just made this was a lot of fun to see.

Claude’s Initial Output

I have had a bit of experience with Claude over the past year, and I’ve built up a decently extensive set of rules in my .claude folder. But I have to say that the initial output from my meager prompt above was very good. It made an XcodeGen yml manifest similar to my other apps, and I was able to easily generate the project the first time. My monorepo is anything but conventional but it works very well for me. Claude plugged in to it and added the new app structure seamlessly.

The SwiftUI code it produced also was really good. It produced easy to read and reason about views, and the backing models were all in the modern @Observable pattern. I didn’t think it got too clever about anything. As a first examination and running of the output I’m very impressed.

Polishing for Distribution

I figured that it could be fun to see what it might take to get this app on the App Store. There were some missing niceties like history preservation and a nicer UI for when the camera is off. For history between sessions I leaned on the swift-sharing package from Point Free. It added the package to my XcodeGen manifest and integrated it perfectly (I was able to point to my example implementations in Baseplate as a pattern).

Next came the app icon. I’ve used Bakery in the past to make quick icons using SF Symbols, but that’s disallowed in the App Store. Claude can’t create images, but it can create SVGs. Since Decoder is basically a full-window code scanner I came up with the concept of a Mac window with a QR code inside and Claude generated the code to make an SVG. This process actually took quite a few iterations but I got to a place I was happy enough with. The biggest creative change I had to make was putting the macOS window stoplights in the center of the title bar because otherwise Tahoe’s roundrect clipping would slice the red one right off. It took a few iterations to get the sizing correct for Icon Composer so that the SVG would fill the icon but we got there. I’m actually pretty happy with how the icon turned out.

Image

Business Time

While I wanted Decoder to be free, I also knew that having a way to get a few dollars for it would be nice. I’ve used RevenueCat with Baseplate already and I’ve seen other apps implement a tip jar feature so I asked Claude to add a tip jar, with a primary action button in the toolbar. Claude spun for a little bit and did a very nice job of producing a view with buttons that can act as individual “tip” buying buttons, hooked up to RevenueCat (after having added RevenueCat’s SDK to my XcodeGen manifest).

The next step was to add the identifiers for my in-app purchases to App Store Connect and hook them up to the RevenueCat backend. I did these steps by hand because I’m not sure if there’s a way to do them using automation.

Web Updates

The next piece of the puzzle to ship Decoder to the App Store is a website. I have a decent if not overdone process for the taphouse.io website – it’s a Vapor site that could absolutely be static but is not. I use Leaf for the templates and have one shared template for both Baseplate and Scorebook. I didn’t want to go overboard with screenshots so I decided to go with a single, simple one for now.

I have a separate repository for my web services (there’s the website and a small API I run), and I added that working directory to my session with Claude Code. From there I gave it the prompting to look at how my other app pages are set up and to create a page for Decoder. It went to work and reused the template I had for the other apps and made something… okay. But after some iteration I was able to get a new template spun up that Decoder could use. It also added the app to my dropdown listing and updated the home page.

App Review

This project began at about 5pm PST on December 29. By 11am on December 30 I had the app in a good state, tested the in-app purchase flow, and updated my website. I asked Claude to give me the marketing spiel, looked it over, and submitted it for review.

A Brief In-App Purchase Aside

The workflow for adding in-app purchases to an app is strange and easily confusing. I say this having gone through it with Baseplate at the end of 2024, and at Adobe having gone through it with Premiere’s launch in September of last year. In-app purchases (consumable, non-consumable, or subscriptions) need to be approved by Apple before they can be sold in an app and the process for getting them approved gets bundled up with an app’s submission.

After I submitted Decoder for app review I found a couple of bugs that I wanted to take care of. So I did that, made a new build, and developer-rejected the existing build that I had submitted before. This took my app (which also had the 3 tip jar in-app purchases submitted as well) back to the “Prepare for Submission” state. However when I went back to submit the updated build I couldn’t find the 3 tip jar items to add back and looking at their product pages in App Store Connect showed them still waiting for review.

It seems to me that the process of approving an in-app purchase has much less transparency than a regular app. There’s no way to see their review status in the same way as an app, and the fact that bundling the submission with an app but rejecting the app doesn’t affect the in-app purchases is unnerving at best.

It took until January 4 to get the app in to App Review, but once it did the review was just 30 minutes. The in-app purchases for the tip jar got approved (even though they were no longer part of the submission) and Decoder is now for sale!

This project was a lot of fun and seeing how well the tooling like Claude Code can help me put together an app and its marketing material – and in so short an order – was really eye opening. I have started working on another, much more complicated, app that I hope to have progress updates on in the coming months. It will take a lot more time than Decoder did but it also does quite a bit more. I’ve also dusted off an app that I started a few years ago and have been working on it too (I’m writing this post using it in fact) and may be able to polish it up for release.

I’m definitely feeling an energy for building apps of my own that I haven’t felt for a while and I’m excited to see how I can move these projects forward in 2026.