# macOS AppState

**Source:** `apps/macos/Sources/SOPHIAClaw/Core/AppState.swift` (677 lines)  
**Category:** UI/UX Components - State Management  
**Purpose:** Central reactive state management with file-based persistence and change observation

## Overview

AppState implements the central state management system for the SophiaClaw macOS application using the Observation framework (`@Observable`). It provides reactive state updates, file-backed persistence, and real-time configuration file watching to keep UI synchronized with both user changes and external modifications.

```
┌─────────────────────────────────────────────────────────────┐
│                    SwiftUI Views                            │
│  (MainWindow, ChatView, SettingsView, etc.)                │
└─────────────────────┬───────────────────────────────────────┘
                      │ observes via @Bindable
                      v
┌─────────────────────────────────────────────────────────────┐
│                   AppState (Singleton)                      │
│  @Published var connectionState: ConnectionState            │
│  @Published var gatewayConfig: GatewayConfiguration         │
│  @Published var sessions: [ConversationSession]             │
│  @Published var currentSession: ConversationSession?        │
│                                                             │
│  ┌───────────────────────────────────────────────────────┐ │
│  │        DispatchSourceFileSystemObject (Watcher)        │ │
│  │  Monitors: ~/.sophiaclaw/sophiaclaw.json              │ │
│  │  Events: .write, .delete, .rename                      │ │
│  └───────────────────────────────────────────────────────┘ │
└─────────────────────┬───────────────────────────────────────┘
                      │ persists to
                      v
┌─────────────────────────────────────────────────────────────┐
│              File System (~/.sophiaclaw/)                   │
│  - sophiaclaw.json (config)                                 │
│  - sessions.json (conversation history)                     │
│  - policies.json (governance rules)                         │
│  - credentials/ (keychain-encrypted secrets)                │
└─────────────────────────────────────────────────────────────┘
```

## Architecture

### Observation Framework Integration

```swift
import Observation

@MainActor
@Observable
final class AppState {
    // No @Published needed with @Observable
    var connectionState: ConnectionState = .disconnected
    var isLoading = false
    var gatewayConfig = GatewayConfiguration()

    // Computed properties trigger UI updates automatically
    var isConnected: Bool {
        connectionState == .connected
    }
}

// Usage in SwiftUI
struct ContentView: View {
    @Bindable var appState = AppState.shared

    var body: some View {
        VStack {
            if appState.isConnected {
                Text("Connected")
            } else {
                Text("Disconnected")
            }
        }
    }
}
```

### Singleton Pattern with Immediate Availability

```swift
final class AppState: ObservableObject {
    // Compile-time initialized singleton
    static let shared = AppState()

    // Private init ensures single instance
    private init() {
        loadConfiguration()
        loadSessions()
        loadPolicies()
        startConfigWatcher()
    }
}
```

**Key Design Decisions:**

1. **@MainActor**: All state mutations on main thread (UI thread)
2. **Eager initialization**: State available immediately on app launch
3. **No dependencies in init**: Avoids circular dependencies

## File Watching Algorithm

### DispatchSourceFileSystemObject

**Problem:** Configuration file may be modified externally (CLI, text editor, other apps). UI must reflect changes without restart.

**Algorithm:**

```swift
private func startConfigWatcher() {
    let configPath = FileManager.default.homeDirectoryForCurrentUser
        .appendingPathComponent(".sophiaclaw/sophiaclaw.json")

    // Ensure file exists (can't watch non-existent files)
    let dirPath = configPath.deletingLastPathComponent()
    if !FileManager.default.fileExists(atPath: dirPath.path) {
        try? FileManager.default.createDirectory(
            at: dirPath,
            withIntermediateDirectories: true
        )
    }
    if !FileManager.default.fileExists(atPath: configPath.path) {
        FileManager.default.createFile(
            atPath: configPath.path,
            contents: Data("{}".utf8)
        )
    }

    // Open file descriptor for monitoring
    let fileDescriptor = open(configPath.path, O_EVTONLY)
    guard fileDescriptor != -1 else { return }

    // Create dispatch source
    let watcher = DispatchSource.makeFileSystemObjectSource(
        fileDescriptor: fileDescriptor,
        eventMask: [.write, .delete, .rename],
        queue: DispatchQueue.main  // Events on main thread
    )

    // Event handler
    watcher.setEventHandler { [weak self] in
        // Debounce: wait for write to complete
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self?.loadConfiguration()
        }

        // Re-establish watcher on delete/rename
        if watcher.data.contains(.delete) ||
           watcher.data.contains(.rename) {
            self?.startConfigWatcher()
        }
    }

    // Cleanup on cancel
    watcher.setCancelHandler {
        close(fileDescriptor)
    }

    // Replace old watcher
    self.configWatcher?.cancel()
    self.configWatcher = watcher
    watcher.resume()
}
```

