RunCat Neo follows the LUCA architecture. All source lives in the LocalPackage/ Swift Package under three library targets with a strict, one-way dependency direction:
UserInterface → Model → DataSource
Arrows show the direction of import. UserInterface may import Model and DataSource; Model may import DataSource; DataSource imports nothing from the other two. Never invert this direction.
-
DataSource— leaf layer. Holds:Entities— plainSendablevalues (AppState,Metrics,Runner,AsyncStreamBundle, etc.)Dependencies— thinSendablewrappers around system APIs (UserDefaults,NSWorkspace,FileManager,SMAppService, etc.), each conforming toDependencyClientwithliveValue+testValueRepositories— composed of dependencies- No application logic.
-
Model— depends onDataSource. Holds:Services— long-lived workers wired inAppDelegate(MetricsService,RunnerService,LogService)Stores—@MainActor @Observableview-models conforming toComposable(Dashboard,RunnerBar,MetricsBar, settings stores)AppDependencies— the bag of all dependency clients, injected everywhere via the\.appDependenciesSwiftUI environment valueAppDelegate- This is where application logic lives.
-
UserInterface— depends onDataSource+Model. Holds:- SwiftUI
Scenes(RunnerBarScene,MetricsBarScene,SettingsWindowScene) - SwiftUI
Views - Localized strings and image assets in
UserInterface/Resources/ - No logic.
- SwiftUI
DependencyClient conformances exist so tests can inject overrides via testDependency(of:injection:). Treat them as thin, untested boundaries — nothing more.
- Never put logic inside a
DependencyClient. Clients themselves are never covered by tests. Any conditional, error-handling branch, retry, or state coordination written inside a client is behaviorally unverified — it silently degrades the test guarantees the rest of the codebase relies on. - A client method should be one direct call into the underlying system API, wrapping the result into
Sendablevalues where necessary. Anything more sits in aServiceorStore, which are covered by tests. - Use
DependencyClientonly as a spot-mock window for effects the project does not control —UserDefaults,FileManager,NSWorkspace,SMAppService, networking, notifications, and similar system-owned side effects. If a piece of code does not need to be swapped in tests, it does not belong in a client.
Stores implement Composable: they expose an Action enum and a reduce(_ action:) async function, with send(_:) calling reduce and then forwarding to a parent-provided action closure. This is the only way views mutate state.
- When adding a new screen, create a
StoreinModel/Stores/, define itsAction, and pair it with a SwiftUIViewthat callsstore.send(...). - All state mutation and control flow initiated by the UI must go through
Action+reduce(_:). Views callstore.send(...)and read@Observablestate — nothing else. - Views must not contain logic. No conditionals over multiple state fields, no derived computations beyond trivial formatting, no direct dependency calls, no
Task { ... }blocks that reach for repositories or services. If a view feels like it wants anif, express that decision in the store and expose the result as state.
Split a store into a child Composable when a part of the screen owns an independent group of state and actions. Judge by what that part owns, not by how it is presented: a Form Section with its own state and actions becomes a child; a sheet becomes a child when it has state and a lifecycle of its own; a sheet that only flips a flag the parent already holds stays in the parent. When several views instead read across one shared state, decompose the views and keep the store whole — decomposing a view and decomposing a store are separate decisions.
- Resident children (a
Section) are held as a non-optionalletand built in the parent'sinit, with aweak var weakSelfwiring the child'sactionclosure back toself. - Transient children (a sheet) are held as an optional
var, conform toIdentifiable, and are presented with.sheet(item:). Rebuilding one on each presentation is what resets its state, so no dismissal action is needed to clear fields by hand. - Children report upward through output cases on their own
Action(errorOccurred,customRunnerAdded): the child callssendinsidereduce, andsendforwards to the parent-provided closure. - Parents interpret only the child actions that govern the child's existence (
cancelButtonTapped,customRunnerAdded). Everything else passes through ascase .child: return. - The store where an error occurs is the store that shows it. It holds
showingAlertanderror, and its own view carries the.alert. A parent never holds alert state for a screen below it. - A parent that builds children holds
AppDependenciesitself, as aprivate letwhen it creates a child lazily insidereduce. The container never travels through anAction: a view reads\.appDependenciesonly to construct a store it owns, and routing the container back in from the UI would let a caller hand the child a different container than the parent was built with.
Global app state flows through AppStateClient (an AllocatedUnfairLock<AppState>). Async streams in AppState (e.g. metrics, runnerBundles, runnerSpeeds) are produced by services and consumed by stores via for await loops launched inside reduce(.task).
AppDependencies.shared is the live singleton injected through the \.appDependencies SwiftUI environment value; tests construct one via AppDependencies.testDependencies(...), overriding only the clients they care about.
- Asset Catalog and String Catalog lookups (
Image(...),String(localized:),Text("key", bundle: .module),.modulebundle references, etc.) must only appear inUserInterface. Modelmust not reference bundle resources. Logic that reasons about "which image / which localized string" belongs in the UI layer. In the logic layer, represent the choice as a plain-value key — an enum case, an entity ID, a semantic constant — thatUserInterfacemaps to the concrete resource. This keepsModeltestable without a resource bundle and keeps localization decisions out of business logic.