refactor(observation): migrate SwiftUI state

This commit is contained in:
Peter Steinberger
2025-12-14 05:04:58 +00:00
parent aab5c490dc
commit b48a556de5
43 changed files with 253 additions and 194 deletions

View File

@@ -1,10 +1,12 @@
import Foundation
import Observation
@MainActor
final class AgentEventStore: ObservableObject {
@Observable
final class AgentEventStore {
static let shared = AgentEventStore()
@Published private(set) var events: [ControlAgentEvent] = []
private(set) var events: [ControlAgentEvent] = []
private let maxEvents = 400
func append(_ event: ControlAgentEvent) {

View File

@@ -2,7 +2,7 @@ import SwiftUI
@MainActor
struct AgentEventsWindow: View {
@ObservedObject private var store = AgentEventStore.shared
private let store = AgentEventStore.shared
var body: some View {
VStack(alignment: .leading, spacing: 6) {

View File

@@ -1,10 +1,12 @@
import AppKit
import Foundation
import Observation
import ServiceManagement
import SwiftUI
@MainActor
final class AppState: ObservableObject {
@Observable
final class AppState {
private let isPreview: Bool
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
@@ -19,26 +21,26 @@ final class AppState: ObservableObject {
case remote
}
@Published var isPaused: Bool {
var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
@Published var launchAtLogin: Bool {
var launchAtLogin: Bool {
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
}
@Published var onboardingSeen: Bool {
var onboardingSeen: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdis.onboardingSeen") }
}
}
@Published var debugPaneEnabled: Bool {
var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
}
}
@Published var swabbleEnabled: Bool {
var swabbleEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
@@ -47,7 +49,7 @@ final class AppState: ObservableObject {
}
}
@Published var swabbleTriggerWords: [String] {
var swabbleTriggerWords: [String] {
didSet {
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
self.ifNotPreview {
@@ -60,21 +62,21 @@ final class AppState: ObservableObject {
}
}
@Published var voiceWakeTriggerChime: VoiceWakeChime {
var voiceWakeTriggerChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
}
@Published var voiceWakeSendChime: VoiceWakeChime {
var voiceWakeSendChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
}
@Published var iconAnimationsEnabled: Bool {
var iconAnimationsEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.iconAnimationsEnabled,
forKey: iconAnimationsEnabledKey) } }
}
@Published var showDockIcon: Bool {
var showDockIcon: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
@@ -83,7 +85,7 @@ final class AppState: ObservableObject {
}
}
@Published var voiceWakeMicID: String {
var voiceWakeMicID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
@@ -94,7 +96,7 @@ final class AppState: ObservableObject {
}
}
@Published var voiceWakeLocaleID: String {
var voiceWakeLocaleID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
@@ -105,27 +107,27 @@ final class AppState: ObservableObject {
}
}
@Published var voiceWakeAdditionalLocaleIDs: [String] {
var voiceWakeAdditionalLocaleIDs: [String] {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voiceWakeAdditionalLocaleIDs,
forKey: voiceWakeAdditionalLocalesKey) } }
}
@Published var voicePushToTalkEnabled: Bool {
var voicePushToTalkEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voicePushToTalkEnabled,
forKey: voicePushToTalkEnabledKey) } }
}
@Published var iconOverride: IconOverrideSelection {
var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
}
@Published var isWorking: Bool = false
@Published var earBoostActive: Bool = false
@Published var blinkTick: Int = 0
@Published var sendCelebrationTick: Int = 0
@Published var heartbeatsEnabled: Bool {
var isWorking: Bool = false
var earBoostActive: Bool = false
var blinkTick: Int = 0
var sendCelebrationTick: Int = 0
var heartbeatsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
@@ -134,31 +136,31 @@ final class AppState: ObservableObject {
}
}
@Published var connectionMode: ConnectionMode {
var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
}
}
@Published var webChatEnabled: Bool {
var webChatEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } }
}
@Published var webChatSwiftUIEnabled: Bool {
var webChatSwiftUIEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.webChatSwiftUIEnabled,
forKey: webChatSwiftUIEnabledKey) } }
}
@Published var webChatPort: Int {
var webChatPort: Int {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } }
}
@Published var canvasEnabled: Bool {
var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
@Published var peekabooBridgeEnabled: Bool {
var peekabooBridgeEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
@@ -167,7 +169,7 @@ final class AppState: ObservableObject {
}
}
@Published var attachExistingGatewayOnly: Bool {
var attachExistingGatewayOnly: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
@@ -175,15 +177,15 @@ final class AppState: ObservableObject {
}
}
@Published var remoteTarget: String {
var remoteTarget: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } }
}
@Published var remoteIdentity: String {
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@Published var remoteProjectRoot: String {
var remoteProjectRoot: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
}

View File

@@ -1,5 +1,6 @@
import ClawdisProtocol
import Foundation
import Observation
import OSLog
import SwiftUI
@@ -36,7 +37,8 @@ enum ControlChannelError: Error, LocalizedError {
}
@MainActor
final class ControlChannel: ObservableObject {
@Observable
final class ControlChannel {
static let shared = ControlChannel()
enum Mode {
@@ -51,8 +53,8 @@ final class ControlChannel: ObservableObject {
case degraded(String)
}
@Published private(set) var state: ConnectionState = .disconnected
@Published private(set) var lastPingMs: Double?
private(set) var state: ConnectionState = .disconnected
private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")

View File

@@ -1,23 +1,25 @@
import ClawdisProtocol
import Foundation
import Observation
import OSLog
@MainActor
final class CronJobsStore: ObservableObject {
@Observable
final class CronJobsStore {
static let shared = CronJobsStore()
@Published var jobs: [CronJob] = []
@Published var selectedJobId: String?
@Published var runEntries: [CronRunLogEntry] = []
var jobs: [CronJob] = []
var selectedJobId: String?
var runEntries: [CronRunLogEntry] = []
@Published var schedulerEnabled: Bool?
@Published var schedulerStorePath: String?
@Published var schedulerNextWakeAtMs: Int?
var schedulerEnabled: Bool?
var schedulerStorePath: String?
var schedulerNextWakeAtMs: Int?
@Published var isLoadingJobs = false
@Published var isLoadingRuns = false
@Published var lastError: String?
@Published var statusMessage: String?
var isLoadingJobs = false
var isLoadingRuns = false
var lastError: String?
var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>?

View File

@@ -1,7 +1,8 @@
import Observation
import SwiftUI
struct CronSettings: View {
@ObservedObject var store: CronJobsStore
@Bindable var store: CronJobsStore
@State private var showEditor = false
@State private var editingJob: CronJob?
@State private var editorError: String?

View File

@@ -12,8 +12,8 @@ struct DebugSettings: View {
@State private var modelsCount: Int?
@State private var modelsLoading = false
@State private var modelsError: String?
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String?

View File

@@ -1,5 +1,6 @@
import Foundation
import Network
import Observation
import OSLog
import Subprocess
#if canImport(Darwin)
@@ -12,7 +13,8 @@ import SystemPackage
#endif
@MainActor
final class GatewayProcessManager: ObservableObject {
@Observable
final class GatewayProcessManager {
static let shared = GatewayProcessManager()
enum Status: Equatable {
@@ -39,11 +41,11 @@ final class GatewayProcessManager: ObservableObject {
}
}
@Published private(set) var status: Status = .stopped
@Published private(set) var log: String = ""
@Published private(set) var restartCount: Int = 0
@Published private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
@Published private(set) var existingGatewayDetails: String?
private(set) var status: Status = .stopped
private(set) var log: String = ""
private(set) var restartCount: Int = 0
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
private(set) var existingGatewayDetails: String?
private var execution: Execution?
private var lastPid: Int32?

View File

@@ -1,13 +1,14 @@
import AppKit
import Observation
import SwiftUI
struct GeneralSettings: View {
@ObservedObject var state: AppState
@Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = true
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@State private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false

View File

@@ -1,5 +1,6 @@
import Foundation
import Network
import Observation
import OSLog
import SwiftUI
@@ -53,15 +54,16 @@ enum HealthState: Equatable {
}
@MainActor
final class HealthStore: ObservableObject {
@Observable
final class HealthStore {
static let shared = HealthStore()
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health")
@Published private(set) var snapshot: HealthSnapshot?
@Published private(set) var lastSuccess: Date?
@Published private(set) var lastError: String?
@Published private(set) var isRefreshing = false
private(set) var snapshot: HealthSnapshot?
private(set) var lastSuccess: Date?
private(set) var lastError: String?
private(set) var isRefreshing = false
private var loopTask: Task<Void, Never>?
private let refreshInterval: TimeInterval = 60

View File

@@ -1,11 +1,13 @@
import Foundation
import Observation
import SwiftUI
@MainActor
final class HeartbeatStore: ObservableObject {
@Observable
final class HeartbeatStore {
static let shared = HeartbeatStore()
@Published private(set) var lastEvent: ControlHeartbeatEvent?
private(set) var lastEvent: ControlHeartbeatEvent?
private var observer: NSObjectProtocol?

View File

@@ -1,7 +1,7 @@
import SwiftUI
struct InstancesSettings: View {
@ObservedObject var store: InstancesStore
var store: InstancesStore
init(store: InstancesStore = .shared) {
self.store = store

View File

@@ -1,6 +1,7 @@
import ClawdisProtocol
import Cocoa
import Foundation
import Observation
import OSLog
struct InstanceInfo: Identifiable, Codable {
@@ -27,14 +28,15 @@ struct InstanceInfo: Identifiable, Codable {
}
@MainActor
final class InstancesStore: ObservableObject {
@Observable
final class InstancesStore {
static let shared = InstancesStore()
let isPreview: Bool
@Published var instances: [InstanceInfo] = []
@Published var lastError: String?
@Published var statusMessage: String?
@Published var isLoading = false
var instances: [InstanceInfo] = []
var lastError: String?
var statusMessage: String?
var isLoading = false
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "instances")
private var task: Task<Void, Never>?

View File

@@ -3,7 +3,7 @@ import SwiftUI
// master is part of the discovery protocol naming; keep UI components consistent.
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryInlineList: View {
@ObservedObject var discovery: MasterDiscoveryModel
var discovery: MasterDiscoveryModel
var currentTarget: String?
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
@State private var hoveredMasterID: MasterDiscoveryModel.DiscoveredMaster.ID?
@@ -109,7 +109,7 @@ struct MasterDiscoveryInlineList: View {
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryMenu: View {
@ObservedObject var discovery: MasterDiscoveryModel
var discovery: MasterDiscoveryModel
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
var body: some View {

View File

@@ -1,10 +1,12 @@
import Foundation
import Network
import Observation
// We use master as the on-the-wire service name; keep the model aligned with the protocol/docs.
@MainActor
// swiftlint:disable:next inclusive_language
final class MasterDiscoveryModel: ObservableObject {
@Observable
final class MasterDiscoveryModel {
// swiftlint:disable:next inclusive_language
struct DiscoveredMaster: Identifiable, Equatable {
var id: String { self.debugID }
@@ -16,8 +18,8 @@ final class MasterDiscoveryModel: ObservableObject {
}
// swiftlint:disable:next inclusive_language
@Published var masters: [DiscoveredMaster] = []
@Published var statusText: String = "Idle"
var masters: [DiscoveredMaster] = []
var statusText: String = "Idle"
private var browser: NWBrowser?

View File

@@ -9,9 +9,9 @@ import SwiftUI
@main
struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var state: AppState
@StateObject private var gatewayManager = GatewayProcessManager.shared
@StateObject private var activityStore = WorkActivityStore.shared
@State private var state: AppState
private let gatewayManager = GatewayProcessManager.shared
private let activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@@ -23,7 +23,7 @@ struct ClawdisApp: App {
}
init() {
_state = StateObject(wrappedValue: AppStateStore.shared)
_state = State(initialValue: AppStateStore.shared)
}
var body: some Scene {

View File

@@ -1,17 +1,18 @@
import AppKit
import AVFoundation
import Foundation
import Observation
import SwiftUI
/// Menu contents for the Clawdis menu bar extra.
struct MenuContent: View {
@ObservedObject var state: AppState
@Bindable var state: AppState
let updater: UpdaterProviding?
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared
@ObservedObject private var activityStore = WorkActivityStore.shared
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
private let heartbeatStore = HeartbeatStore.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
@Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false

View File

@@ -1,13 +1,15 @@
import AppKit
import Observation
import QuartzCore
import SwiftUI
/// Lightweight, borderless panel for in-app "toast" notifications (bypasses macOS Notification Center).
@MainActor
final class NotifyOverlayController: ObservableObject {
@Observable
final class NotifyOverlayController {
static let shared = NotifyOverlayController()
@Published private(set) var model = Model()
private(set) var model = Model()
var isVisible: Bool { self.model.isVisible }
struct Model {
@@ -159,7 +161,7 @@ final class NotifyOverlayController: ObservableObject {
}
private struct NotifyOverlayView: View {
@ObservedObject var controller: NotifyOverlayController
var controller: NotifyOverlayController
var body: some View {
VStack(alignment: .leading, spacing: 6) {

View File

@@ -1,5 +1,6 @@
import AppKit
import ClawdisIPC
import Observation
import SwiftUI
enum UIStrings {
@@ -68,9 +69,9 @@ struct OnboardingView: View {
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery: MasterDiscoveryModel
@ObservedObject private var state: AppState
@ObservedObject private var permissionMonitor: PermissionMonitor
@State private var masterDiscovery: MasterDiscoveryModel
@Bindable private var state: AppState
private var permissionMonitor: PermissionMonitor
private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520
@@ -99,9 +100,9 @@ struct OnboardingView: View {
permissionMonitor: PermissionMonitor = .shared,
discoveryModel: MasterDiscoveryModel = MasterDiscoveryModel())
{
self._state = ObservedObject(wrappedValue: state)
self._permissionMonitor = ObservedObject(wrappedValue: permissionMonitor)
self._masterDiscovery = StateObject(wrappedValue: discoveryModel)
self.state = state
self.permissionMonitor = permissionMonitor
self._masterDiscovery = State(initialValue: discoveryModel)
}
var body: some View {

View File

@@ -4,6 +4,7 @@ import AVFoundation
import ClawdisIPC
import CoreGraphics
import Foundation
import Observation
import OSLog
import Speech
import UserNotifications
@@ -236,10 +237,11 @@ enum AppleScriptPermission {
}
@MainActor
final class PermissionMonitor: ObservableObject {
@Observable
final class PermissionMonitor {
static let shared = PermissionMonitor()
@Published private(set) var status: [Capability: Bool] = [:]
private(set) var status: [Capability: Bool] = [:]
private var monitorTimer: Timer?
private var isChecking = false

View File

@@ -4,7 +4,7 @@ import SwiftUI
@MainActor
struct SessionsSettings: View {
private let isPreview: Bool
@ObservedObject private var state = AppStateStore.shared
private let state = AppStateStore.shared
@State private var rows: [SessionRow]
@State private var storePath: String = SessionLoader.defaultStorePath
@State private var lastLoaded: Date?

View File

@@ -1,8 +1,9 @@
import Observation
import SwiftUI
struct SettingsRootView: View {
@ObservedObject var state: AppState
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
@Bindable var state: AppState
private let permissionMonitor = PermissionMonitor.shared
@State private var monitoringPermissions = false
@State private var selectedTab: SettingsTab = .general
let updater: UpdaterProviding?

View File

@@ -1,9 +1,11 @@
import AppKit
import Foundation
import Observation
import OSLog
@MainActor
final class VoiceSessionCoordinator: ObservableObject {
@Observable
final class VoiceSessionCoordinator {
static let shared = VoiceSessionCoordinator()
enum Source: String { case wakeWord, pushToTalk }

View File

@@ -1,11 +1,13 @@
import AppKit
import Observation
import OSLog
import QuartzCore
import SwiftUI
/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar.
@MainActor
final class VoiceWakeOverlayController: ObservableObject {
@Observable
final class VoiceWakeOverlayController {
static let shared = VoiceWakeOverlayController()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
@@ -17,7 +19,7 @@ final class VoiceWakeOverlayController: ObservableObject {
enum Source: String { case wakeWord, pushToTalk }
@Published private(set) var model = Model()
private(set) var model = Model()
var isVisible: Bool { self.model.isVisible }
struct Model {
@@ -465,7 +467,7 @@ final class VoiceWakeOverlayController: ObservableObject {
}
struct VoiceWakeOverlayView: View {
@ObservedObject var controller: VoiceWakeOverlayController
var controller: VoiceWakeOverlayController
@FocusState private var textFocused: Bool
@State private var isHovering: Bool = false
@State private var closeHovering: Bool = false

View File

@@ -1,11 +1,12 @@
import AppKit
import AVFoundation
import Observation
import Speech
import SwiftUI
import UniformTypeIdentifiers
struct VoiceWakeSettings: View {
@ObservedObject var state: AppState
@Bindable var state: AppState
@State private var testState: VoiceWakeTestState = .idle
@State private var tester = VoiceWakeTester()
@State private var isTesting = false

View File

@@ -1,8 +1,10 @@
import Foundation
import Observation
import SwiftUI
@MainActor
final class WorkActivityStore: ObservableObject {
@Observable
final class WorkActivityStore {
static let shared = WorkActivityStore()
struct Activity: Equatable {
@@ -14,8 +16,8 @@ final class WorkActivityStore: ObservableObject {
var lastUpdate: Date
}
@Published private(set) var current: Activity?
@Published private(set) var iconState: IconState = .idle
private(set) var current: Activity?
private(set) var iconState: IconState = .idle
private var active: [String: Activity] = [:]
private var currentSessionKey: String?