🐮 SwiftUI Tools w/ just SwiftPM

The Swift Package Manager coming with Swift 5.5 / Xcode 13 now supports the @main attribute. We can use that to build apps using just SwiftPM, no Xcode involved! Let’s use it to build a Cowtastic app!

The @main attribute was actually introduced in Xcode 12, but starting w/ Swift 5.5 it is now also supported in Swift Package Manager. (ObjC.io also has a nice pre-@main article about this, requiring more boilerplate.)

This is what is possible now, w/o having to use Xcode:

import SwiftUI

@main
struct HelloWorld: App {
  
  var body: some Scene {
    WindowGroup { 
      Text("Hello World!").padding()
    }
  }
}

Produces this little thing when it is run:

Setting up the SwiftPM Boilerplate

Unfortunately the otherwise awesome swift-sh does not yet support @main. Issue #163 got filed. Once that is implemented, even SwiftPM can be completely avoided and Swift scripts w/ SwiftUI would just work.

For now, we still need to create a Swift package. First make sure that Swift 5.5 is active:

$ swift --version
swift-driver version: 1.26.9 Apple Swift version 5.5 (swiftlang-1300.0.29.102 clang-1300.0.28.1)
Target: arm64-apple-macosx11.0

If it is not: sudo xcode-select -s /Applications/Xcode-beta.app.

Next create the package boilerplate:

$ mkdir Tows && cd Tows
$ swift package init --type executable
Creating executable package: Tows
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/Tows/main.swift
Creating Tests/
Creating Tests/TowsTests/
Creating Tests/TowsTests/TowsTests.swift

This needs to be massaged a little more. First we need to rename the main.swift to something else. main.swift is a special file which essentially wraps the whole content in a big function (i.e. you can run statements like print("Moo!") at the top level). This clashes with how @main works.

$ mv Sources/Tows/main.swift \
     Sources/Tows/Tows.swift

Then we need to replace the contents (the print("Hello")) with our app as shown above:

import SwiftUI

@main
struct HelloWorld: App {
  
  var body: some Scene {
    WindowGroup { 
      Text("Hello World!").padding()
    }
  }
}

And then we can run swift build:

$ swift build
Tows.swift:3:1: error: 'main()' is only available in macOS 11.0 or newer
@main
^

Oops. That says at least macOS BS is required. Easily fixed by adding that requirement to our Package.swift file:

// swift-tools-version:5.5
import PackageDescription

let package = Package(
  name: "Tows",
  platforms: [ .macOS(.v11) ], // <= add this!
  dependencies: [],
  targets: [ .executableTarget(name: "Tows", dependencies: []) ]
)

Now it builds and runs:

$ swift run
[3/3] Build complete!

You may have to search for the window, it is not put in front of your other windows.

IMPORTANT: This is not quite a full app yet. You’ll notice it has no menu bar and if you quit it, you’ll quit your editor instead 😈 To close it, you need to Control-C the swift run in the terminal.

Fixing the App Activation

To make the thing behave like an app proper a Cocoa with Love trick from 2010 has to be applied. We are going to add an init to our App structure:

@main
struct HelloWorld: App {
  
  init() {
    DispatchQueue.main.async {
      NSApp.setActivationPolicy(.regular)
      NSApp.activate(ignoringOtherApps: true)
      NSApp.windows.first?.makeKeyAndOrderFront(nil)
    }    
  }
  ...
}

Re-run the tool, it’ll have a menu proper and it can be quit as usual.

We are done. That is all which is required. But can we write more complex apps with that? Something cowtastic? Yes we can!

          (__)
        /  .\/.     ______
       |  /\_|     |      \
       |  |___     |       |
       |   ---@    |_______|
    *  |  |   ----   |    |
     \ |  |_____
      \|________|
CompuCow Discovers Bug in Compiler

A Cowtastic App

This thing, as a SwiftPM tool, in 82 lines of code (including support for search, selection and dragging):

We are going to use the Swift cows package, let’s add it as a dependency to Package.swift:

// swift-tools-version:5.5
import PackageDescription

let package = Package(
  name: "Tows",
  platforms: [ .macOS(.v11) ],
  dependencies: [ // add this:
    .package(url: "https://github.com/AlwaysRightInstitute/cows",
             from: "1.0.10")
  ],
  targets: [ 
    .executableTarget(name: "Tows", 
                      dependencies: [ "cows" ]) // <= add this!
  ]
)

And the script itself. Gist: Tows.swift

import SwiftUI
import cows // @AlwaysRightInstitute

struct ContentView: View {
  
  @State var searchString  = ""
  @State var matches       = allCows
  @State var selectedCow   : String?
  
  let font = Font(NSFont
    .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular))
  
  var body: some View {
    NavigationView {
      ScrollView {
        TextField("Search", text: $searchString)
          .textFieldStyle(RoundedBorderTextFieldStyle())
          .padding(8)
          .onChange(of: searchString) { nv in
            matches = nv.isEmpty 
                    ? cows.allCows 
                    : cows.allCows.filter { $0.contains(searchString) }
          }
        Spacer()
      }
      
      ScrollView {
        VStack(spacing: 0) {
          if matches.isEmpty {
            Text("Didn't find cows matching '\(searchString)' 🐮")
              .padding()
              .font(.title)
            Divider()
          }
          
          ForEach(matches.isEmpty ? allCows : matches, id: \.self) { cow in
            Text(verbatim: cow)
              .font(font)
              .onDrag { NSItemProvider(object: cow as NSString ) }
              .padding()
              .background(
                RoundedRectangle(cornerRadius: 16)
                  .strokeBorder()
                  .foregroundColor(.accentColor)
                  .padding(4)
                  .opacity(selectedCow == cow ? 1 : 0)
              )
              .frame(maxWidth: .infinity)
              .contentShape(Rectangle())
              .onTapGesture {
                selectedCow = selectedCow == cow ? nil : cow
              }
          }
        }
      }
      .ignoresSafeArea()
    }
  }
}

@main
struct TestAppApp: App {
  
  init() {
    DispatchQueue.main.async {
      NSApp.setActivationPolicy(.regular)
      NSApp.activate(ignoringOtherApps: true)
      NSApp.windows.first?.makeKeyAndOrderFront(nil)
    }
  }
  
  var body: some Scene {
    WindowGroup {
      ContentView()
        .frame(minWidth: 640, minHeight: 320)
    }
    .windowStyle(HiddenTitleBarWindowStyle())
  }
}

Closing Notes

The only thing we’d like is swift-sh support, so that we can drop all that SwiftPM boilerplate 🤓

Want to have a readymade app that is properly reviewed by Apple’s AppStore team?

There is CodeCows. Which (amongst other things) features an Xcode extension (yes!) and support for macOS services (i.e. automatic ASCII Cows support in any Cocoa text field, Electron not).

And for iOS there is ASCII Cows. Includes a Messages app and proper Markdown support, so that you can paste the cows into WhatsApp properly.

Contact

Feedback is warmly welcome: @helje5, wrong@alwaysrightinstitute.com. GitHub.

Written on August 23, 2021