Make the app reliable

Cross concurrency boundaries deliberately

Keep interface state on the main actor and move blocking file or process work behind asynchronous service methods.

Everything the user sees lives on the main actor, one protected context that owns all interface state. Blocking file reads and child-process waits don’t belong there. While the main actor is busy, the app is beachballing.

The design that works: interface state on the main actor, slow work in async services, and explicit hops between them.

Mark the model with @MainActor

@MainActor
final class ExportModel: ObservableObject {
  @Published private(set) var status = "Ready"

  func export() async {
    status = "Exporting"
    let result = await exporter.run()
    status = result.summary
  }
}

Read export() as a story about threads. The first status assignment runs on the main actor. The await is the boundary. The model suspends, and exporter.run() does its slow work wherever it likes.

Then comes the part Swift handles for you. The method resumes on the main actor for the final assignment. No dispatch calls, no queue names, and the compiler checks it.

Keep services free of UI

The service side knows nothing about views:

struct ExportResult {
  let summary: String
}

struct Exporter {
  func run() async -> ExportResult {
    // launch the child process, await its exit
    ExportResult(summary: "Exported 3 notes")
  }
}

Services return values or throw typed errors. They never reach back into the model, never touch @Published properties, never “helpfully” dispatch to the main queue.

The model pulls results across the boundary. The service does not push.

The purple warning

Here’s the mistake and how it announces itself. Update status from a background context, say a DispatchQueue.global().async block or a callback from an old-style API. Xcode prints a purple runtime warning: publishing changes from background threads is not allowed.

Sometimes the UI still updates and everything looks fine. Don’t trust that. It’s a data race that happens to be working today. Eventually it shows up as a stale label, or a crash you cannot reproduce.

Treat every purple warning as a bug found early. With strict concurrency checking enabled, and I think it’s worth enabling, many of these mistakes stop compiling at all.

Decide what cancellation means

When the user cancels an export, Task.cancel() only sets a flag. Your service must check Task.isCancelled at sensible points.

Then decide what happens to partial output. Delete the half-file, or keep it and mark it as incomplete. Either is defensible. Choose one and write it down.

Verify by feel

Run a big export and click a menu while it runs. The window must stay responsive.

If the beachball appears, something blocking snuck onto the main actor. The usual suspect is a synchronous waitUntilExit called outside the service.

Lesson completed