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,18 +1,18 @@
import ClawdisKit
import Combine
import Foundation
import Network
import Observation
import SwiftUI
@MainActor
final class BridgeConnectionController: ObservableObject {
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
@Published private(set) var discoveryStatusText: String = "Idle"
@Published private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
@Observable
final class BridgeConnectionController {
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
private let discovery = BridgeDiscoveryModel()
private weak var appModel: NodeAppModel?
private var cancellables = Set<AnyCancellable>()
private var didAutoConnect = false
private var seenStableIDs = Set<String>()
@@ -23,20 +23,8 @@ final class BridgeConnectionController: ObservableObject {
self.discovery.setDebugLoggingEnabled(
UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs"))
self.discovery.$bridges
.sink { [weak self] newValue in
guard let self else { return }
self.bridges = newValue
self.updateLastDiscoveredBridge(from: newValue)
self.maybeAutoConnect()
}
.store(in: &self.cancellables)
self.discovery.$statusText
.assign(to: &self.$discoveryStatusText)
self.discovery.$debugLog
.assign(to: &self.$discoveryDebugLog)
self.updateFromDiscovery()
self.observeDiscovery()
if startDiscovery {
self.discovery.start()
@@ -58,6 +46,29 @@ final class BridgeConnectionController: ObservableObject {
}
}
private func updateFromDiscovery() {
let newBridges = self.discovery.bridges
self.bridges = newBridges
self.discoveryStatusText = self.discovery.statusText
self.discoveryDebugLog = self.discovery.debugLog
self.updateLastDiscoveredBridge(from: newBridges)
self.maybeAutoConnect()
}
private func observeDiscovery() {
withObservationTracking {
_ = self.discovery.bridges
_ = self.discovery.statusText
_ = self.discovery.debugLog
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
self.updateFromDiscovery()
self.observeDiscovery()
}
}
}
private func maybeAutoConnect() {
guard !self.didAutoConnect else { return }
guard let appModel = self.appModel else { return }

View File

@@ -2,7 +2,7 @@ import SwiftUI
import UIKit
struct BridgeDiscoveryDebugLogView: View {
@EnvironmentObject private var bridgeController: BridgeConnectionController
@Environment(BridgeConnectionController.self) private var bridgeController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View {

View File

@@ -1,9 +1,11 @@
import ClawdisKit
import Foundation
import Network
import Observation
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
@Observable
final class BridgeDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable {
var id = UUID()
var ts: Date
@@ -18,9 +20,9 @@ final class BridgeDiscoveryModel: ObservableObject {
var debugID: String
}
@Published var bridges: [DiscoveredBridge] = []
@Published var statusText: String = "Idle"
@Published private(set) var debugLog: [DebugLogEntry] = []
var bridges: [DiscoveredBridge] = []
var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = []
private var browser: NWBrowser?
private var debugLoggingEnabled = false

View File

@@ -3,12 +3,12 @@ import SwiftUI
struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ClawdisChatViewModel
@State private var viewModel: ClawdisChatViewModel
init(bridge: BridgeSession, sessionKey: String = "main") {
let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = StateObject(
wrappedValue: ClawdisChatViewModel(
self._viewModel = State(
initialValue: ClawdisChatViewModel(
sessionKey: sessionKey,
transport: transport))
}

View File

@@ -2,23 +2,23 @@ import SwiftUI
@main
struct ClawdisApp: App {
@StateObject private var appModel: NodeAppModel
@StateObject private var bridgeController: BridgeConnectionController
@State private var appModel: NodeAppModel
@State private var bridgeController: BridgeConnectionController
@Environment(\.scenePhase) private var scenePhase
init() {
BridgeSettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = StateObject(wrappedValue: appModel)
_bridgeController = StateObject(wrappedValue: BridgeConnectionController(appModel: appModel))
_appModel = State(initialValue: appModel)
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
}
var body: some Scene {
WindowGroup {
RootCanvas()
.environmentObject(self.appModel)
.environmentObject(self.appModel.voiceWake)
.environmentObject(self.bridgeController)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.bridgeController)
.onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) }
}

View File

@@ -1,16 +1,18 @@
import ClawdisKit
import Network
import Observation
import SwiftUI
@MainActor
final class NodeAppModel: ObservableObject {
@Published var isBackgrounded: Bool = false
@Observable
final class NodeAppModel {
var isBackgrounded: Bool = false
let screen = ScreenController()
let camera = CameraController()
@Published var bridgeStatusText: String = "Not connected"
@Published var bridgeServerName: String?
@Published var bridgeRemoteAddress: String?
@Published var connectedBridgeID: String?
var bridgeStatusText: String = "Not connected"
var bridgeServerName: String?
var bridgeRemoteAddress: String?
var connectedBridgeID: String?
private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>?

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct RootCanvas: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct RootTabs: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?

View File

@@ -1,14 +1,16 @@
import ClawdisKit
import Observation
import SwiftUI
import WebKit
@MainActor
final class ScreenController: ObservableObject {
@Observable
final class ScreenController {
let webView: WKWebView
@Published var mode: ClawdisScreenMode = .canvas
@Published var urlString: String = ""
@Published var errorText: String?
var mode: ClawdisScreenMode = .canvas
var urlString: String = ""
var errorText: String?
init() {
let config = WKWebViewConfiguration()

View File

@@ -2,7 +2,7 @@ import ClawdisKit
import SwiftUI
struct ScreenTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@Environment(NodeAppModel.self) private var appModel
var body: some View {
ZStack(alignment: .top) {

View File

@@ -3,7 +3,7 @@ import SwiftUI
import WebKit
struct ScreenWebView: UIViewRepresentable {
@ObservedObject var controller: ScreenController
var controller: ScreenController
func makeUIView(context: Context) -> WKWebView {
self.controller.webView

View File

@@ -1,19 +1,21 @@
import ClawdisKit
import Network
import Observation
import SwiftUI
import UIKit
@MainActor
private final class ConnectStatusStore: ObservableObject {
@Published var text: String?
@Observable
private final class ConnectStatusStore {
var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@EnvironmentObject private var bridgeController: BridgeConnectionController
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
@Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@@ -25,7 +27,7 @@ struct SettingsTab: View {
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@StateObject private var connectStatus = ConnectStatusStore()
@State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
@@ -264,6 +266,7 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil }
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
@@ -281,9 +284,8 @@ struct SettingsTab: View {
endpoint: bridge.endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
statusStore.text = status
}
})
@@ -330,6 +332,7 @@ struct SettingsTab: View {
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
@@ -347,9 +350,8 @@ struct SettingsTab: View {
endpoint: endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
statusStore.text = status
}
})

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct VoiceTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
var body: some View {

View File

@@ -1,5 +1,6 @@
import AVFAudio
import Foundation
import Observation
import Speech
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
@@ -76,12 +77,13 @@ extension AVAudioPCMBuffer {
}
@MainActor
final class VoiceWakeManager: NSObject, ObservableObject {
@Published var isEnabled: Bool = false
@Published var isListening: Bool = false
@Published var statusText: String = "Off"
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@Published var lastTriggeredCommand: String?
@Observable
final class VoiceWakeManager: NSObject {
var isEnabled: Bool = false
var isListening: Bool = false
var statusText: String = "Off"
var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
var lastTriggeredCommand: String?
private let audioEngine = AVAudioEngine()
private var speechRecognizer: SFSpeechRecognizer?

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?

View File

@@ -1,4 +1,5 @@
import Foundation
import Observation
import SwiftUI
#if !os(macOS)
@@ -8,7 +9,7 @@ import UniformTypeIdentifiers
@MainActor
struct ClawdisChatComposer: View {
@ObservedObject var viewModel: ClawdisChatViewModel
@Bindable var viewModel: ClawdisChatViewModel
#if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = []

View File

@@ -2,11 +2,11 @@ import SwiftUI
@MainActor
public struct ClawdisChatView: View {
@StateObject private var viewModel: ClawdisChatViewModel
@State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID()
public init(viewModel: ClawdisChatViewModel) {
self._viewModel = StateObject(wrappedValue: viewModel)
self._viewModel = State(initialValue: viewModel)
}
public var body: some View {

View File

@@ -1,5 +1,6 @@
import ClawdisKit
import Foundation
import Observation
import OSLog
import UniformTypeIdentifiers
@@ -12,21 +13,23 @@ import UIKit
private let chatUILogger = Logger(subsystem: "com.steipete.clawdis", category: "ClawdisChatUI")
@MainActor
public final class ClawdisChatViewModel: ObservableObject {
@Published public private(set) var messages: [ClawdisChatMessage] = []
@Published public var input: String = ""
@Published public var thinkingLevel: String = "off"
@Published public private(set) var isLoading = false
@Published public private(set) var isSending = false
@Published public var errorText: String?
@Published public var attachments: [ClawdisPendingAttachment] = []
@Published public private(set) var healthOK: Bool = true
@Published public private(set) var pendingRunCount: Int = 0
@Observable
public final class ClawdisChatViewModel {
public private(set) var messages: [ClawdisChatMessage] = []
public var input: String = ""
public var thinkingLevel: String = "off"
public private(set) var isLoading = false
public private(set) var isSending = false
public var errorText: String?
public var attachments: [ClawdisPendingAttachment] = []
public private(set) var healthOK: Bool = true
public private(set) var pendingRunCount: Int = 0
public let sessionKey: String
private let transport: any ClawdisChatTransport
private var eventTask: Task<Void, Never>?
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
private var pendingRuns = Set<String>() {
didSet { self.pendingRunCount = self.pendingRuns.count }
}