|
| 1 | +# TimeCapsuleSMB GUI Architecture |
| 2 | + |
| 3 | +This is the living architecture target for the macOS GUI. Future GUI changes |
| 4 | +should reference this file and keep the implementation moving toward these |
| 5 | +boundaries. |
| 6 | + |
| 7 | +## Product Shape |
| 8 | + |
| 9 | +The GUI is a native multi-device manager for Apple Time Capsules. It should not |
| 10 | +feel like a wrapper around CLI commands. |
| 11 | + |
| 12 | +The main user flows are: |
| 13 | + |
| 14 | +1. Add one or more Time Capsules. |
| 15 | +2. Save device profiles with per-device config files. |
| 16 | +3. Store passwords in Keychain only. |
| 17 | +4. Install or update SMB support. |
| 18 | +5. Run checkups and show structured health. |
| 19 | +6. Run maintenance tasks with explicit plans and confirmations. |
| 20 | +7. Surface advanced logs and helper details only when needed. |
| 21 | + |
| 22 | +`bootstrap`, `paths`, and `validate-install` are app readiness concerns. They |
| 23 | +run in the background or diagnostics surfaces, not as first-class user actions. |
| 24 | +The bundled app should already contain the helper, runtime, tools, artifacts, |
| 25 | +and manifests needed by those checks. |
| 26 | + |
| 27 | +## Architectural Principles |
| 28 | + |
| 29 | +- The app is profile-first. Screens operate on `DeviceProfile`, not loose host |
| 30 | + fields or a shared `.env`. |
| 31 | +- Views are thin. They render state and send user intents to stores. |
| 32 | +- Stores own state machines. Each workflow has explicit states, terminal states, |
| 33 | + validation, and event-to-model parsing. |
| 34 | +- Backend execution is centralized. There is one global `OperationCoordinator` |
| 35 | + and one active helper operation at a time. |
| 36 | +- Backend contracts are typed at the GUI boundary. Swift decodes payloads into |
| 37 | + models and does not parse human log text for app behavior. |
| 38 | +- Credentials never persist to `.env`. GUI passwords live in Keychain and are |
| 39 | + passed per operation as credentials. |
| 40 | +- Runtime context is explicit. Profile-scoped operations always carry |
| 41 | + `DeviceRuntimeContext`. |
| 42 | +- Device snapshots are attributed to the operation profile ID, not the currently |
| 43 | + selected sidebar item. |
| 44 | +- Advanced diagnostics exist, but normal workflows use user-facing language: |
| 45 | + Install / Update, Checkup, Maintenance, Add Time Capsule. |
| 46 | + |
| 47 | +## Layer Map |
| 48 | + |
| 49 | +Target source organization: |
| 50 | + |
| 51 | +```text |
| 52 | +TimeCapsuleSMBApp/ |
| 53 | + App/ |
| 54 | + AppStore.swift |
| 55 | + AppReadinessStore.swift |
| 56 | + Backend/ |
| 57 | + BackendClient.swift |
| 58 | + BackendPayloads.swift |
| 59 | + HelperLocator.swift |
| 60 | + HelperRunner.swift |
| 61 | + OperationCoordinator.swift |
| 62 | + OperationParams.swift |
| 63 | + PendingConfirmation.swift |
| 64 | + Profiles/ |
| 65 | + DeviceProfile.swift |
| 66 | + DeviceRegistryStore.swift |
| 67 | + PasswordStore.swift |
| 68 | + Policies/ |
| 69 | + HostCompatibilityPolicy.swift |
| 70 | + Workflows/ |
| 71 | + AddDeviceFlowStore.swift |
| 72 | + DashboardStore.swift |
| 73 | + DeployWorkflowStore.swift |
| 74 | + DoctorStore.swift |
| 75 | + MaintenanceStore.swift |
| 76 | + Views/ |
| 77 | + Shell/ |
| 78 | + AddDevice/ |
| 79 | + Dashboard/ |
| 80 | + Diagnostics/ |
| 81 | + Components/ |
| 82 | +``` |
| 83 | + |
| 84 | +The current code can keep file names during transition, but new substantial |
| 85 | +screen code should move toward this split instead of growing `ContentView.swift`. |
| 86 | + |
| 87 | +## Ownership |
| 88 | + |
| 89 | +### AppStore |
| 90 | + |
| 91 | +`AppStore` is the app composition root. It owns: |
| 92 | + |
| 93 | +- `AppReadinessStore` |
| 94 | +- `DeviceRegistryStore` |
| 95 | +- `OperationCoordinator` |
| 96 | +- `PasswordStore` |
| 97 | +- selected profile ID |
| 98 | +- high-level navigation state |
| 99 | + |
| 100 | +`AppStore` should not parse backend events. It may derive cross-cutting summary |
| 101 | +state such as the dashboard primary action, host compatibility warnings, and |
| 102 | +password availability. |
| 103 | + |
| 104 | +### DeviceRegistryStore |
| 105 | + |
| 106 | +`DeviceRegistryStore` owns persistent device profiles: |
| 107 | + |
| 108 | +```text |
| 109 | +~/Library/Application Support/TimeCapsuleSMB/devices.json |
| 110 | +~/Library/Application Support/TimeCapsuleSMB/Devices/<device-id>/.env |
| 111 | +``` |
| 112 | + |
| 113 | +The registry is responsible for: |
| 114 | + |
| 115 | +- loading and saving `devices.json` |
| 116 | +- creating per-device config directories |
| 117 | +- duplicate matching by Bonjour fullname and normalized host |
| 118 | +- deleting profile config directories |
| 119 | +- persisting checkup and deploy snapshots |
| 120 | + |
| 121 | +It must not delete corrupt registries automatically. Corrupt registry state |
| 122 | +goes to diagnostics and waits for explicit user recovery. |
| 123 | + |
| 124 | +### PasswordStore |
| 125 | + |
| 126 | +`PasswordStore` abstracts Keychain access. |
| 127 | + |
| 128 | +Production storage: |
| 129 | + |
| 130 | +```text |
| 131 | +service = TimeCapsuleSMB.DevicePassword |
| 132 | +account = <DeviceProfile.id> |
| 133 | +``` |
| 134 | + |
| 135 | +Rules: |
| 136 | + |
| 137 | +- Add Device saves a password only after `configure` succeeds. |
| 138 | +- `.env` files never contain `TC_PASSWORD`. |
| 139 | +- Missing Keychain item maps to `passwordNeeded` or `.missing`. |
| 140 | +- Keychain access errors map to `.keychainUnavailable`. |
| 141 | +- Auth failures mark the password invalid, but do not delete it automatically. |
| 142 | +- Forget Device deletes the profile, per-device config directory, and Keychain |
| 143 | + item as one user-visible action. |
| 144 | + |
| 145 | +## Backend Execution |
| 146 | + |
| 147 | +`BackendClient` owns process execution state and raw events. It should not know |
| 148 | +about UI screens. |
| 149 | + |
| 150 | +`OperationCoordinator` is the only workflow-facing entry point for helper runs: |
| 151 | + |
| 152 | +```swift |
| 153 | +run(operation:params:profile:password:) |
| 154 | +run(operation:params:context:activeDeviceID:password:) |
| 155 | +``` |
| 156 | + |
| 157 | +Responsibilities: |
| 158 | + |
| 159 | +- reject a second operation while one is running |
| 160 | +- expose active operation and active profile ID |
| 161 | +- inject password credentials when provided |
| 162 | +- delegate profile context to `BackendClient` |
| 163 | +- preserve context through confirmation replay |
| 164 | +- support cancel and clear semantics |
| 165 | + |
| 166 | +Profile-scoped operations must pass `DeviceRuntimeContext`. The backend layer |
| 167 | +injects: |
| 168 | + |
| 169 | +- `params["config"] = context.configURL.path` |
| 170 | +- `TCAPSULE_CONFIG = context.configURL.path` |
| 171 | + |
| 172 | +`TCAPSULE_STATE_DIR` remains app-level so bootstrap/version/cache state is not |
| 173 | +multiplied per profile. |
| 174 | + |
| 175 | +## Operation Attribution |
| 176 | + |
| 177 | +Workflow stores must attribute terminal results to the profile that started the |
| 178 | +operation. |
| 179 | + |
| 180 | +Do not write snapshots using `selectedProfile` at result time. The user can |
| 181 | +change sidebar selection while an operation runs. A workflow should capture |
| 182 | +`activeProfileID` when it starts, then use that ID when persisting: |
| 183 | + |
| 184 | +- `DeviceCheckupSnapshot` |
| 185 | +- `DeviceDeploySnapshot` |
| 186 | +- future maintenance snapshots |
| 187 | + |
| 188 | +If `OperationCoordinator` rejects a run, the caller must leave or restore its |
| 189 | +state to a non-running failure state. No workflow should enter `running`, |
| 190 | +`planning`, `configuring`, or `saving` unless the operation actually started. |
| 191 | + |
| 192 | +## Backend Contract |
| 193 | + |
| 194 | +The Python app API is the source of truth for structured payloads. GUI-facing |
| 195 | +payloads should remain stable and versioned. |
| 196 | + |
| 197 | +Important contracts: |
| 198 | + |
| 199 | +- `discover` returns `devices`, a deduped list of selectable Time Capsules. |
| 200 | +- Each discovered device includes `selected_record`, which the GUI passes back |
| 201 | + to `configure`. |
| 202 | +- `configure` accepts either `selected_record` or `host`. |
| 203 | +- Manual `host` values are treated as root SSH targets by the backend. |
| 204 | +- GUI `configure` sends `persist_password: false`. |
| 205 | +- Deploy, doctor, activate, uninstall, and fsck receive credentials from |
| 206 | + Keychain-backed GUI state. |
| 207 | + |
| 208 | +Swift should prefer decoding structured fields over reading `summary` strings. |
| 209 | +Raw summaries are for display only. |
| 210 | + |
| 211 | +## Add Device Flow |
| 212 | + |
| 213 | +Add Device is a state machine with mutually exclusive entry modes: |
| 214 | + |
| 215 | +- Discover |
| 216 | +- Manual Address |
| 217 | + |
| 218 | +States: |
| 219 | + |
| 220 | +```text |
| 221 | +idle |
| 222 | +discovering |
| 223 | +discoveryEmpty |
| 224 | +discoveryReady |
| 225 | +manualEntry |
| 226 | +passwordEntry |
| 227 | +configuring |
| 228 | +savingProfile |
| 229 | +saved |
| 230 | +authFailed |
| 231 | +unsupported |
| 232 | +failed |
| 233 | +``` |
| 234 | + |
| 235 | +Discover mode: |
| 236 | + |
| 237 | +- runs backend `discover` |
| 238 | +- shows only `payload.devices` |
| 239 | +- auto-selects if there is exactly one device |
| 240 | +- fills and disables Host/IP from the selected device |
| 241 | +- routes already saved devices to their existing profile |
| 242 | + |
| 243 | +Manual mode: |
| 244 | + |
| 245 | +- clears discovered candidates from the active flow |
| 246 | +- enables Host/IP entry |
| 247 | +- assumes root SSH unless the user explicitly enters a user |
| 248 | + |
| 249 | +Save rules: |
| 250 | + |
| 251 | +- no profile is saved until `configure` succeeds |
| 252 | +- wrong password saves nothing |
| 253 | +- unsupported device saves nothing |
| 254 | +- duplicate host or Bonjour fullname updates the existing profile |
| 255 | +- Keychain save failure may keep the profile, but marks password state missing |
| 256 | + |
| 257 | +## Dashboard |
| 258 | + |
| 259 | +The dashboard has these user-facing tabs: |
| 260 | + |
| 261 | +- Overview |
| 262 | +- Install / Update |
| 263 | +- Checkup |
| 264 | +- Maintenance |
| 265 | +- Advanced |
| 266 | + |
| 267 | +Overview is decision-oriented. It shows device identity, password state, host |
| 268 | +macOS warnings, last checkup, last install/update, and one primary action. |
| 269 | + |
| 270 | +Install / Update wraps deploy planning and deploy execution. Dry-run planning |
| 271 | +should remain first-class. |
| 272 | + |
| 273 | +Checkup wraps doctor and shows grouped checks by domain and status. |
| 274 | + |
| 275 | +Maintenance wraps: |
| 276 | + |
| 277 | +- NetBSD4 activation |
| 278 | +- uninstall |
| 279 | +- fsck |
| 280 | +- repair xattrs |
| 281 | +- future flash workflow |
| 282 | + |
| 283 | +Advanced contains raw events, helper path, profile ID, config path, and other |
| 284 | +technical diagnostics. |
| 285 | + |
| 286 | +## App Readiness And Bundling |
| 287 | + |
| 288 | +Readiness runs at app launch and validates the bundled runtime. It is not a |
| 289 | +device workflow. |
| 290 | + |
| 291 | +Production bundle target: |
| 292 | + |
| 293 | +```text |
| 294 | +Contents/MacOS/TimeCapsuleSMB |
| 295 | +Contents/Helpers/tcapsule |
| 296 | +Contents/Resources/Distribution/... |
| 297 | +Contents/Resources/Tools/... |
| 298 | +``` |
| 299 | + |
| 300 | +The app sets: |
| 301 | + |
| 302 | +- `TCAPSULE_CONFIG` per profile operation |
| 303 | +- `TCAPSULE_STATE_DIR` to app support |
| 304 | +- `TCAPSULE_DISTRIBUTION_ROOT` to bundled distribution resources |
| 305 | +- `PATH` to bundled tools where required |
| 306 | + |
| 307 | +If bundled resources are missing or invalid, normal workflows are blocked and |
| 308 | +diagnostics explain that the app install is incomplete. |
| 309 | + |
| 310 | +## Host Compatibility |
| 311 | + |
| 312 | +`HostCompatibilityPolicy` is pure Swift and side-effect free. It warns |
| 313 | +non-blockingly for host macOS versions with known Time Machine network backup |
| 314 | +issues: |
| 315 | + |
| 316 | +- macOS 15.7.5 |
| 317 | +- macOS 15.7.6 |
| 318 | +- macOS 15.7.7 |
| 319 | +- macOS 26.4.x |
| 320 | + |
| 321 | +Warnings appear globally or on dashboards, but they do not prevent SMB install |
| 322 | +or maintenance. |
| 323 | + |
| 324 | +## Error Handling |
| 325 | + |
| 326 | +Errors should preserve machine-readable codes and user-facing recovery. |
| 327 | + |
| 328 | +Workflow stores should map backend errors into: |
| 329 | + |
| 330 | +- state transition |
| 331 | +- concise visible message |
| 332 | +- recovery action, when available |
| 333 | +- raw details in Advanced or Diagnostics |
| 334 | + |
| 335 | +Authentication failures must prompt for password replacement without deleting |
| 336 | +the existing Keychain item automatically. |
| 337 | + |
| 338 | +Unsupported devices must show the compatibility explanation and avoid creating |
| 339 | +profiles. |
| 340 | + |
| 341 | +## Testing Standards |
| 342 | + |
| 343 | +Every workflow state enum should have an inventory test. Tests should verify |
| 344 | +state transitions and side effects through mocks, not string grep checks. |
| 345 | + |
| 346 | +Required coverage areas: |
| 347 | + |
| 348 | +- missing, corrupt, save, update, duplicate, and delete registry behavior |
| 349 | +- Keychain save/read/update/delete, missing item, and unavailable item |
| 350 | +- backend context injection and confirmation replay context preservation |
| 351 | +- operation rejection while another operation is active |
| 352 | +- add-device discover/manual/auth/unsupported/duplicate/password-save failure |
| 353 | +- dashboard primary action derivation |
| 354 | +- operation snapshots attributed to active operation profile ID |
| 355 | +- host compatibility warning matrix |
| 356 | +- helper locator production and development environment behavior |
| 357 | + |
| 358 | +Regression runs: |
| 359 | + |
| 360 | +```bash |
| 361 | +cd macos/TimeCapsuleSMB && swift test |
| 362 | +.venv/bin/pytest |
| 363 | +``` |
| 364 | + |
| 365 | +Run Python tests from the repo root. Run Swift tests from |
| 366 | +`macos/TimeCapsuleSMB`. |
0 commit comments