Bushel of an App - Design, Architecture, and Automation

Much of the design for the app, mostly came from the apps I use that are designed well within macOS. In other words, they used the patterns that a developer like myself are familiar and comfortable with. These include:

I tried to avoid in this version any other Virtual Machine app (except for Docker I suppose which I use nearly all the time). This meant I did not use Parallels or VirtualBox as well as the highly acclaimed VirtualBuddy by Guilherme Rambo.




The biggest design restriction was that it needed to play along the rules of a Sandboxed app. Therefore every file used in the app must go through a file dialog of some sort and that the app must save the bookmark data for the file. In my case in the Swift Data database. Sandboxing would also become an issue as I used a Microapps architecture for the application.

Machine Window from Bushel

Microapps

Microapps architecture means I would create separate apps for each part of the application. Additionally Swift Package was used for nearly all the code in the application. The exception being one code file which is the entry point for the application:

import SwiftUI
import BushelApp

@main
struct BushelApp: Application {
  @Environment(\.scenePhase) var scenePhase
}

The Microapp parts of the application are:

It was also important that specialized Apple components or anything shared accross these parts were in separate targets. This includes:

The app was built with the intention of allow other virtual machine systems to be integrated in the future. On top of the additional supporting or core targets, this means there are currently 52 targets in the Bushel Swift Package.

All the Swift Package Targets... and the one Application code file

In order to facilitate either building of microapps, I used XcodeGen to create a project for each target.

Target setup in the XcodeGen project.yml file.

This worked really well until I started using bookmark urls from Sandboxing. Once I did that, I no longer had access to the same urls and the micro apps would crash. (Humorously there were no issues with sharing the Swift Data database.)

Otherwise the use of MicroApps has made it much easier to keep concepts and sections of the app separated. I am the kind of developer who prefers things in the smallest pieces as possible: small files, small types, small functions, small targets, etc…

However it made the Package.swift file quite cumbersome to manage. This is where PackageDSL came in.

PackageDSL

I was inspired by the work of Josh Holtz and his DeckUI library to create a (SwiftUI-like) DSL for Swift packages. This was the origin for PackageDSL. This would allow for:

New Swift Package setup using PackageDSL.

Now it became much easier and faster for me to create new targets. And with smaller targets comes:

Better organized Swift Package setup with PackageDSL.

The only requirement is some boilerplate support files and a simple bash script to concatenate all the files in the directory I store my Package metadata into so it will create a usable Package.swift file.

#!/bin/sh

# package.sh

echo "⚙️  Generating package..."

if [ -z "$SRCROOT" ]; then
  SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
  PACKAGE_DIR="${SCRIPT_DIR}/../Packages/BushelKit"
else
  PACKAGE_DIR="${SRCROOT}/Packages/BushelKit" 	
fi

cd $PACKAGE_DIR
echo "// swift-tools-version: 5.9" > Package.swift
cat Package/Support/*.swift >> Package.swift
cat Package/Sources/**/*.swift >> Package.swift
cat Package/Sources/*.swift >> Package.swift

FelinePine

The only outside library I used (and developed) was FelinePine, a logging library I developed. FelinePine allows me to easily designate the category for each class I use.

internal struct VirtualMachine: Loggable {
  internal typealias LoggingSystemType = BushelLogging

  // set the logging category for VirtualMachine to `.machine`
  internal static let loggingCategory: BushelLogging.Category = .machine
  
  func run () {
    // use the `.machine` logger to log "Starting Run"
    Self.logger.debug("Starting Run")
    ...
  }
  ...
}

I've been developing it off and on for a year and I think it could be fairly useful to folks in the community.

Murray

In Xcode, if you’ve ever created a new file in a Swift Package, you’ve probably had issues with the file name or content.

This is where Murray comes in. As I talked about in a previous episode, I used Murray for several templates including new Swift Package targets via PackageDSL:

//
// Bushel{{name|firstUppercase}}.swift
// Copyright (c) 2023 BrightDigit.
//

struct Bushel{{name|firstUppercase}}: Target {
  var dependencies: any Dependencies {
	BushelCore()
	BushelLogging()
  }
}

Or creating a new SwiftUI View:

//
// {{name|firstUppercase}}View.swift
// Copyright (c) 2023 BrightDigit.
//

#if canImport(SwiftUI)
  import SwiftUI

  struct {{name|firstUppercase}}View: View {    
    var body: some View {
      Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
    }
  }

  #Preview {
    {{name|firstUppercase}}View()
  }
#endif

As someone comfortable with the terminal, this makes it much easier for me to create entire new features in Bushel. Additionally since it’s a Swift Package, there's no need to let Xcode know about the new file since package directories are automatically parsed. When I do need to make changes to Xcode projects that’s where XcodeGen comes in.

XcodeGen

Using XcodeGen makes this an even better match since I can have XcodeGen run the PackageDSL script each time it creates a new Xcode project:

name: Bushel
options:
  preGenCommand: ./Scripts/package.sh
...

XcodeGen really is the glue holding together the Swift Package, Xcode, and Fastlane. XcodeGen allows for simple repeatable creation of the Xcode projects for easy development. At some point I may transition to Tuist, especially as PackageDSL is so Swift dependent - I may as well transition from YAML to Swift but XcodeGen is working well for me.

Included are set of linting tools I use to optimize my applications code. In this case I am currently using mint and a bash script to lint my code at each build:

Faire/StringsLint@0.1.7
nicklockwood/SwiftFormat@0.51.13
realm/SwiftLint@0.41.0

With XcodeGen, I have the option to set how strict my linting will be and can pass that into the Xcode build steps accordingly.

GitLab CI

I have been using GitLab for years on my private projects and have had very few issues with their CI setup. Purchasing a Mac mini for my CI has been a big advantage as well allowing me to regularly build and verify the builds of my applications.

With GitLab, I have been able to setup builds and testing on both macOS and Linux docker machines. The benefit of having targets separated really pays off on the Linux setup since much of my code-base isn’t built to be Apple-specific there I can test business logic quickly on Linux and then for higher priority build ensure they work on macOS.

So while I can’t test Observation or Virtualization, I can test the base OS-agnostic function on Linux as well as iPhone Simulator if a developer didn’t want to install a Sonoma beta during development.

Fastlane

Most importantly Fastlane was easily configured with the CI setup. At no point did I manually archive and upload to the App Store any builds. This meant all builds and metadata were deployed via Fastlane and Gitlab CI. Additionally match was used for storing and setting up certificates and provisioning profile making sharing the project much easier and worked really well with the XcodeGen setup. Lastly after every build was uploaded I used yq to increment the build number in the XcodeGen project file ensuring I’ll never upload the same build number again.

If a tree falls in the forest...

It's really great to see an automated system quickly verify and deploy a build to TestFlight or to the AppStore. However it means nothing if no one is using are testing the app. Let's discuss the marketing side, how AI fits in, and what's the future for Bushel.