Architecture

Installation Wizard

Three types. Two pure functions. One loop.

Inspect. Route. Fix.

The wizard inspects the system, finds what's wrong, shows the page for the first unresolved issue, and offers a button to fix it. After the fix, it re-inspects and advances.

Inspect

What's wrong with this system right now?

Route

Which page fixes the first issue?

Fix

One button, one InstallerEngine call.

The Complete Picture

Data flows down through two pure functions, with a feedback loop that re-inspects after every fix.
SystemSnapshot from InstallerEngine PURE FUNCTION SystemInspector [WizardIssue] PURE FUNCTION WizardRouter WizardPage SWIFTUI VIEW WizardView EXISTING InstallerEngine fix re-inspect after fix Each box is labeled with its role. Blue arrows show the data flow.
Core invariant. Given the same SystemSnapshot, the wizard always shows the same page and offers the same fix. No hidden state, no timing dependencies, no async side effects in the decision path.

Three Types, Three Jobs

Each is independently testable. Together they handle every wizard interaction.
SystemInspector Pure Function
Takes a SystemSnapshot and returns a list of WizardIssue values describing what's wrong.
enum SystemInspector {
    static func inspect(snapshot: SystemSnapshot) -> (WizardSystemState, [WizardIssue])
}

// Test:
let snapshot = SystemSnapshot(permissions: noIM, components: allGood, ...)
let (state, issues) = SystemInspector.inspect(snapshot: snapshot)
XCTAssertEqual(issues.first?.identifier, .permission(.keyPathInputMonitoring))
WizardRouter Pure Function
Takes the issues and returns which WizardPage to show. Priority: helper → conflicts → permissions → components → service → summary.
enum WizardRouter {
    static func route(
        state: WizardSystemState,
        issues: [WizardIssue],
        helperInstalled: Bool,
        helperNeedsApproval: Bool
    ) -> WizardPage

    static func nextPage(
        after: WizardPage,
        issues: [WizardIssue],
        state: WizardSystemState
    ) -> WizardPage
}
InstallationWizardView SwiftUI View
Holds @State for the current page, issues, and snapshot. On appear: inspect + route. Fix button: call InstallerEngine, then re-inspect + re-route.
struct InstallationWizardView: View {
    @State private var currentPage: WizardPage = .summary
    @State private var issues: [WizardIssue] = []

    func inspectAndRoute() async {
        let snapshot = await InstallerEngine().inspectSystem()
        let (state, issues) = SystemInspector.inspect(snapshot: snapshot)
        currentPage = WizardRouter.route(state: state, issues: issues, ...)
    }

    func fix(_ action: AutoFixAction) async {
        await InstallerEngine().runSingleAction(action, using: PrivilegeBroker())
        await inspectAndRoute()
    }
}
InstallerEngine Existing
The existing facade for all system mutations: install, repair, uninstall, inspect. Fix buttons call it directly. No wrappers.

Every Interaction, Same Cycle

No special cases. No alternate paths.
Wizard opens
inspectSystem()inspect()route() → show page
Fix button
runSingleAction() → re-inspect → re-route → next page
Continue
WizardRouter.nextPage() — skips resolved pages
All resolved
Router returns .summary

Before & After

11 types with tangled dependencies become 3 with a clean data flow.
Before — 11 types
  • WizardStateMachine
  • WizardNavigationEngine
  • WizardNavigationHeuristics
  • WizardStateInterpreter
  • WizardRouter
  • SystemContextAdapter
  • IssueGenerator
  • WizardAutoFixer
  • WizardAutoFixerManager
  • WizardAsyncOperationManager
  • WizardOperationsUIExtension
After — 3 types
  • SystemInspector
  • WizardRouter
  • InstallationWizardView
Plus InstallerEngine, page views, and infrastructure services — all unchanged.

Design Rules

Five invariants that keep the architecture simple.
1
No async in the decision path
SystemInspector and WizardRouter are synchronous pure functions. The only async call is inspectSystem() which gathers the snapshot.
2
No hidden state
Behavior is fully determined by the SystemSnapshot. No timing-dependent navigation. One-time pages are simple booleans on the view.
3
Fix buttons call InstallerEngine directly
No wrappers, no managers, no operation queues. await InstallerEngine().runSingleAction(action, using: broker)
4
One loop, not a pipeline
After every fix, re-inspect from scratch and re-route. No predicting the next state. Fresh data, fresh decision.
5
Pages are dumb
Each page receives issues as input, renders them, and offers a fix button. No system queries, no state interpretation, no navigation decisions.

Page Priority

The Router checks these in order and returns the first page with unresolved issues.
1
.helper
Privileged helper not installed or unhealthy
2
.conflicts
Karabiner or external kanata running
3
.inputMonitoring
Missing Input Monitoring permission
4
.accessibility
Missing Accessibility permission
5
.karabinerComponents
VirtualHID driver or services missing
6
.service
Kanata daemon not running or unhealthy
7
.summary
Everything is green

Testing Strategy

Pure functions are trivially testable. That's the point.

SystemInspector

Given a SystemSnapshot, assert the exact issues returned. No mocks, no async, no setup.

WizardRouter

Given state and issues, assert the page. Test every priority transition.

Integration

Full inspect → route → fix → re-inspect cycle. Verify walk-through to summary.

Related

See Also

The wizard doesn't exist in isolation. These guides cover the systems it depends on and sets up.

PermissionOracle → — How the wizard detects which permissions are missing.

Privileged Helper & XPC → — What happens when the user clicks "Install" on the helper page.

Runtime & Service Lifecycle → — How the service starts after the wizard finishes.

← Back to Index