### Event Handling

| Event Type | Trigger               | Response                                     |
| ---------- | --------------------- | -------------------------------------------- |
| `.write`   | File content modified | Reload config after 100ms debounce           |
| `.delete`  | File removed          | Reload (creates empty), re-establish watcher |
| `.rename`  | File moved/renamed    | Reload (creates empty), re-establish watcher |

### Write-Back Suspension

**Problem:** When AppState saves config, the file watcher detects the write and triggers a reload, causing potential race conditions.

**Solution:** Temporarily suspend watcher during save:

```swift
func saveConfiguration() {
    // Read existing config (preserve external changes)
    var existingJson: [String: Any] = [:]
    if FileManager.default.fileExists(atPath: configPath.path),
       let data = try? Data(contentsOf: configPath),
       let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
        existingJson = json
    }

    // Update only UI-managed keys
    existingJson["host"] = gatewayConfig.host
    existingJson["port"] = gatewayConfig.port
    // ... more fields

    // SUSPEND WATCHER before write
    let currentWatcher = configWatcher
    configWatcher = nil
    currentWatcher?.cancel()

    // Write atomically
    if let data = try? JSONSerialization.data(
        withJSONObject: existingJson,
        options: .prettyPrinted
    ) {
        try? data.write(to: configPath)
    }

    // Resume watcher after delay (ensure write completes)
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
        self?.startConfigWatcher()
    }
}
```

## State Categories

### 1. Connection State

```swift
enum ConnectionState: Equatable {
    case disconnected
    case connecting
    case connected
    case error(String)
}

@Published var connectionState: ConnectionState = .disconnected
```

**State Transitions:**

```
disconnected → connecting → connected
                      ↓
                 error(message) → disconnected
```

**UI Reactions:**

- `disconnected`: Show connection prompt
- `connecting`: Show loading spinner
- `connected`: Enable chat input, show messages
- `error`: Show error banner with retry option

### 2. Gateway Configuration

```swift
struct GatewayConfiguration: Codable, Equatable {
    var host: String = "127.0.0.1"
    var port: Int = 18789
    var useTLS: Bool = false
    var autoConnect: Bool = true

    // Voice settings
    var voiceEnabled: Bool = false
    var voiceProvider: String = "elevenlabs"
    var wakeupWord: String = "Sophia"

    // UI preferences
    var isExpertModeEnabled: Bool = false
    var notificationPreviewStyle: NotificationPreviewStyle = .standard

    // Budget tracking
    var monthlyBudget: Double = 0.0
    var currentSpend: Double = 0.0

    // Derived property
    var baseURL: String {
        let scheme = useTLS ? "https" : "http"
        return "\(scheme)://\(host):\(port)"
    }
}
```

### 3. Conversation Sessions

```swift
@Published var sessions: [ConversationSession] = []
@Published var currentSession: ConversationSession?

struct ConversationSession: Identifiable, Codable {
    let id: UUID
    var title: String
    var messages: [ChatMessage]
    var persona: AgentPersona
    var topic: String
    var createdAt: Date
}
```

**Operations:**

```swift
func createNewSession(persona: AgentPersona, topic: String) {
    let session = ConversationSession(
        title: "New \(persona.name) Chat",
        messages: [],
        persona: persona,
        topic: topic
    )
    sessions.insert(session, at: 0)  // Newest first
    currentSession = session
    saveSessions()
}

func deleteSession(_ session: ConversationSession) {
    sessions.removeAll { $0.id == session.id }
    if currentSession?.id == session.id {
        currentSession = sessions.first  // Select next available
    }
    saveSessions()
}
```

### 4. Governance Policies

