Skip to content

Commit fabd699

Browse files
committed
Build GUI dashboard state and recovery policies
Add activity snapshots, operation timelines, dashboard presentation models, device status policies, and flash workflow scaffolding for the saved-device dashboard. Split registry behavior so configure can merge rediscovered devices while profile edits update by ID and reject duplicate hosts or Bonjour fullnames. Route recovery actions, password invalidation, and backend-only readiness activity through app stores with focused Swift coverage. Tests: swift test; .venv/bin/pytest
1 parent a914319 commit fabd699

32 files changed

Lines changed: 2146 additions & 160 deletions
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import Combine
2+
import Foundation
3+
4+
enum ActivityScope: Equatable {
5+
case app
6+
case device(DeviceProfile.ID)
7+
case unknown
8+
}
9+
10+
struct ActivitySnapshot: Equatable {
11+
let isRunning: Bool
12+
let scope: ActivityScope
13+
let operationTitle: String
14+
let latestMessage: String?
15+
let timeline: [OperationTimelineItem]
16+
}
17+
18+
@MainActor
19+
final class ActivityStore: ObservableObject {
20+
@Published private(set) var snapshot = ActivitySnapshot(
21+
isRunning: false,
22+
scope: .unknown,
23+
operationTitle: "No active operation",
24+
latestMessage: nil,
25+
timeline: []
26+
)
27+
28+
private let coordinator: OperationCoordinator
29+
private var cancellables: Set<AnyCancellable> = []
30+
31+
init(coordinator: OperationCoordinator) {
32+
self.coordinator = coordinator
33+
coordinator.$activeOperation
34+
.sink { [weak self] _ in
35+
Task { @MainActor in
36+
self?.refresh()
37+
}
38+
}
39+
.store(in: &cancellables)
40+
coordinator.$activeDeviceID
41+
.sink { [weak self] _ in
42+
Task { @MainActor in
43+
self?.refresh()
44+
}
45+
}
46+
.store(in: &cancellables)
47+
coordinator.backend.$events
48+
.sink { [weak self] _ in
49+
Task { @MainActor in
50+
self?.refresh()
51+
}
52+
}
53+
.store(in: &cancellables)
54+
coordinator.backend.$isRunning
55+
.sink { [weak self] _ in
56+
Task { @MainActor in
57+
self?.refresh()
58+
}
59+
}
60+
.store(in: &cancellables)
61+
coordinator.backend.$activeOperationName
62+
.sink { [weak self] _ in
63+
Task { @MainActor in
64+
self?.refresh()
65+
}
66+
}
67+
.store(in: &cancellables)
68+
refresh()
69+
}
70+
71+
func refresh() {
72+
let events = coordinator.backend.events
73+
let timeline = OperationTimelineBuilder.timeline(from: events)
74+
let latestMessage = timeline.last?.detail ?? events.last?.summary
75+
let operation = coordinator.activeOperation?.operation
76+
?? coordinator.backend.activeOperationName
77+
?? latestOperation(from: events)
78+
let scope: ActivityScope
79+
if let activeDeviceID = coordinator.activeDeviceID {
80+
scope = .device(activeDeviceID)
81+
} else if isAppOperation(operation) {
82+
scope = .app
83+
} else {
84+
scope = .unknown
85+
}
86+
snapshot = ActivitySnapshot(
87+
isRunning: coordinator.backend.isRunning,
88+
scope: scope,
89+
operationTitle: operation.map(OperationTimelineBuilder.operationTitle) ?? (timeline.isEmpty ? "No active operation" : "Last operation"),
90+
latestMessage: latestMessage,
91+
timeline: timeline
92+
)
93+
}
94+
95+
private func latestOperation(from events: [BackendEvent]) -> String? {
96+
events.last?.operation
97+
}
98+
99+
private func isAppOperation(_ operation: String?) -> Bool {
100+
guard let operation else {
101+
return false
102+
}
103+
return ["capabilities", "validate-install", "paths"].contains(operation)
104+
}
105+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import SwiftUI
2+
3+
struct ActivityCompactView: View {
4+
@ObservedObject var activityStore: ActivityStore
5+
@ObservedObject var registry: DeviceRegistryStore
6+
7+
var body: some View {
8+
let snapshot = activityStore.snapshot
9+
HStack(spacing: 10) {
10+
Image(systemName: snapshot.isRunning ? "hourglass" : "checkmark.circle")
11+
.foregroundStyle(snapshot.isRunning ? Color.accentColor : Color.secondary)
12+
VStack(alignment: .leading, spacing: 2) {
13+
Text(title(snapshot))
14+
.font(.caption.weight(.medium))
15+
if let latest = snapshot.latestMessage, !latest.isEmpty {
16+
Text(latest)
17+
.font(.caption2)
18+
.foregroundStyle(.secondary)
19+
.lineLimit(1)
20+
.truncationMode(.middle)
21+
}
22+
}
23+
Spacer()
24+
if let last = snapshot.timeline.last {
25+
Text(last.title)
26+
.font(.caption2)
27+
.foregroundStyle(.secondary)
28+
}
29+
}
30+
.padding(.horizontal)
31+
.padding(.vertical, 8)
32+
.background(Color.secondary.opacity(0.06))
33+
}
34+
35+
private func title(_ snapshot: ActivitySnapshot) -> String {
36+
if case .device(let activeDeviceID) = snapshot.scope,
37+
let profile = registry.profile(id: activeDeviceID) {
38+
return "\(snapshot.operationTitle) - \(profile.title)"
39+
}
40+
return snapshot.operationTitle
41+
}
42+
}

macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ final class AddDeviceFlowStore: ObservableObject {
385385
try passwordStore.save(password, for: profile.keychainAccount)
386386
var saved = profile
387387
saved.passwordState = .available
388-
saved = try registry.save(saved)
388+
saved = try registry.updateProfile(saved)
389389
savedProfile = saved
390390
} catch {
391391
registry.updatePasswordState(.missing, for: profile.id)

macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ enum DashboardPrimaryAction: String, Equatable {
1313
struct DeviceDashboardSummary: Equatable {
1414
let profile: DeviceProfile
1515
let passwordState: DevicePasswordState
16+
let displayStatus: DeviceDisplayStatus
1617
let primaryAction: DashboardPrimaryAction
1718
let hostWarning: HostCompatibilityWarning?
1819
}
@@ -26,6 +27,7 @@ final class AppStore: ObservableObject {
2627
let deviceRegistry: DeviceRegistryStore
2728
let operationCoordinator: OperationCoordinator
2829
let passwordStore: PasswordStore
30+
let activityStore: ActivityStore
2931

3032
private var cancellables: Set<AnyCancellable> = []
3133

@@ -35,20 +37,23 @@ final class AppStore: ObservableObject {
3537
appReadinessStore: AppReadinessStore(backend: coordinator.backend),
3638
deviceRegistry: DeviceRegistryStore(),
3739
operationCoordinator: coordinator,
38-
passwordStore: KeychainPasswordStore()
40+
passwordStore: KeychainPasswordStore(),
41+
activityStore: ActivityStore(coordinator: coordinator)
3942
)
4043
}
4144

4245
init(
4346
appReadinessStore: AppReadinessStore,
4447
deviceRegistry: DeviceRegistryStore,
4548
operationCoordinator: OperationCoordinator,
46-
passwordStore: PasswordStore
49+
passwordStore: PasswordStore,
50+
activityStore: ActivityStore? = nil
4751
) {
4852
self.appReadinessStore = appReadinessStore
4953
self.deviceRegistry = deviceRegistry
5054
self.operationCoordinator = operationCoordinator
5155
self.passwordStore = passwordStore
56+
self.activityStore = activityStore ?? ActivityStore(coordinator: operationCoordinator)
5257

5358
appReadinessStore.objectWillChange
5459
.sink { [weak self] _ in
@@ -65,6 +70,11 @@ final class AppStore: ObservableObject {
6570
self?.objectWillChange.send()
6671
}
6772
.store(in: &cancellables)
73+
self.activityStore.objectWillChange
74+
.sink { [weak self] _ in
75+
self?.objectWillChange.send()
76+
}
77+
.store(in: &cancellables)
6878
deviceRegistry.$profiles
6979
.sink { [weak self] profiles in
7080
Task { @MainActor in
@@ -99,28 +109,30 @@ final class AppStore: ObservableObject {
99109
}
100110

101111
func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary {
102-
let passwordState = passwordStore.state(for: profile.keychainAccount)
103-
let primaryAction: DashboardPrimaryAction
104-
if passwordState != .available {
105-
primaryAction = .replacePassword
106-
} else if profile.lastCheckup == nil {
107-
primaryAction = .runCheckup
108-
} else if profile.lastDeploy == nil {
109-
primaryAction = .installSMB
110-
} else if profile.lastCheckup?.failCount ?? 0 > 0 || profile.lastCheckup?.warnCount ?? 0 > 0 {
111-
primaryAction = .viewCheckup
112-
} else {
113-
primaryAction = .openSMB
114-
}
112+
let passwordState = effectivePasswordState(for: profile)
113+
let displayStatus = DeviceStatusPolicy.status(
114+
for: profile,
115+
passwordState: passwordState,
116+
activeOperation: operationCoordinator.activeOperation
117+
)
118+
let primaryAction = DashboardPrimaryActionPolicy.primaryAction(
119+
for: profile,
120+
passwordState: passwordState,
121+
activeOperation: operationCoordinator.activeOperation
122+
)
115123
return DeviceDashboardSummary(
116124
profile: profile,
117125
passwordState: passwordState,
126+
displayStatus: displayStatus,
118127
primaryAction: primaryAction,
119128
hostWarning: HostCompatibilityPolicy.warning()
120129
)
121130
}
122131

123132
func password(for profile: DeviceProfile) -> String? {
133+
if profile.passwordState == .invalid {
134+
return nil
135+
}
124136
do {
125137
return try passwordStore.password(for: profile.keychainAccount)
126138
} catch PasswordStoreError.missing {
@@ -137,6 +149,24 @@ final class AppStore: ObservableObject {
137149
deviceRegistry.updatePasswordState(.available, for: profile.id)
138150
}
139151

152+
func updateSettings(_ settings: DeviceProfileSettings, for profile: DeviceProfile) throws {
153+
var updated = profile
154+
updated.settings = settings
155+
try deviceRegistry.updateProfile(updated)
156+
}
157+
158+
func rename(_ profile: DeviceProfile, displayName: String) throws {
159+
var updated = profile
160+
updated.displayName = displayName
161+
try deviceRegistry.updateProfile(updated)
162+
}
163+
164+
func updateHost(_ profile: DeviceProfile, host: String) throws {
165+
var updated = profile
166+
updated.host = host
167+
try deviceRegistry.updateProfile(updated)
168+
}
169+
140170
func forget(_ profile: DeviceProfile) throws {
141171
try passwordStore.deletePassword(for: profile.keychainAccount)
142172
try deviceRegistry.delete(profile)
@@ -148,8 +178,15 @@ final class AppStore: ObservableObject {
148178

149179
func refreshPasswordStates() {
150180
for profile in deviceRegistry.profiles {
151-
deviceRegistry.updatePasswordState(passwordStore.state(for: profile.keychainAccount), for: profile.id)
181+
deviceRegistry.updatePasswordState(effectivePasswordState(for: profile), for: profile.id)
182+
}
183+
}
184+
185+
private func effectivePasswordState(for profile: DeviceProfile) -> DevicePasswordState {
186+
if profile.passwordState == .invalid {
187+
return .invalid
152188
}
189+
return passwordStore.state(for: profile.keychainAccount)
153190
}
154191

155192
private func syncSelection(profiles: [DeviceProfile]) {

macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ final class BackendClient: ObservableObject {
1010
@Published var currentStage: String?
1111
@Published var currentRisk: String?
1212
@Published var currentCancellable: Bool?
13+
@Published private(set) var activeOperationName: String?
1314

1415
private let runner: any HelperRunning
1516
private var runTask: Task<Void, Never>?
@@ -33,6 +34,7 @@ final class BackendClient: ObservableObject {
3334
currentStage = nil
3435
currentRisk = nil
3536
currentCancellable = nil
37+
activeOperationName = nil
3638
}
3739

3840
var canCancel: Bool {
@@ -51,6 +53,7 @@ final class BackendClient: ObservableObject {
5153
currentStage = nil
5254
currentRisk = nil
5355
currentCancellable = nil
56+
activeOperationName = operation
5457
activeCall = BackendCall(operation: operation, params: runParams, context: context)
5558
let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines)
5659
let runner = self.runner
@@ -107,6 +110,7 @@ final class BackendClient: ObservableObject {
107110
isRunning = false
108111
runTask = nil
109112
activeCall = nil
113+
activeOperationName = nil
110114
}
111115
}
112116

macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ struct ConnectView: View {
7070
}
7171

7272
if let error = store.error {
73-
ErrorRecoveryView(error: error)
73+
ErrorBlock(error: error)
7474
}
7575
}
7676
.padding()
@@ -186,24 +186,3 @@ private struct ConfiguredDeviceView: View {
186186
.font(.caption)
187187
}
188188
}
189-
190-
private struct ErrorRecoveryView: View {
191-
let error: BackendErrorViewModel
192-
193-
var body: some View {
194-
VStack(alignment: .leading, spacing: 4) {
195-
Text(error.recovery?.title ?? error.code)
196-
.font(.body.weight(.medium))
197-
Text(error.message)
198-
.font(.caption)
199-
if let recovery = error.recovery, !recovery.actions.isEmpty {
200-
ForEach(recovery.actions, id: \.self) { action in
201-
Text(action)
202-
.font(.caption)
203-
.foregroundStyle(.secondary)
204-
}
205-
}
206-
}
207-
.foregroundStyle(.red)
208-
}
209-
}

0 commit comments

Comments
 (0)