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.
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.