```swift
@Published var policies: [SOPHIAClawPolicy] = []

struct SOPHIAClawPolicy: Identifiable, Codable {
    let id: String
    var name: String
    var description: String
    var rules: [SOPHIAClawRule]
}

struct SOPHIAClawRule: Identifiable, Codable {
    let id: String
    var name: String
    var pattern: String          // Regex pattern
    var enforcement: String      // "allow", "require_approval", "deny"
    var scope: [String]          // Tool names to apply to
}
```

**Default Policies:**

```swift
self.policies = [
    SOPHIAClawPolicy(
        id: "terminal-safety",
        name: "Terminal Protection",
        description: "Require approval for dangerous shell commands",
        rules: [
            SOPHIAClawRule(
                id: "destructive-shell",
                name: "Destructive Commands",
                pattern: "rm -rf|mkfs|dd if=",
                enforcement: "require_approval",
                scope: ["tool.run_shell_command"]
            )
        ]
    )
]
```

### 5. Approval Requests

```swift
var pendingApprovals: [ApprovalRequest] = []
var currentApproval: ApprovalRequest?

struct ApprovalRequest: Identifiable, Equatable, Codable {
    let id: UUID
    let timestamp: Date
    let action: String
    let description: String
    let riskLevel: RiskLevel
    let metadata: [String: String]
}

enum RiskLevel: String, CaseIterable {
    case low = "Low"
    case medium = "Medium"
    case high = "High"
    case critical = "Critical"

    var color: String { /* UI color */ }
    var icon: String { /* SF Symbol name */ }
}
```

## Persistence Strategy

### Non-Destructive Save

**Principle:** Preserve external configuration keys that UI doesn't manage.

```swift
func saveConfiguration() {
    // 1. Read existing config
    var existingJson: [String: Any] = [:]
    if FileManager.default.fileExists(atPath: configPath.path),
       let data = try? Data(contentsOf: configPath),
       let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
        existingJson = json
    }

    // 2. Update ONLY UI-managed keys
    existingJson["host"] = gatewayConfig.host
    existingJson["port"] = gatewayConfig.port
    existingJson["useTLS"] = gatewayConfig.useTLS
    // ... other managed keys

    // 3. Write merged config
    if let data = try? JSONSerialization.data(
        withJSONObject: existingJson,
        options: .prettyPrinted
    ) {
        try? data.write(to: configPath)
    }
}
```

### Type-Safe Decoding with Fallbacks

```swift
func loadConfiguration() {
    guard FileManager.default.fileExists(atPath: configPath.path),
          let data = try? Data(contentsOf: configPath),
          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
        hasValidConfig = false
        return
    }

    hasValidConfig = true

    // Safe extraction with defaults
    gatewayConfig.host = json["host"] as? String ?? "127.0.0.1"
    gatewayConfig.port = json["port"] as? Int ?? 18789
    gatewayConfig.useTLS = json["useTLS"] as? Bool ?? false

    // Nested structures
    if let ui = json["ui"] as? [String: Any] {
        gatewayConfig.isExpertModeEnabled = ui["expertMode"] as? Bool ?? false
    }

    // Enum with raw value validation
    if let style = json["notificationPreviewStyle"] as? String,
       let previewStyle = NotificationPreviewStyle(rawValue: style) {
        gatewayConfig.notificationPreviewStyle = previewStyle
    }
}
```

### Directory Initialization

```swift
func saveSessions() {
    let sessionsPath = /* ... */

    // Ensure directory exists
    let dir = sessionsPath.deletingLastPathComponent()
    try? FileManager.default.createDirectory(
        at: dir,
        withIntermediateDirectories: true
    )

    // Encode and write
    if let data = try? JSONEncoder().encode(sessions) {
        try? data.write(to: sessionsPath)
    }
}
```

## Secure Storage Integration

### Keychain Operations

```swift
// Save API key to secure enclave
func saveSecret(_ value: String, for key: String) {
    try? Dependencies.shared.keychain.saveString(value, for: key)
}

// Load on app startup
func loadSecret(for key: String) -> String? {
    return try? Dependencies.shared.keychain.loadString(for: key)
}

// Delete on logout
func deleteSecret(for key: String) {
    try? Dependencies.shared.keychain.deleteString(for: key)
}

// Usage in onboarding
func saveInitialConfiguration(
    provider: String,
    model: String,
    apiKey: String,
    workspace: String
) {
    // Store in config (non-sensitive)
    gatewayConfig.customSecrets["SOPHIACLAW_DEFAULT_PROVIDER"] = provider
    gatewayConfig.customSecrets["SOPHIACLAW_DEFAULT_MODEL"] = model

    // Store in keychain (sensitive)
    saveSecret(apiKey, for: "\(provider)_api_key")

    saveConfiguration()
}
```

## Notification Center Integration

### Cross-Component Communication

```swift
// Post notification on state change
func setConnectionState(_ state: ConnectionState) {
    connectionState = state
    NotificationCenter.default.post(
        name: .connectionStateChanged,
        object: nil,
        userInfo: nil
    )
}

// Observe in other components
NotificationCenter.default.addObserver(
    forName: .connectionStateChanged,
    object: nil,
    queue: .main
) { [weak self] _ in
    self?.updateUI()
}

// Custom notification names
extension Notification.Name {
    static let connectionStateChanged = Notification.Name("connectionStateChanged")
    static let onboardingCompleted = Notification.Name("onboardingCompleted")
}
```

## Threading Model

### Main Actor Isolation

```swift
@MainActor
final class AppState {
    // All properties implicitly @MainActor
    var connectionState: ConnectionState = .disconnected

    // Methods run on main actor
    func updateConnectionState(_ state: ConnectionState) {
        // Safe UI mutation
        connectionState = state
    }

    // Async methods inherit main actor
    func loadConfiguration() async {
        // Can await without leaving main actor
        let data = try? await readFile(configPath)
        // Still on main actor, safe to update UI
    }
}
```

### Background Work Offloading

```swift
func performExpensiveOperation() {
    isLoading = true  // UI feedback

    // Offload to background queue
    Task.detached {
        // Background work
        let result = await self.expensiveComputation()

        // Back to main actor for UI update
        await MainActor.run {
            self.isLoading = false
            // Update UI with result
        }
    }
}
```

## Memory Management

### Weak Self in Closures

```swift
watcher.setEventHandler { [weak self] in
    // Avoid retain cycle: watcher → AppState → watcher
    guard let self = self else { return }

    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        self.loadConfiguration()
    }
}
```

### Cleanup on Deinit

```swift
deinit {
    // Cancel file watcher to release file descriptor
    configWatcher?.cancel()

    // Note: NotificationCenter observers auto-removed in iOS 9+
}
```

## Testing Strategy

### Unit Testing AppState

```swift
@MainActor
final class AppStateTests: XCTestCase {
    func testConnectionStateTransition() {
        let state = AppState.shared

        XCTAssertEqual(state.connectionState, .disconnected)

        state.setConnectionState(.connecting)
        XCTAssertEqual(state.connectionState, .connecting)

        state.setConnectionState(.connected)
        XCTAssertEqual(state.connectionState, .connected)
    }

    func testFileWatcherTriggersReload() async throws {
        let state = AppState.shared
        let configPath = /* ... */

        // Write initial config
        try JSONSerialization.data(withJSONObject: ["host": "initial"])
            .write(to: configPath)

        // Wait for watcher to detect
        try await Task.sleep(nanoseconds: 200_000_000)

        XCTAssertEqual(state.gatewayConfig.host, "initial")

        // Modify file
        try JSONSerialization.data(withJSONObject: ["host": "modified"])
            .write(to: configPath)

        // Wait for reload
        try await Task.sleep(nanoseconds: 200_000_000)

        XCTAssertEqual(state.gatewayConfig.host, "modified")
    }
}
```

## Performance Optimizations

### Debounced File Reloads

```swift
// Prevent rapid reloads on sustained writes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    self.loadConfiguration()
}
```

### Lazy Policy Loading

```swift
func loadPolicies() {
    let policyPath = /* ... */

    // Only load on demand (settings view)
    guard policies.isEmpty else { return }

    // Load from file or use defaults
    // ...
}
```

## Related Documentation

- [SwiftUI Observation Framework](/docs/platforms/macos/swiftui-observation.md)
- [Gateway Configuration](/docs/configuration/gateway.md)
- [macOS Permissions](/docs/platforms/macos/permissions.md)
