diff --git a/.gitignore b/.gitignore index a59cc14..23fb2f0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,13 @@ dist/ *.egg-info/ *.egg +# Swift / macOS GUI build outputs +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate +xcuserdata/ + # Local dependencies and AirPyrt env .deps/ .airpyrt-venv/ diff --git a/GUI_ARCH.md b/GUI_ARCH.md new file mode 100644 index 0000000..c461abe --- /dev/null +++ b/GUI_ARCH.md @@ -0,0 +1,366 @@ +# TimeCapsuleSMB GUI Architecture + +This is the living architecture target for the macOS GUI. Future GUI changes +should reference this file and keep the implementation moving toward these +boundaries. + +## Product Shape + +The GUI is a native multi-device manager for Apple Time Capsules. It should not +feel like a wrapper around CLI commands. + +The main user flows are: + +1. Add one or more Time Capsules. +2. Save device profiles with per-device config files. +3. Store passwords in Keychain only. +4. Install or update SMB support. +5. Run checkups and show structured health. +6. Run maintenance tasks with explicit plans and confirmations. +7. Surface advanced logs and helper details only when needed. + +`bootstrap`, `paths`, and `validate-install` are app readiness concerns. They +run in the background or diagnostics surfaces, not as first-class user actions. +The bundled app should already contain the helper, runtime, tools, artifacts, +and manifests needed by those checks. + +## Architectural Principles + +- The app is profile-first. Screens operate on `DeviceProfile`, not loose host + fields or a shared `.env`. +- Views are thin. They render state and send user intents to stores. +- Stores own state machines. Each workflow has explicit states, terminal states, + validation, and event-to-model parsing. +- Backend execution is centralized. There is one global `OperationCoordinator` + and one active helper operation at a time. +- Backend contracts are typed at the GUI boundary. Swift decodes payloads into + models and does not parse human log text for app behavior. +- Credentials never persist to `.env`. GUI passwords live in Keychain and are + passed per operation as credentials. +- Runtime context is explicit. Profile-scoped operations always carry + `DeviceRuntimeContext`. +- Device snapshots are attributed to the operation profile ID, not the currently + selected sidebar item. +- Advanced diagnostics exist, but normal workflows use user-facing language: + Install / Update, Checkup, Maintenance, Add Time Capsule. + +## Layer Map + +Target source organization: + +```text +TimeCapsuleSMBApp/ + App/ + AppStore.swift + AppReadinessStore.swift + Backend/ + BackendClient.swift + BackendPayloads.swift + HelperLocator.swift + HelperRunner.swift + OperationCoordinator.swift + OperationParams.swift + PendingConfirmation.swift + Profiles/ + DeviceProfile.swift + DeviceRegistryStore.swift + PasswordStore.swift + Policies/ + HostCompatibilityPolicy.swift + Workflows/ + AddDeviceFlowStore.swift + DashboardStore.swift + DeployWorkflowStore.swift + DoctorStore.swift + MaintenanceStore.swift + Views/ + Shell/ + AddDevice/ + Dashboard/ + Diagnostics/ + Components/ +``` + +The current code can keep file names during transition, but new substantial +screen code should move toward this split instead of growing `ContentView.swift`. + +## Ownership + +### AppStore + +`AppStore` is the app composition root. It owns: + +- `AppReadinessStore` +- `DeviceRegistryStore` +- `OperationCoordinator` +- `PasswordStore` +- selected profile ID +- high-level navigation state + +`AppStore` should not parse backend events. It may derive cross-cutting summary +state such as the dashboard primary action, host compatibility warnings, and +password availability. + +### DeviceRegistryStore + +`DeviceRegistryStore` owns persistent device profiles: + +```text +~/Library/Application Support/TimeCapsuleSMB/devices.json +~/Library/Application Support/TimeCapsuleSMB/Devices//.env +``` + +The registry is responsible for: + +- loading and saving `devices.json` +- creating per-device config directories +- duplicate matching by Bonjour fullname and normalized host +- deleting profile config directories +- persisting checkup and deploy snapshots + +It must not delete corrupt registries automatically. Corrupt registry state +goes to diagnostics and waits for explicit user recovery. + +### PasswordStore + +`PasswordStore` abstracts Keychain access. + +Production storage: + +```text +service = TimeCapsuleSMB.DevicePassword +account = +``` + +Rules: + +- Add Device saves a password only after `configure` succeeds. +- `.env` files never contain `TC_PASSWORD`. +- Missing Keychain item maps to `passwordNeeded` or `.missing`. +- Keychain access errors map to `.keychainUnavailable`. +- Auth failures mark the password invalid, but do not delete it automatically. +- Forget Device deletes the profile, per-device config directory, and Keychain + item as one user-visible action. + +## Backend Execution + +`BackendClient` owns process execution state and raw events. It should not know +about UI screens. + +`OperationCoordinator` is the only workflow-facing entry point for helper runs: + +```swift +run(operation:params:profile:password:) +run(operation:params:context:activeDeviceID:password:) +``` + +Responsibilities: + +- reject a second operation while one is running +- expose active operation and active profile ID +- inject password credentials when provided +- delegate profile context to `BackendClient` +- preserve context through confirmation replay +- support cancel and clear semantics + +Profile-scoped operations must pass `DeviceRuntimeContext`. The backend layer +injects: + +- `params["config"] = context.configURL.path` +- `TCAPSULE_CONFIG = context.configURL.path` + +`TCAPSULE_STATE_DIR` remains app-level so bootstrap/version/cache state is not +multiplied per profile. + +## Operation Attribution + +Workflow stores must attribute terminal results to the profile that started the +operation. + +Do not write snapshots using `selectedProfile` at result time. The user can +change sidebar selection while an operation runs. A workflow should capture +`activeProfileID` when it starts, then use that ID when persisting: + +- `DeviceCheckupSnapshot` +- `DeviceDeploySnapshot` +- future maintenance snapshots + +If `OperationCoordinator` rejects a run, the caller must leave or restore its +state to a non-running failure state. No workflow should enter `running`, +`planning`, `configuring`, or `saving` unless the operation actually started. + +## Backend Contract + +The Python app API is the source of truth for structured payloads. GUI-facing +payloads should remain stable and versioned. + +Important contracts: + +- `discover` returns `devices`, a deduped list of selectable Time Capsules. +- Each discovered device includes `selected_record`, which the GUI passes back + to `configure`. +- `configure` accepts either `selected_record` or `host`. +- Manual `host` values are treated as root SSH targets by the backend. +- GUI `configure` sends `persist_password: false`. +- Deploy, doctor, activate, uninstall, and fsck receive credentials from + Keychain-backed GUI state. + +Swift should prefer decoding structured fields over reading `summary` strings. +Raw summaries are for display only. + +## Add Device Flow + +Add Device is a state machine with mutually exclusive entry modes: + +- Discover +- Manual Address + +States: + +```text +idle +discovering +discoveryEmpty +discoveryReady +manualEntry +passwordEntry +configuring +savingProfile +saved +authFailed +unsupported +failed +``` + +Discover mode: + +- runs backend `discover` +- shows only `payload.devices` +- auto-selects if there is exactly one device +- fills and disables Host/IP from the selected device +- routes already saved devices to their existing profile + +Manual mode: + +- clears discovered candidates from the active flow +- enables Host/IP entry +- assumes root SSH unless the user explicitly enters a user + +Save rules: + +- no profile is saved until `configure` succeeds +- wrong password saves nothing +- unsupported device saves nothing +- duplicate host or Bonjour fullname updates the existing profile +- Keychain save failure may keep the profile, but marks password state missing + +## Dashboard + +The dashboard has these user-facing tabs: + +- Overview +- Install / Update +- Checkup +- Maintenance +- Advanced + +Overview is decision-oriented. It shows device identity, password state, host +macOS warnings, last checkup, last install/update, and one primary action. + +Install / Update wraps deploy planning and deploy execution. Dry-run planning +should remain first-class. + +Checkup wraps doctor and shows grouped checks by domain and status. + +Maintenance wraps: + +- NetBSD4 activation +- uninstall +- fsck +- repair xattrs +- future flash workflow + +Advanced contains raw events, helper path, profile ID, config path, and other +technical diagnostics. + +## App Readiness And Bundling + +Readiness runs at app launch and validates the bundled runtime. It is not a +device workflow. + +Production bundle target: + +```text +Contents/MacOS/TimeCapsuleSMB +Contents/Helpers/tcapsule +Contents/Resources/Distribution/... +Contents/Resources/Tools/... +``` + +The app sets: + +- `TCAPSULE_CONFIG` per profile operation +- `TCAPSULE_STATE_DIR` to app support +- `TCAPSULE_DISTRIBUTION_ROOT` to bundled distribution resources +- `PATH` to bundled tools where required + +If bundled resources are missing or invalid, normal workflows are blocked and +diagnostics explain that the app install is incomplete. + +## Host Compatibility + +`HostCompatibilityPolicy` is pure Swift and side-effect free. It warns +non-blockingly for host macOS versions with known Time Machine network backup +issues: + +- macOS 15.7.5 +- macOS 15.7.6 +- macOS 15.7.7 +- macOS 26.4.x + +Warnings appear globally or on dashboards, but they do not prevent SMB install +or maintenance. + +## Error Handling + +Errors should preserve machine-readable codes and user-facing recovery. + +Workflow stores should map backend errors into: + +- state transition +- concise visible message +- recovery action, when available +- raw details in Advanced or Diagnostics + +Authentication failures must prompt for password replacement without deleting +the existing Keychain item automatically. + +Unsupported devices must show the compatibility explanation and avoid creating +profiles. + +## Testing Standards + +Every workflow state enum should have an inventory test. Tests should verify +state transitions and side effects through mocks, not string grep checks. + +Required coverage areas: + +- missing, corrupt, save, update, duplicate, and delete registry behavior +- Keychain save/read/update/delete, missing item, and unavailable item +- backend context injection and confirmation replay context preservation +- operation rejection while another operation is active +- add-device discover/manual/auth/unsupported/duplicate/password-save failure +- dashboard primary action derivation +- operation snapshots attributed to active operation profile ID +- host compatibility warning matrix +- helper locator production and development environment behavior + +Regression runs: + +```bash +cd macos/TimeCapsuleSMB && swift test +.venv/bin/pytest +``` + +Run Python tests from the repo root. Run Swift tests from +`macos/TimeCapsuleSMB`. diff --git a/gui.md b/gui.md new file mode 100644 index 0000000..5b035a3 --- /dev/null +++ b/gui.md @@ -0,0 +1,778 @@ +# TimeCapsuleSMB GUI UX Brainstorm + +This document describes what the macOS GUI should feel like and how its user +experience should be shaped. It is based on the CLI product surface and README, +translated into a native app product surface. + +## Product Direction + +The app should feel like a device manager for old Time Capsules, not like a +terminal wrapper. + +The main user job is: + +1. Find one or more Time Capsules on the network. +2. Save them as named devices. +3. Install or update modern SMB support. +4. Verify Finder and Time Machine readiness. +5. Recover from common disk, metadata, Bonjour, SSH, reboot, or NetBSD4 issues. +6. Remove the install safely if desired. + +The app should not expose repo-oriented setup commands. `bootstrap`, `paths`, +and `validate-install` should run as app readiness checks in the background. +Normal users should never see those as actions. If the bundled app is damaged or +missing binaries, the app should say the app install is damaged and point the +user to reinstall the app. + +The app should support multiple saved Time Capsules from the beginning. A user +may own more than one unit, may test Gen 5 and Gen 1-4 devices side by side, or +may need to manage a friend's device temporarily. + +## Visual Tone + +This should be a quiet Mac utility: + +- sidebar + detail layout +- dense but readable status rows +- clear progress timelines for long operations +- simple colored health badges +- native controls and sheets +- no decorative landing page +- no raw JSON as a primary UX +- no "wizard wall of text" + +Use short, concrete text. Prefer device facts and next actions over explanation. +Deep logs, raw events, payload details, and advanced flags should exist, but +behind disclosure controls. + +## App Shell + +Recommended top-level structure: + +- Sidebar + - All Time Capsules + - Add Time Capsule + - Activity + - Settings + - Help + +- Device detail area + - selected device summary + - primary action + - health and warnings + - workflow tabs or sections + +- Bottom or collapsible activity drawer + - latest operation progress + - log lines + - copy diagnostics button + +The sidebar device rows should show: + +- user nickname +- Bonjour/device name +- host or IP +- health badge +- last seen time +- small NetBSD4 marker when relevant + +Example row statuses: + +- Not set up +- Ready to install +- Installing +- Rebooting +- Verifying +- Healthy +- Needs activation +- Warning +- Failed +- Removed +- Offline + +## First Launch + +The first launch should do background app readiness immediately: + +- verify bundled helper/runtime is present +- verify bundled Samba, mDNS, NBNS, scripts, and manifest are present +- check app version support, using cached network metadata when available +- detect host macOS version and Time Machine warning status +- start Bonjour discovery + +The user-facing first screen should be an empty device list with active +discovery results, not a setup checklist. + +Empty state: + +- title: "No Time Capsules saved" +- primary button: "Add Time Capsule" +- secondary button: "Enter Address Manually" +- inline list of discovered candidates if any + +Do not ask the user to run setup or install dependencies. If a required bundled +asset is missing, show a blocking app readiness alert: + +"TimeCapsuleSMB is incomplete. Reinstall the app." + +Advanced details can show the failed checks, but the main remediation should be +reinstalling the app. + +## Multiple Saved Devices + +Each saved device should be a profile with a stable app-level identity. + +User-visible profile fields: + +- nickname +- Bonjour name +- host/IP +- model +- generation +- OS family +- payload family +- last known SMB URL +- last doctor result +- last successful deploy/update time +- NetBSD4 activation reminder status +- flash backup availability if any + +Credentials should live in Keychain. The app should not repeatedly ask for the +password unless the Keychain item is missing or authentication fails. + +The app should allow: + +- rename device +- forget device +- refresh identity +- update saved host/IP +- replace stored password +- duplicate profile is detected and merged or warned + +Discovery should not create profiles automatically. It should present candidates +that can be saved. + +## Add Device Flow + +The add-device flow should be one guided panel with clear stages: + +1. Discover +2. Select +3. Authenticate +4. Enable SSH if needed +5. Identify device +6. Save + +Discovery screen: + +- list AirPort/Time Capsule candidates from Bonjour +- show name, host, IPv4, model hint, and service status +- support manual address entry +- warn when only link-local `169.254.x.x` is available + +Authentication screen: + +- password field labeled "Time Capsule password" +- short note: "This password is also used for SMB login after install." +- "Save in Keychain" should be on by default + +SSH state handling: + +- if SSH is reachable and auth works, continue +- if SSH is closed, explain that the app can enable SSH using the Time Capsule + admin protocol and the device will reboot +- after enabling SSH, show a reboot wait progress state +- if password fails, ask again without saving a broken profile + +Device identity result: + +- model and syAP +- NetBSD version and architecture +- supported/unsupported status +- payload family +- expected behavior: + - Gen 5 / NetBSD 6: persistent install, reboot after deploy + - Gen 1-4 / NetBSD 4: deploy activates now, needs activation after later reboots unless flash patch is used + +Save screen: + +- nickname defaulted from Bonjour name +- primary button: "Save Time Capsule" +- next suggested action: "Install SMB" + +## Device Dashboard + +The device dashboard should answer four questions at a glance: + +- Is this device reachable? +- Is TimeCapsuleSMB installed? +- Is SMB currently working? +- What should I do next? + +Suggested layout: + +- Header + - nickname + - model/generation + - health badge + - last checked + +- Primary action strip + - "Install SMB" for not installed + - "Update SMB" for installed but app bundle has newer payload + - "Run Activation" for NetBSD4 deployed but inactive + - "Open in Finder" for healthy devices + - "Run Checkup" for warning/failed state + +- Health sections + - Connection + - Runtime + - Finder/Bonjour + - SMB auth + - Time Machine + +- Secondary actions + - Maintenance + - Uninstall + - Advanced + +The dashboard should run a lightweight refresh when selected. Full doctor can be +manual or automatically offered after deploy/update. + +## Known macOS Time Machine Warnings + +The app should proactively warn when the host macOS version is known to have +Time Machine network backup issues. + +Known warning policy: + +- macOS 15.7.5 +- macOS 15.7.6 +- macOS 15.7.7 +- macOS 26.4.x + +Warning behavior: + +- show a top-level banner on launch when the current Mac matches +- repeat the warning before deploy verification if the user expects Time Machine + validation +- do not block installation +- make clear that normal Finder SMB file sharing can still work +- make clear that Time Machine failure on this Mac may be a macOS issue, not a + TimeCapsuleSMB install failure + +Suggested text: + +"This macOS version has known Time Machine network backup issues. Finder SMB +access may still work, but Time Machine validation may fail on this Mac. Use a +different macOS version or update macOS before treating Time Machine failure as a +device problem." + +This should be data-driven so a later app update can change the warning list +without redesigning the UI. + +## Install And Update UX + +The deploy CLI should become an "Install SMB" or "Update SMB" workflow. + +The workflow should always start with a plan. + +Plan screen should show: + +- target device +- detected generation and OS +- payload family +- install location on disk +- files to upload, summarized +- mDNS/NBNS behavior +- reboot behavior +- NetBSD4 activation behavior +- expected downtime +- whether Time Machine warning applies on this Mac + +The normal user should see: + +- "This will install Samba 4.24.1 on the Time Capsule." +- "The device will reboot and may be unavailable for several minutes." +- "After it returns, the app will verify Finder and SMB access." + +Advanced disclosure should show: + +- upload count +- boot files +- payload directory +- selected volume +- mount wait setting +- NBNS toggle +- debug logging toggle + +Deploy progress should be a timeline: + +- Preparing +- Checking device +- Checking bundled files +- Finding disk +- Building plan +- Uploading +- Syncing to disk +- Rebooting or activating +- Waiting for device +- Verifying SMB +- Done + +Post-success screen: + +- show SMB URL +- "Open in Finder" +- "Run Time Machine Check" +- "Run Full Checkup" +- for NetBSD4, show activation reminder: + "This device needs activation after each reboot unless the flash boot hook is patched." + +## Doctor / Checkup UX + +The CLI `doctor` should be a "Checkup" workflow. + +It should group results by domain: + +- App + - bundled files + - local helper/tools + - app version +- Device + - SSH + - model and OS + - payload family + - interface/IP +- Runtime + - Samba process + - TCP 445 + - mDNS takeover + - NBNS if enabled + - persistent xattr database +- Finder/Bonjour + - advertised names + - resolved addresses + - `_smb._tcp` + - `_adisk._tcp` +- SMB + - authenticated listing + - share names + - file operation test +- Time Machine + - share flags + - host macOS warning + +Each check row should have: + +- status icon: pass, warning, fail, info +- human message +- "What to do" action if available +- raw detail disclosure + +Doctor failure should not be a wall of logs. The top should say: + +- "SMB is not running" +- "Bonjour is advertising the wrong name" +- "The disk did not mount" +- "This may be a macOS Time Machine issue" + +Recovery actions should be buttons: + +- Retry Checkup +- Reboot Device +- Run Activation +- Run Disk Repair +- Repair xattrs +- Open Finder to SMB URL +- Copy Diagnostics + +## Maintenance UX + +Maintenance should be available per saved device. It should be visually +separate from the primary install/checkup path because several actions are +destructive or specialized. + +Recommended sections: + +- NetBSD4 Activation +- Disk Repair +- File Metadata Repair +- Uninstall +- Firmware Flash, disabled or experimental + +### NetBSD4 Activation + +Show this only when the saved or probed device is NetBSD4, or keep it disabled +with an explanation. + +States: + +- not needed +- needs activation +- planning +- ready to activate +- activating +- verifying +- active +- failed + +UX: + +- "Start SMB now" +- dry-run plan shown first +- confirmation required before modifying runtime state +- after success, show "Open in Finder" and "Run Checkup" + +### Disk Repair + +This maps to `fsck`. + +The UX should be careful because it can stop sharing, unmount disks, run +`fsck_hfs`, and reboot. + +Flow: + +1. List mounted HFS volumes. +2. Select volume. +3. Build repair plan. +4. Confirm. +5. Run repair. +6. Reboot/wait if required. +7. Suggest Checkup. + +Volume picker should show: + +- device path, for example `/dev/dk2` +- mountpoint +- volume name +- internal/external marker + +Default should be conservative: + +- reboot after fsck +- wait for device to return +- do not expose `--no-reboot` and `--no-wait` unless advanced options are shown + +### File Metadata Repair + +This maps to `repair-xattrs`. + +This is a local macOS-side workflow for mounted SMB shares. It should use a path +picker instead of asking users to type paths. + +Flow: + +1. Choose mounted SMB share or folder. +2. Scan. +3. Show findings. +4. Repair known-safe issues. +5. Show summary. + +Defaults: + +- recursive scan on +- skip hidden paths +- skip Time Machine bundles +- do not fix permissions unless advanced +- do not include Time Machine unless advanced and heavily warned + +If the host is not macOS, disable the feature with a simple explanation. + +If no mounted matching share is found, show: + +- "Open in Finder" +- "Choose Folder" +- "Connect to SMB URL" + +### Uninstall + +Uninstall should be a destructive advanced action, but still polished. + +Flow: + +1. Build uninstall plan. +2. Show what will be removed. +3. Confirm. +4. Remove managed files. +5. Reboot or leave running state as explicitly chosen. +6. Verify removal when possible. + +Plan should show: + +- flash hooks to remove +- payload directories to remove +- whether reboot is required +- whether post-reboot verification will run + +Default should be reboot and verify. `No reboot` should be advanced. + +## Flash UX + +Flash should be planned now, but disabled before release unless it has gone +through separate acceptance testing. + +Product label: + +"Persistent NetBSD4 Boot Hook" + +Do not call the main entry point "flash" in the normal UI. The word can appear +inside advanced details. + +Release gating: + +- hidden by default +- visible only in an Advanced or Experimental section +- write actions disabled in release builds until explicitly enabled +- read-only backup/analyze may be available earlier, but only for NetBSD4 + +Eligibility checks: + +- saved device exists +- device is NetBSD4 +- SSH is reachable and authenticated +- app can read both firmware banks +- app can read ACP checksum properties +- app can identify the active bank or explain ambiguity +- app can classify the live `LOGIN` hook + +Flash landing screen should say: + +"This experimental workflow can back up and inspect the two firmware banks on a +NetBSD4 Time Capsule. Write modes can modify firmware. A failed or interrupted +write can make the device difficult or impossible to recover without hardware +tools." + +Modes: + +- Back Up and Inspect +- Check Against Apple Firmware +- Download Apple Firmware Only +- Patch Boot Hook, disabled by default +- Restore Apple Firmware, disabled by default + +Read-only analysis result should show: + +- backup directory +- primary bank validity +- secondary bank validity +- active bank +- how active bank was selected +- LOGIN classification: stock, patched, unknown +- patch feasibility +- restore feasibility +- Apple firmware match if checked + +Patch plan screen: + +- target bank: primary +- inactive bank remains untouched +- backup validity for both banks +- target payload checksum +- warnings +- manual power-cycle requirement + +Restore plan screen: + +- target bank: active bank only +- Apple firmware source/version +- payload checksum +- optional reboot after restore +- post-restore check required + +Write confirmation should be stronger than normal: + +- require explicit checkbox: "I have saved the firmware backup." +- require explicit checkbox: "I understand only the selected bank will be written." +- require typed confirmation such as the device nickname +- show power warning + +After patch write: + +- do not offer software reboot +- show "Unplug the Time Capsule, wait 10 seconds, plug it back in." +- show a timer and then "Run Checkup" +- remind user that one bank was left untouched + +After restore write: + +- allow optional reboot +- suggest "Check Apple Firmware" +- then suggest normal deploy if the user wants TimeCapsuleSMB again + +## Settings + +App-level settings: + +- default Bonjour timeout +- default mount wait +- diagnostics sharing/telemetry preference +- show advanced options +- check for app updates +- Time Machine warning policy version + +Device-level settings: + +- nickname +- host/IP +- stored password status +- NBNS enabled +- debug logging for future deploys +- advanced SSH options, hidden +- forget device + +## Background Jobs + +The app should run these without presenting them as commands: + +- app bundle validation +- payload manifest validation +- version support check +- host macOS warning check +- periodic Bonjour discovery +- lightweight selected-device reachability refresh +- Keychain availability check + +If background jobs fail: + +- app damaged: blocking alert +- update required: blocking or strong warning based on version metadata +- missing optional verification tool: degraded checkup warning, not install blocker +- Bonjour unavailable: non-blocking warning with manual address option + +## User-Facing Copy Principles + +Use familiar words first: + +- "Install SMB" instead of "deploy" +- "Checkup" instead of "doctor" +- "Start SMB" instead of "activate" except in advanced text +- "Disk Repair" instead of `fsck` +- "File Metadata Repair" instead of `repair-xattrs` +- "Persistent NetBSD4 Boot Hook" instead of `flash` + +Use technical names in secondary labels or details so expert users can map GUI +actions back to CLI commands. + +Do not expose implementation path names unless the user opens details. + +## Suggested Screen Map + +```text +All Time Capsules + Device Detail + Overview + Install / Update + Checkup + Maintenance + NetBSD4 Activation + Disk Repair + File Metadata Repair + Uninstall + Firmware Boot Hook (experimental) + Advanced + logs + raw operation events + copy diagnostics + +Add Time Capsule + Discover + Manual Address + Authenticate + Enable SSH + Identify + Save + +Activity + current operation + historical operations + copied diagnostics + +Settings + app defaults + warning policy + updates +``` + +## Important UX States + +Global app states: + +- app ready +- app bundle damaged +- update required +- host macOS has Time Machine warning +- no saved devices +- discovery running +- discovery unavailable + +Device states: + +- discovered unsaved +- saved, unchecked +- password needed +- SSH disabled +- enabling SSH +- rebooting after SSH enable +- unsupported device +- ready to install +- install planned +- installing +- rebooting after install +- verifying after install +- healthy +- warning +- failed +- NetBSD4 activation needed +- removed +- offline + +Operation states: + +- idle +- preparing +- planning +- ready for review +- awaiting confirmation +- running +- waiting for reboot +- verifying +- succeeded +- warning +- failed +- cancelled + +Flash-specific states: + +- unavailable +- disabled in this build +- eligible for read-only analysis +- reading banks +- saving backup +- analyzing banks +- plan available +- write locked +- awaiting strong confirmation +- writing +- readback validating +- write validated +- manual power cycle required +- restore rebooting +- check Apple firmware needed +- failed + +## Release Recommendation + +For the first polished GUI release: + +- include multi-device save/select +- include add-device, install/update, checkup, NetBSD4 activation, disk repair, + xattr repair, and uninstall +- run app readiness in the background +- show macOS Time Machine warning proactively +- include flash read-only planning only if stable enough +- keep flash write actions disabled + +The first release should make the normal Time Capsule owner successful without +teaching them the command set. The advanced tools should be available, but they +should feel like guarded recovery workflows rather than ordinary setup steps. diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift new file mode 100644 index 0000000..b29a750 --- /dev/null +++ b/macos/TimeCapsuleSMB/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version: 5.9 + +import Foundation +import PackageDescription + +let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"] ?? "/Applications/Xcode.app/Contents/Developer" +let xcodeFrameworkPath = "\(developerDir)/Platforms/MacOSX.platform/Developer/Library/Frameworks" +let xcodeFrameworkFlags = FileManager.default.fileExists(atPath: xcodeFrameworkPath) + ? ["-F", xcodeFrameworkPath] + : [] +let xcodeSwiftSettings: [SwiftSetting] = xcodeFrameworkFlags.isEmpty ? [] : [.unsafeFlags(xcodeFrameworkFlags)] +let xcodeLinkerSettings: [LinkerSetting] = xcodeFrameworkFlags.isEmpty ? [] : [.unsafeFlags(xcodeFrameworkFlags)] + +let package = Package( + name: "TimeCapsuleSMBMac", + defaultLocalization: "en", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"]) + ], + targets: [ + .target( + name: "TimeCapsuleSMBApp", + path: "Sources/TimeCapsuleSMBApp", + resources: [.process("Resources")] + ), + .executableTarget( + name: "TimeCapsuleSMBExecutable", + dependencies: ["TimeCapsuleSMBApp"], + path: "Sources/TimeCapsuleSMBExecutable" + ), + .testTarget( + name: "TimeCapsuleSMBAppTests", + dependencies: ["TimeCapsuleSMBApp"], + path: "Tests/TimeCapsuleSMBAppTests", + swiftSettings: xcodeSwiftSettings, + linkerSettings: xcodeLinkerSettings + ) + ] +) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift new file mode 100644 index 0000000..76ad744 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift @@ -0,0 +1,106 @@ +import Combine +import Foundation + +enum ActivityScope: Equatable { + case app + case device(DeviceProfile.ID) + case unknown +} + +struct ActivitySnapshot: Equatable { + let isRunning: Bool + let scope: ActivityScope + let operationTitle: String + let latestMessage: String? + let timeline: [OperationTimelineItem] +} + +@MainActor +final class ActivityStore: ObservableObject { + @Published private(set) var snapshot = ActivitySnapshot( + isRunning: false, + scope: .unknown, + operationTitle: L10n.string("activity.no_active_operation"), + latestMessage: nil, + timeline: [] + ) + + private let coordinator: OperationCoordinator + private var cancellables: Set = [] + + init(coordinator: OperationCoordinator) { + self.coordinator = coordinator + coordinator.$activeOperation + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.$activeDeviceID + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.backend.$events + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.backend.$isRunning + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.backend.$activeOperationName + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + refresh() + } + + func refresh() { + let events = coordinator.backend.events + let timeline = OperationTimelineBuilder.timeline(from: events) + let latestMessage = timeline.last?.detail ?? events.last?.summary + let operation = coordinator.activeOperation?.operation + ?? coordinator.backend.activeOperationName + ?? latestOperation(from: events) + let scope: ActivityScope + if let activeDeviceID = coordinator.activeDeviceID { + scope = .device(activeDeviceID) + } else if isAppOperation(operation) { + scope = .app + } else { + scope = .unknown + } + snapshot = ActivitySnapshot( + isRunning: coordinator.backend.isRunning, + scope: scope, + operationTitle: operation.map(OperationTimelineBuilder.operationTitle) + ?? (timeline.isEmpty ? L10n.string("activity.no_active_operation") : L10n.string("activity.last_operation")), + latestMessage: latestMessage, + timeline: timeline + ) + } + + private func latestOperation(from events: [BackendEvent]) -> String? { + events.last?.operation + } + + private func isAppOperation(_ operation: String?) -> Bool { + guard let operation else { + return false + } + return ["capabilities", "validate-install", "paths"].contains(operation) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift new file mode 100644 index 0000000..2f5f8f0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ActivityCompactView: View { + @ObservedObject var activityStore: ActivityStore + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + let snapshot = activityStore.snapshot + HStack(spacing: 10) { + Image(systemName: snapshot.isRunning ? "hourglass" : "checkmark.circle") + .foregroundStyle(snapshot.isRunning ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(title(snapshot)) + .font(.caption.weight(.medium)) + if let latest = snapshot.latestMessage, !latest.isEmpty { + Text(latest) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer() + if let last = snapshot.timeline.last { + Text(last.title) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.06)) + } + + private func title(_ snapshot: ActivitySnapshot) -> String { + if case .device(let activeDeviceID) = snapshot.scope, + let profile = registry.profile(id: activeDeviceID) { + return "\(snapshot.operationTitle) - \(profile.title)" + } + return snapshot.operationTitle + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift new file mode 100644 index 0000000..fdb0818 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift @@ -0,0 +1,481 @@ +import Combine +import Foundation + +enum AddDeviceFlowState: String, CaseIterable, Equatable { + case idle + case discovering + case discoveryEmpty + case discoveryReady + case manualEntry + case passwordEntry + case configuring + case savingProfile + case saved + case authFailed + case unsupported + case failed + + var title: String { + switch self { + case .idle: + return L10n.string("add_device.state.idle") + case .discovering: + return L10n.string("add_device.state.discovering") + case .discoveryEmpty: + return L10n.string("add_device.state.discovery_empty") + case .discoveryReady: + return L10n.string("add_device.state.discovery_ready") + case .manualEntry: + return L10n.string("add_device.state.manual_entry") + case .passwordEntry: + return L10n.string("add_device.state.password_entry") + case .configuring: + return L10n.string("add_device.state.configuring") + case .savingProfile: + return L10n.string("add_device.state.saving_profile") + case .saved: + return L10n.string("add_device.state.saved") + case .authFailed: + return L10n.string("add_device.state.auth_failed") + case .unsupported: + return L10n.string("add_device.state.unsupported") + case .failed: + return L10n.string("add_device.state.failed") + } + } +} + +enum AddDeviceEntryMode: String, CaseIterable, Equatable, Identifiable { + case discover + case manual + + var id: String { rawValue } + + var title: String { + switch self { + case .discover: + return L10n.string("add_device.entry.discover") + case .manual: + return L10n.string("add_device.entry.manual") + } + } +} + +@MainActor +final class AddDeviceFlowStore: ObservableObject { + @Published private(set) var entryMode: AddDeviceEntryMode = .discover + @Published var manualHost = "" + @Published var bonjourTimeout = "6" + @Published var password = "" + @Published var debugLogging = false + @Published private(set) var state: AddDeviceFlowState = .idle + @Published private(set) var devices: [DiscoveredDevice] = [] + @Published var selectedDeviceID: DiscoveredDevice.ID? + @Published private(set) var savedProfile: DeviceProfile? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let coordinator: OperationCoordinator + let registry: DeviceRegistryStore + let passwordStore: PasswordStore + let profileSaver: ConfiguredDeviceProfileSaving + + private var pendingProfileID: DeviceProfile.ID? + private var pendingDiscoveredDevice: DiscoveredDevice? + private var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + init( + coordinator: OperationCoordinator, + registry: DeviceRegistryStore, + passwordStore: PasswordStore, + profileSaver: ConfiguredDeviceProfileSaving? = nil + ) { + self.coordinator = coordinator + self.registry = registry + self.passwordStore = passwordStore + self.profileSaver = profileSaver ?? ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) + coordinator.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var isRunning: Bool { + coordinator.backend.isRunning + } + + var canCancel: Bool { + coordinator.backend.canCancel + } + + var selectedDevice: DiscoveredDevice? { + guard let selectedDeviceID else { + return nil + } + return devices.first { $0.id == selectedDeviceID } + } + + var hostFieldText: String { + switch entryMode { + case .discover: + return selectedDevice?.host ?? "" + case .manual: + return manualHost + } + } + + var isHostFieldEditable: Bool { + entryMode == .manual + } + + var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + var canConfigure: Bool { + let hasTarget: Bool + switch entryMode { + case .discover: + hasTarget = selectedDevice != nil + case .manual: + hasTarget = !manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return !isRunning + && !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && hasTarget + } + + func setEntryMode(_ mode: AddDeviceEntryMode) { + guard entryMode != mode else { + return + } + switch mode { + case .discover: + entryMode = .discover + selectedDeviceID = nil + manualHost = "" + savedProfile = nil + error = nil + currentStage = nil + state = devices.isEmpty ? .idle : .discoveryReady + case .manual: + startManualEntry() + } + } + + func startManualEntry() { + entryMode = .manual + state = .manualEntry + devices = [] + selectedDeviceID = nil + savedProfile = nil + error = nil + currentStage = nil + } + + func promptForPassword() { + guard hasSelectedTarget else { + failLocally(L10n.string("add_device.error.choose_target")) + return + } + state = .passwordEntry + error = nil + } + + func runDiscover() { + guard let timeout = bonjourTimeoutValue else { + failLocally(L10n.string("add_device.error.invalid_bonjour_timeout")) + return + } + guard !coordinator.backend.isRunning else { + rejectRun(L10n.string("operation.error.already_running")) + return + } + resetRunState(clearDevices: true) + entryMode = .discover + manualHost = "" + switch coordinator.run(operation: "discover", params: OperationParams.discover(timeout: timeout), profile: nil) { + case .started(let operation): + activeOperation = operation + state = .discovering + case .rejected(let message): + rejectRun(message) + } + } + + func runConfigure() { + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPassword.isEmpty else { + state = .passwordEntry + failLocally(L10n.string("add_device.error.password_required")) + return + } + let selectedDevice = entryMode == .discover ? selectedDevice : nil + let trimmedHost = manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard selectedDevice != nil || (entryMode == .manual && !trimmedHost.isEmpty) else { + failLocally(L10n.string("add_device.error.choose_target")) + return + } + + let targetHost = selectedDevice?.host ?? trimmedHost + let existing = registry.matchingProfile(host: targetHost, bonjourFullname: selectedDevice?.fullname) + let profileID = existing?.id ?? UUID().uuidString.lowercased() + pendingProfileID = profileID + pendingDiscoveredDevice = selectedDevice + + let context = DeviceRuntimeContext( + profileID: profileID, + configURL: DeviceProfile.configURL(for: profileID, applicationSupportURL: registry.applicationSupportURL) + ) + + guard !coordinator.backend.isRunning else { + pendingProfileID = nil + pendingDiscoveredDevice = nil + rejectRun(L10n.string("operation.error.already_running")) + return + } + resetRunState(clearDevices: false) + switch coordinator.run( + operation: "configure", + params: OperationParams.configure( + host: targetHost, + selectedRecord: selectedDevice?.rawRecord, + password: password, + debugLogging: debugLogging + ), + context: context, + activeDeviceID: profileID + ) { + case .started(let operation): + activeOperation = operation + state = .configuring + case .rejected(let message): + pendingProfileID = nil + pendingDiscoveredDevice = nil + rejectRun(message) + } + } + + func select(_ device: DiscoveredDevice) { + entryMode = .discover + selectedDeviceID = device.id + manualHost = device.host + if let existing = registry.matchingProfile(host: device.host, bonjourFullname: device.fullname) { + savedProfile = existing + state = .saved + error = nil + return + } + state = .passwordEntry + } + + func reset() { + coordinator.backend.clear() + devices = [] + selectedDeviceID = nil + entryMode = .discover + manualHost = "" + password = "" + savedProfile = nil + error = nil + currentStage = nil + pendingProfileID = nil + pendingDiscoveredDevice = nil + activeOperation = nil + lastProcessedEventCount = 0 + state = .idle + } + + func cancel() { + coordinator.cancel() + } + + private func resetRunState(clearDevices: Bool) { + coordinator.backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + savedProfile = nil + activeOperation = nil + if clearDevices { + devices = [] + selectedDeviceID = nil + if entryMode == .discover { + manualHost = "" + } + } + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "discover" || event.operation == "configure" else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + if event.type == "error" { + applyError(event) + return + } + guard event.type == "result" else { + return + } + if event.ok == false { + failFromResult(event) + return + } + switch event.operation { + case "discover": + applyDiscoverResult(event) + case "configure": + applyConfigureResult(event) + default: + break + } + } + + private func applyDiscoverResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(DiscoverPayload.self) + devices = payload.devices.enumerated().map { index, device in + DiscoveredDevice(payload: device, index: index) + } + selectedDeviceID = devices.count == 1 ? devices[0].id : nil + manualHost = devices.count == 1 ? devices[0].host : "" + state = devices.isEmpty ? .discoveryEmpty : .discoveryReady + error = nil + activeOperation = nil + } catch { + failContract(error) + } + } + + private func applyConfigureResult(_ event: BackendEvent) { + let configured: ConfiguredDeviceState + do { + configured = ConfiguredDeviceState(payload: try event.decodePayload(ConfigurePayload.self)) + } catch { + failContract(error) + return + } + + do { + state = .savingProfile + let profileID = pendingProfileID ?? UUID().uuidString.lowercased() + savedProfile = try profileSaver.saveConfiguredDevice( + configuredDevice: configured, + discoveredDevice: pendingDiscoveredDevice, + password: password, + preferredID: profileID + ) + error = nil + state = .saved + activeOperation = nil + } catch { + failProfileSave(error) + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + switch event.code { + case "auth_failed": + state = .authFailed + case "unsupported_device": + state = .unsupported + default: + state = .failed + } + activeOperation = nil + } + + private func failFromResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + state = .failed + activeOperation = nil + } + + private func failContract(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "add-device", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + activeOperation = nil + } + + private func failProfileSave(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "add-device", + code: "profile_save_failed", + message: error.localizedDescription + ) + state = .failed + activeOperation = nil + } + + private func failLocally(_ message: String) { + error = BackendErrorViewModel( + operation: "add-device", + code: "validation_failed", + message: message + ) + currentStage = nil + state = .failed + } + + private func rejectRun(_ message: String) { + error = BackendErrorViewModel( + operation: "add-device", + code: "operation_rejected", + message: message + ) + currentStage = nil + state = .failed + activeOperation = nil + } + + private func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } + + private var hasSelectedTarget: Bool { + switch entryMode { + case .discover: + return selectedDevice != nil + case .manual: + return !manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift new file mode 100644 index 0000000..761ea59 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift @@ -0,0 +1,311 @@ +import Combine +import Foundation + +enum AppReadinessStateKind: String, CaseIterable, Equatable { + case idle + case resolvingBundle + case checkingCapabilities + case validatingInstall + case ready + case degraded + case blocked + + var title: String { + switch self { + case .idle: + return L10n.string("app_readiness.state.idle") + case .resolvingBundle: + return L10n.string("app_readiness.state.resolving_bundle") + case .checkingCapabilities: + return L10n.string("app_readiness.state.checking_capabilities") + case .validatingInstall: + return L10n.string("app_readiness.state.validating_install") + case .ready: + return L10n.string("app_readiness.state.ready") + case .degraded: + return L10n.string("app_readiness.state.degraded") + case .blocked: + return L10n.string("app_readiness.state.blocked") + } + } +} + +struct AppReadinessSummary: Equatable { + let runtimeMode: BundleRuntimeMode + let helperVersion: String + let distributionRoot: String + let validationSummary: String + let validationCounts: [String: Int] +} + +enum AppReadinessState: Equatable { + case idle + case resolvingBundle + case checkingCapabilities + case validatingInstall + case ready(AppReadinessSummary) + case degraded(AppReadinessSummary, [BundleRuntimeIssue]) + case blocked(BundleRuntimeIssue) + + var kind: AppReadinessStateKind { + switch self { + case .idle: + return .idle + case .resolvingBundle: + return .resolvingBundle + case .checkingCapabilities: + return .checkingCapabilities + case .validatingInstall: + return .validatingInstall + case .ready: + return .ready + case .degraded: + return .degraded + case .blocked: + return .blocked + } + } +} + +protocol AppRuntimeResolving { + func resolve(helperPath: String?) throws -> HelperResolution + func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] +} + +extension HelperLocator: AppRuntimeResolving {} + +@MainActor +final class AppReadinessStore: ObservableObject { + @Published private(set) var state: AppReadinessState = .idle + @Published private(set) var capabilities: CapabilitiesPayload? + @Published private(set) var validation: InstallValidationPayload? + @Published private(set) var issues: [BundleRuntimeIssue] = [] + @Published private(set) var currentStage: OperationStageState? + + let backend: BackendClient + + private let runtimeResolver: any AppRuntimeResolving + private let helperPathProvider: () -> String + private var runtimeMode: BundleRuntimeMode = .developmentCheckout + private var pendingOperation: String? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init(backend: BackendClient) { + self.init( + backend: backend, + runtimeResolver: HelperLocator(), + helperPathProvider: { backend.helperPath } + ) + } + + init( + backend: BackendClient, + runtimeResolver: any AppRuntimeResolving, + helperPathProvider: @escaping () -> String + ) { + self.backend = backend + self.runtimeResolver = runtimeResolver + self.helperPathProvider = helperPathProvider + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + Task { @MainActor in + self?.runPendingOperation() + } + } + .store(in: &cancellables) + } + + var canRetry: Bool { + !backend.isRunning + } + + func start() { + guard !backend.isRunning else { return } + backend.clear() + capabilities = nil + validation = nil + issues = [] + currentStage = nil + pendingOperation = nil + lastProcessedEventCount = 0 + state = .resolvingBundle + + let helperPath = normalized(helperPathProvider()) + do { + let resolution = try runtimeResolver.resolve(helperPath: helperPath) + runtimeMode = resolution.mode + issues = runtimeResolver.runtimeIssues(for: resolution) + if let blockingIssue = issues.first(where: { $0.severity == .error }) { + state = .blocked(blockingIssue) + return + } + } catch { + state = .blocked(BundleRuntimeIssue( + code: .helperMissing, + severity: .error, + message: error.localizedDescription, + recovery: L10n.string("app_readiness.recovery.helper_missing") + )) + return + } + + state = .checkingCapabilities + backend.run(operation: "capabilities") + } + + func clear() { + backend.clear() + capabilities = nil + validation = nil + issues = [] + currentStage = nil + pendingOperation = nil + lastProcessedEventCount = 0 + state = .idle + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard ["capabilities", "validate-install"].contains(event.operation) else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + state = .blocked(issue(from: event)) + return + } + + guard event.type == "result" else { + return + } + + switch event.operation { + case "capabilities": + applyCapabilitiesResult(event) + case "validate-install": + applyValidationResult(event) + default: + break + } + } + + private func applyCapabilitiesResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(CapabilitiesPayload.self) + capabilities = payload + guard event.ok == true else { + state = .blocked(BundleRuntimeIssue( + code: .operationFailed, + severity: .error, + message: payload.summary, + recovery: L10n.string("app_readiness.recovery.retry_diagnostics") + )) + return + } + pendingOperation = "validate-install" + runPendingOperation() + } catch { + state = .blocked(contractIssue(operation: "capabilities", error: error)) + } + } + + private func applyValidationResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(InstallValidationPayload.self) + validation = payload + guard payload.ok else { + state = .blocked(BundleRuntimeIssue( + code: .installValidationFailed, + severity: .error, + message: payload.summary, + recovery: L10n.string("app_readiness.recovery.install_validation_failed") + )) + return + } + finishReady(validation: payload) + } catch { + state = .blocked(contractIssue(operation: "validate-install", error: error)) + } + } + + private func finishReady(validation: InstallValidationPayload) { + let summary = AppReadinessSummary( + runtimeMode: runtimeMode, + helperVersion: capabilities?.helperVersion ?? "", + distributionRoot: capabilities?.distributionRoot ?? "", + validationSummary: validation.summary, + validationCounts: validation.counts + ) + let warnings = issues.filter { $0.severity == .warning } + state = warnings.isEmpty ? .ready(summary) : .degraded(summary, warnings) + } + + private func runPendingOperation() { + guard let operation = pendingOperation, !backend.isRunning else { + return + } + pendingOperation = nil + if operation == "validate-install" { + state = .validatingInstall + } + backend.run(operation: operation) + } + + private func issue(from event: BackendEvent) -> BundleRuntimeIssue { + let code: BundleRuntimeIssueCode + switch event.code { + case "helper_not_found": + code = .helperMissing + case "helper_launch_failed": + code = .helperLaunchFailed + default: + code = .operationFailed + } + return BundleRuntimeIssue( + code: code, + severity: .error, + message: event.message ?? event.summary, + recovery: BackendErrorViewModel(event: event).recovery?.message ?? L10n.string("app_readiness.recovery.retry_diagnostics") + ) + } + + private func contractIssue(operation: String, error: Error) -> BundleRuntimeIssue { + BundleRuntimeIssue( + code: .contractDecodeFailed, + severity: .error, + message: L10n.format("app_readiness.error.unexpected_payload", operation, error.localizedDescription), + recovery: L10n.string("app_readiness.recovery.contract_mismatch") + ) + } + + private func normalized(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift new file mode 100644 index 0000000..e8dd93d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift @@ -0,0 +1,201 @@ +import Combine +import Foundation + +enum DashboardPrimaryAction: String, Equatable { + case addDevice + case replacePassword + case runCheckup + case installSMB + case viewCheckup + case openSMB +} + +struct DeviceDashboardSummary: Equatable { + let profile: DeviceProfile + let passwordState: DevicePasswordState + let displayStatus: DeviceDisplayStatus + let primaryAction: DashboardPrimaryAction + let hostWarning: HostCompatibilityWarning? +} + +@MainActor +final class AppStore: ObservableObject { + @Published var selectedDeviceID: DeviceProfile.ID? + @Published var showingAddDevice = false + + let appReadinessStore: AppReadinessStore + let deviceRegistry: DeviceRegistryStore + let operationCoordinator: OperationCoordinator + let passwordStore: PasswordStore + let activityStore: ActivityStore + + private var cancellables: Set = [] + + convenience init() { + let coordinator = OperationCoordinator() + self.init( + appReadinessStore: AppReadinessStore(backend: coordinator.backend), + deviceRegistry: DeviceRegistryStore(), + operationCoordinator: coordinator, + passwordStore: KeychainPasswordStore(), + activityStore: ActivityStore(coordinator: coordinator) + ) + } + + init( + appReadinessStore: AppReadinessStore, + deviceRegistry: DeviceRegistryStore, + operationCoordinator: OperationCoordinator, + passwordStore: PasswordStore, + activityStore: ActivityStore? = nil + ) { + self.appReadinessStore = appReadinessStore + self.deviceRegistry = deviceRegistry + self.operationCoordinator = operationCoordinator + self.passwordStore = passwordStore + self.activityStore = activityStore ?? ActivityStore(coordinator: operationCoordinator) + + appReadinessStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + deviceRegistry.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + operationCoordinator.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + self.activityStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + deviceRegistry.$profiles + .sink { [weak self] profiles in + Task { @MainActor in + self?.syncSelection(profiles: profiles) + } + } + .store(in: &cancellables) + } + + var selectedProfile: DeviceProfile? { + deviceRegistry.profile(id: selectedDeviceID) + } + + var backend: BackendClient { + operationCoordinator.backend + } + + func start() { + deviceRegistry.load() + refreshPasswordStates() + appReadinessStore.start() + } + + func select(_ profile: DeviceProfile) { + selectedDeviceID = profile.id + showingAddDevice = false + } + + func showAddDevice() { + selectedDeviceID = nil + showingAddDevice = true + } + + func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary { + let passwordState = effectivePasswordState(for: profile) + let displayStatus = DeviceStatusPolicy.status( + for: profile, + passwordState: passwordState, + activeOperation: operationCoordinator.activeOperation + ) + let primaryAction = DashboardPrimaryActionPolicy.primaryAction( + for: profile, + passwordState: passwordState, + activeOperation: operationCoordinator.activeOperation + ) + return DeviceDashboardSummary( + profile: profile, + passwordState: passwordState, + displayStatus: displayStatus, + primaryAction: primaryAction, + hostWarning: HostCompatibilityPolicy.warning() + ) + } + + func password(for profile: DeviceProfile) -> String? { + if profile.passwordState == .invalid { + return nil + } + do { + return try passwordStore.password(for: profile.keychainAccount) + } catch PasswordStoreError.missing { + deviceRegistry.updatePasswordState(.missing, for: profile.id) + return nil + } catch { + deviceRegistry.updatePasswordState(.keychainUnavailable, for: profile.id) + return nil + } + } + + func savePassword(_ password: String, for profile: DeviceProfile) throws { + try passwordStore.save(password, for: profile.keychainAccount) + deviceRegistry.updatePasswordState(.available, for: profile.id) + } + + func updateSettings(_ settings: DeviceProfileSettings, for profile: DeviceProfile) throws { + var updated = profile + updated.settings = settings + try deviceRegistry.updateProfile(updated) + } + + func rename(_ profile: DeviceProfile, displayName: String) throws { + var updated = profile + updated.displayName = displayName + try deviceRegistry.updateProfile(updated) + } + + func updateHost(_ profile: DeviceProfile, host: String) throws { + var updated = profile + updated.host = host + try deviceRegistry.updateProfile(updated) + } + + func forget(_ profile: DeviceProfile) throws { + try passwordStore.deletePassword(for: profile.keychainAccount) + try deviceRegistry.delete(profile) + if selectedDeviceID == profile.id { + selectedDeviceID = deviceRegistry.profiles.first?.id + showingAddDevice = false + } + } + + func refreshPasswordStates() { + for profile in deviceRegistry.profiles { + deviceRegistry.updatePasswordState(effectivePasswordState(for: profile), for: profile.id) + } + } + + private func effectivePasswordState(for profile: DeviceProfile) -> DevicePasswordState { + if profile.passwordState == .invalid { + return .invalid + } + return passwordStore.state(for: profile.keychainAccount) + } + + private func syncSelection(profiles: [DeviceProfile]) { + if let selectedDeviceID, profiles.contains(where: { $0.id == selectedDeviceID }) { + return + } + selectedDeviceID = profiles.first?.id + if !profiles.isEmpty { + showingAddDevice = false + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift new file mode 100644 index 0000000..7f6618a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -0,0 +1,146 @@ +import Foundation + +@MainActor +final class BackendClient: ObservableObject { + @Published var helperPath: String + @Published var events: [BackendEvent] = [] + @Published var isRunning = false + @Published var lastExitCode: Int32? + @Published var pendingConfirmation: PendingConfirmation? + @Published var currentStage: String? + @Published var currentRisk: String? + @Published var currentCancellable: Bool? + @Published private(set) var activeOperationName: String? + + private let runner: any HelperRunning + private var runTask: Task? + private var activeCall: BackendCall? + + init( + runner: any HelperRunning = HelperRunner(), + helperPath: String = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" + ) { + self.runner = runner + self.helperPath = helperPath + } + + deinit { + runTask?.cancel() + } + + func clear() { + guard !isRunning else { + return + } + events.removeAll() + lastExitCode = nil + pendingConfirmation = nil + currentStage = nil + currentRisk = nil + currentCancellable = nil + activeOperationName = nil + } + + var canCancel: Bool { + isRunning && (currentCancellable ?? true) + } + + func run(operation: String, params: [String: JSONValue] = [:], context: DeviceRuntimeContext? = nil) { + guard !isRunning else { return } + var runParams = params + if let context, runParams["config"] == nil { + runParams["config"] = .string(context.configURL.path) + } + isRunning = true + lastExitCode = nil + pendingConfirmation = nil + currentStage = nil + currentRisk = nil + currentCancellable = nil + activeOperationName = operation + activeCall = BackendCall(operation: operation, params: runParams, context: context) + let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) + let runner = self.runner + let updateTarget = BackendClientUpdateTarget( + appendEvent: { [weak self] event in + self?.appendEvent(event) + }, + finishRun: { [weak self] exitCode in + self?.finishRun(exitCode: exitCode) + } + ) + runTask = Task.detached(priority: .userInitiated) { [runner, updateTarget, helperPath, operation, runParams, context] in + let result = await runner.run( + helperPath: helperPath.isEmpty ? nil : helperPath, + operation: operation, + params: runParams, + context: context + ) { event in + await updateTarget.appendEvent(event) + } + await updateTarget.finishRun(exitCode: result.exitCode) + } + } + + func cancel() { + guard canCancel else { return } + runTask?.cancel() + } + + func confirmPending() { + guard let confirmation = pendingConfirmation, !isRunning else { return } + pendingConfirmation = nil + run(operation: confirmation.operation, params: confirmation.params, context: confirmation.context) + } + + fileprivate func appendEvent(_ event: BackendEvent) { + if event.type == "stage" { + currentStage = event.stage + currentRisk = event.risk + currentCancellable = event.cancellable + } + if let activeCall, let confirmation = PendingConfirmation( + confirmationEvent: event, + originalParams: activeCall.params, + context: activeCall.context + ) { + pendingConfirmation = confirmation + } + events.append(event) + } + + fileprivate func finishRun(exitCode: Int32) { + lastExitCode = exitCode + isRunning = false + runTask = nil + activeCall = nil + activeOperationName = nil + } +} + +private struct BackendCall: Sendable { + let operation: String + let params: [String: JSONValue] + let context: DeviceRuntimeContext? +} + +private final class BackendClientUpdateTarget: Sendable { + private let appendEventOnMain: @MainActor @Sendable (BackendEvent) -> Void + private let finishRunOnMain: @MainActor @Sendable (Int32) -> Void + + init( + appendEvent: @escaping @MainActor @Sendable (BackendEvent) -> Void, + finishRun: @escaping @MainActor @Sendable (Int32) -> Void + ) { + self.appendEventOnMain = appendEvent + self.finishRunOnMain = finishRun + } + + func appendEvent(_ event: BackendEvent) async { + await appendEventOnMain(event) + } + + func finishRun(exitCode: Int32) async { + await finishRunOnMain(exitCode) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift new file mode 100644 index 0000000..bdac20f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift @@ -0,0 +1,45 @@ +import Foundation + +enum BackendContractError: Error, Equatable, LocalizedError { + case missingPayload(operation: String) + case payloadDecodeFailed(operation: String, payloadType: String, message: String) + + var errorDescription: String? { + switch self { + case .missingPayload(let operation): + return "\(operation) result did not include a payload." + case .payloadDecodeFailed(let operation, let payloadType, let message): + return "\(operation) payload could not be decoded as \(payloadType): \(message)" + } + } +} + +extension JSONValue { + func decode(_ type: T.Type = T.self) throws -> T { + let data = try JSONEncoder().encode(self) + return try JSONDecoder().decode(T.self, from: data) + } +} + +extension BackendEvent { + func decodePayload(_ type: T.Type = T.self) throws -> T { + guard let payload else { + throw BackendContractError.missingPayload(operation: operation) + } + do { + return try payload.decode(type) + } catch let error as DecodingError { + throw BackendContractError.payloadDecodeFailed( + operation: operation, + payloadType: String(describing: type), + message: error.localizedDescription + ) + } catch { + throw BackendContractError.payloadDecodeFailed( + operation: operation, + payloadType: String(describing: type), + message: error.localizedDescription + ) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift new file mode 100644 index 0000000..3d617cc --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift @@ -0,0 +1,700 @@ +import Foundation + +struct CapabilitiesPayload: Decodable, Equatable { + let schemaVersion: Int + let apiSchemaVersion: Int + let helperVersion: String + let helperVersionCode: Int + let operations: [String] + let distributionRoot: String + let artifactManifestSHA256: String? + let confirmationSchemaVersion: Int + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case apiSchemaVersion = "api_schema_version" + case helperVersion = "helper_version" + case helperVersionCode = "helper_version_code" + case operations + case distributionRoot = "distribution_root" + case artifactManifestSHA256 = "artifact_manifest_sha256" + case confirmationSchemaVersion = "confirmation_schema_version" + case summary + } +} + +struct PathsPayload: Decodable, Equatable { + let schemaVersion: Int + let distributionRoot: String + let configPath: String + let stateDir: String + let packageRoot: String + let artifactManifest: String + let artifacts: [ArtifactPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case distributionRoot = "distribution_root" + case configPath = "config_path" + case stateDir = "state_dir" + case packageRoot = "package_root" + case artifactManifest = "artifact_manifest" + case artifacts + case counts + case summary + } +} + +struct ArtifactPayload: Decodable, Equatable { + let name: String + let repoRelativePath: String + let absolutePath: String + let sha256: String + let ok: Bool + let message: String + + enum CodingKeys: String, CodingKey { + case name + case repoRelativePath = "repo_relative_path" + case absolutePath = "absolute_path" + case sha256 + case ok + case message + } +} + +struct InstallValidationPayload: Decodable, Equatable { + let schemaVersion: Int + let ok: Bool + let checks: [InstallCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case ok + case checks + case counts + case summary + } +} + +struct InstallCheckPayload: Decodable, Equatable { + let id: String + let ok: Bool + let message: String + let details: JSONValue? +} + +struct DiscoverPayload: Decodable, Equatable { + let schemaVersion: Int + let instances: [BonjourServiceInstancePayload] + let resolved: [BonjourResolvedServicePayload] + let devices: [DiscoveredDevicePayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case instances + case resolved + case devices + case counts + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.instances = try container.decodeIfPresent([BonjourServiceInstancePayload].self, forKey: .instances) ?? [] + self.resolved = try container.decodeIfPresent([BonjourResolvedServicePayload].self, forKey: .resolved) ?? [] + self.devices = try container.decodeIfPresent([DiscoveredDevicePayload].self, forKey: .devices) ?? [] + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "" + } +} + +struct DiscoveredDevicePayload: Decodable, Equatable { + let id: String + let name: String + let host: String + let sshHost: String? + let hostname: String + let addresses: [String] + let ipv4: [String] + let ipv6: [String] + let preferredIPv4: String? + let linkLocalOnly: Bool + let syap: String? + let model: String? + let serviceType: String + let fullname: String + let selectedRecord: JSONValue + + enum CodingKeys: String, CodingKey { + case id + case name + case host + case sshHost = "ssh_host" + case hostname + case addresses + case ipv4 + case ipv6 + case preferredIPv4 = "preferred_ipv4" + case linkLocalOnly = "link_local_only" + case syap + case model + case serviceType = "service_type" + case fullname + case selectedRecord = "selected_record" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.host = try container.decodeIfPresent(String.self, forKey: .host) ?? "" + self.sshHost = try container.decodeIfPresent(String.self, forKey: .sshHost) + self.hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" + self.addresses = try container.decodeIfPresent([String].self, forKey: .addresses) ?? [] + self.ipv4 = try container.decodeIfPresent([String].self, forKey: .ipv4) ?? [] + self.ipv6 = try container.decodeIfPresent([String].self, forKey: .ipv6) ?? [] + self.preferredIPv4 = try container.decodeIfPresent(String.self, forKey: .preferredIPv4) + self.linkLocalOnly = try container.decodeIfPresent(Bool.self, forKey: .linkLocalOnly) ?? false + self.syap = try container.decodeIfPresent(String.self, forKey: .syap) + self.model = try container.decodeIfPresent(String.self, forKey: .model) + self.serviceType = try container.decodeIfPresent(String.self, forKey: .serviceType) ?? "" + self.fullname = try container.decodeIfPresent(String.self, forKey: .fullname) ?? "" + self.selectedRecord = try container.decodeIfPresent(JSONValue.self, forKey: .selectedRecord) ?? .null + } +} + +struct BonjourServiceInstancePayload: Decodable, Equatable { + let serviceType: String + let name: String + let fullname: String + + enum CodingKeys: String, CodingKey { + case serviceType = "service_type" + case name + case fullname + } +} + +struct BonjourResolvedServicePayload: Decodable, Equatable { + let name: String + let hostname: String + let serviceType: String + let port: Int + let ipv4: [String] + let ipv6: [String] + let services: [String] + let properties: [String: String] + let fullname: String + + enum CodingKeys: String, CodingKey { + case name + case hostname + case serviceType = "service_type" + case port + case ipv4 + case ipv6 + case services + case properties + case fullname + } + + init( + name: String, + hostname: String, + serviceType: String = "", + port: Int = 0, + ipv4: [String] = [], + ipv6: [String] = [], + services: [String] = [], + properties: [String: String] = [:], + fullname: String = "" + ) { + self.name = name + self.hostname = hostname + self.serviceType = serviceType + self.port = port + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.services = services + self.properties = properties + self.fullname = fullname + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" + self.serviceType = try container.decodeIfPresent(String.self, forKey: .serviceType) ?? "" + self.port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 0 + self.ipv4 = try container.decodeIfPresent([String].self, forKey: .ipv4) ?? [] + self.ipv6 = try container.decodeIfPresent([String].self, forKey: .ipv6) ?? [] + self.services = try container.decodeIfPresent([String].self, forKey: .services) ?? [] + self.properties = try container.decodeIfPresent([String: String].self, forKey: .properties) ?? [:] + self.fullname = try container.decodeIfPresent(String.self, forKey: .fullname) ?? "" + } + + var jsonValue: JSONValue { + .object([ + "name": .string(name), + "hostname": .string(hostname), + "service_type": .string(serviceType), + "port": .number(Double(port)), + "ipv4": .array(ipv4.map(JSONValue.string)), + "ipv6": .array(ipv6.map(JSONValue.string)), + "services": .array(services.map(JSONValue.string)), + "properties": .object(properties.mapValues(JSONValue.string)), + "fullname": .string(fullname) + ]) + } +} + +struct ConfigurePayload: Decodable, Equatable { + let schemaVersion: Int + let configPath: String + let host: String + let configureId: String + let sshAuthenticated: Bool + let deviceSyap: String? + let deviceModel: String? + let compatibility: DeviceCompatibilityPayload? + let device: DevicePayload? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case configPath = "config_path" + case host + case configureId = "configure_id" + case sshAuthenticated = "ssh_authenticated" + case deviceSyap = "device_syap" + case deviceModel = "device_model" + case compatibility + case device + case summary + } +} + +struct DevicePayload: Decodable, Equatable { + let host: String? + let syap: String? + let model: String? +} + +struct DeviceCompatibilityPayload: Decodable, Equatable { + let osName: String? + let osRelease: String? + let arch: String? + let elfEndianness: String? + let payloadFamily: String? + let deviceGeneration: String? + let supported: Bool? + let reasonCode: String? + let reasonDetail: String? + let syapCandidates: [String] + let modelCandidates: [String] + + enum CodingKeys: String, CodingKey { + case osName = "os_name" + case osRelease = "os_release" + case arch + case elfEndianness = "elf_endianness" + case payloadFamily = "payload_family" + case deviceGeneration = "device_generation" + case supported + case reasonCode = "reason_code" + case reasonDetail = "reason_detail" + case syapCandidates = "syap_candidates" + case modelCandidates = "model_candidates" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.osName = try container.decodeIfPresent(String.self, forKey: .osName) + self.osRelease = try container.decodeIfPresent(String.self, forKey: .osRelease) + self.arch = try container.decodeIfPresent(String.self, forKey: .arch) + self.elfEndianness = try container.decodeIfPresent(String.self, forKey: .elfEndianness) + self.payloadFamily = try container.decodeIfPresent(String.self, forKey: .payloadFamily) + self.deviceGeneration = try container.decodeIfPresent(String.self, forKey: .deviceGeneration) + self.supported = try container.decodeIfPresent(Bool.self, forKey: .supported) + self.reasonCode = try container.decodeIfPresent(String.self, forKey: .reasonCode) + self.reasonDetail = try container.decodeIfPresent(String.self, forKey: .reasonDetail) + self.syapCandidates = try container.decodeIfPresent([String].self, forKey: .syapCandidates) ?? [] + self.modelCandidates = try container.decodeIfPresent([String].self, forKey: .modelCandidates) ?? [] + } +} + +struct DeployPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let host: String + let volumeRoot: String? + let payloadDir: String + let payloadFamily: String? + let netbsd4: Bool + let requiresReboot: Bool + let rebootRequired: Bool? + let uploads: [JSONValue] + let preUploadActions: [JSONValue] + let postUploadActions: [JSONValue] + let activationActions: [JSONValue] + let postDeployChecks: [PlannedCheckPayload] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case host + case volumeRoot = "volume_root" + case payloadDir = "payload_dir" + case payloadFamily = "payload_family" + case netbsd4 + case requiresReboot = "requires_reboot" + case rebootRequired = "reboot_required" + case uploads + case preUploadActions = "pre_upload_actions" + case postUploadActions = "post_upload_actions" + case activationActions = "activation_actions" + case postDeployChecks = "post_deploy_checks" + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.host = try container.decode(String.self, forKey: .host) + self.volumeRoot = try container.decodeIfPresent(String.self, forKey: .volumeRoot) + self.payloadDir = try container.decode(String.self, forKey: .payloadDir) + self.payloadFamily = try container.decodeIfPresent(String.self, forKey: .payloadFamily) + self.netbsd4 = try container.decode(Bool.self, forKey: .netbsd4) + self.requiresReboot = try container.decode(Bool.self, forKey: .requiresReboot) + self.rebootRequired = try container.decodeIfPresent(Bool.self, forKey: .rebootRequired) + self.uploads = try container.decodeIfPresent([JSONValue].self, forKey: .uploads) ?? [] + self.preUploadActions = try container.decodeIfPresent([JSONValue].self, forKey: .preUploadActions) ?? [] + self.postUploadActions = try container.decodeIfPresent([JSONValue].self, forKey: .postUploadActions) ?? [] + self.activationActions = try container.decodeIfPresent([JSONValue].self, forKey: .activationActions) ?? [] + self.postDeployChecks = try container.decodeIfPresent([PlannedCheckPayload].self, forKey: .postDeployChecks) ?? [] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct DeployResultPayload: Decodable, Equatable { + let schemaVersion: Int + let payloadDir: String + let netbsd4: Bool + let payloadFamily: String? + let requiresReboot: Bool + let rebooted: Bool? + let rebootRequested: Bool? + let waited: Bool? + let verified: Bool? + let message: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case payloadDir = "payload_dir" + case netbsd4 + case payloadFamily = "payload_family" + case requiresReboot = "requires_reboot" + case rebooted + case rebootRequested = "reboot_requested" + case waited + case verified + case message + case summary + } +} + +struct DoctorPayload: Decodable, Equatable { + let schemaVersion: Int + let fatal: Bool + let results: [DoctorCheckPayload] + let counts: [String: Int] + let error: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case fatal + case results + case counts + case error + case summary + } +} + +struct DoctorCheckPayload: Decodable, Equatable { + let status: String + let message: String + let details: JSONValue + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.status = try container.decode(String.self, forKey: .status) + self.message = try container.decode(String.self, forKey: .message) + self.details = try container.decodeIfPresent(JSONValue.self, forKey: .details) ?? .object([:]) + } + + enum CodingKeys: String, CodingKey { + case status + case message + case details + } +} + +struct FsckVolumeListPayload: Decodable, Equatable { + let schemaVersion: Int + let targets: [FsckTargetPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case targets + case counts + case summary + } +} + +struct FsckTargetPayload: Decodable, Equatable { + let name: String? + let builtin: Bool? + let device: String + let mountpoint: String +} + +struct ActivationPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let actions: [JSONValue] + let postActivationChecks: [PlannedCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case actions + case postActivationChecks = "post_activation_checks" + case counts + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.actions = try container.decodeIfPresent([JSONValue].self, forKey: .actions) ?? [] + self.postActivationChecks = try container.decodeIfPresent([PlannedCheckPayload].self, forKey: .postActivationChecks) ?? [] + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct ActivationResultPayload: Decodable, Equatable { + let schemaVersion: Int + let alreadyActive: Bool + let message: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case alreadyActive = "already_active" + case message + case summary + } +} + +struct UninstallPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let host: String + let volumeRoots: [String] + let payloadDirs: [String] + let remoteActions: [JSONValue] + let requiresReboot: Bool + let rebootRequired: Bool? + let postUninstallChecks: [PlannedCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case host + case volumeRoots = "volume_roots" + case payloadDirs = "payload_dirs" + case remoteActions = "remote_actions" + case requiresReboot = "requires_reboot" + case rebootRequired = "reboot_required" + case postUninstallChecks = "post_uninstall_checks" + case counts + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.host = try container.decode(String.self, forKey: .host) + self.volumeRoots = try container.decodeIfPresent([String].self, forKey: .volumeRoots) ?? [] + self.payloadDirs = try container.decodeIfPresent([String].self, forKey: .payloadDirs) ?? [] + self.remoteActions = try container.decodeIfPresent([JSONValue].self, forKey: .remoteActions) ?? [] + self.requiresReboot = try container.decode(Bool.self, forKey: .requiresReboot) + self.rebootRequired = try container.decodeIfPresent(Bool.self, forKey: .rebootRequired) + self.postUninstallChecks = try container.decodeIfPresent([PlannedCheckPayload].self, forKey: .postUninstallChecks) ?? [] + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct FsckPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let target: FsckTargetPayload? + let device: String + let mountpoint: String + let rebootRequired: Bool + let waitAfterReboot: Bool + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case target + case device + case mountpoint + case rebootRequired = "reboot_required" + case waitAfterReboot = "wait_after_reboot" + case summary + } +} + +struct FsckResultPayload: Decodable, Equatable { + let schemaVersion: Int + let device: String + let mountpoint: String + let returncode: Int? + let rebootRequested: Bool? + let waited: Bool? + let verified: Bool? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case device + case mountpoint + case returncode + case rebootRequested = "reboot_requested" + case waited + case verified + case summary + } +} + +struct RepairXattrsPayload: Decodable, Equatable { + let schemaVersion: Int + let returncode: Int? + let root: String? + let findingCount: Int + let repairableCount: Int + let counts: [String: Int] + let stats: JSONValue? + let report: String? + let telemetryResult: JSONValue? + let error: String? + let summary: String + let summaryText: String? + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case returncode + case root + case findingCount = "finding_count" + case repairableCount = "repairable_count" + case counts + case stats + case report + case telemetryResult = "telemetry_result" + case error + case summary + case summaryText = "summary_text" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.returncode = try container.decodeIfPresent(Int.self, forKey: .returncode) + self.root = try container.decodeIfPresent(String.self, forKey: .root) + self.findingCount = try container.decodeIfPresent(Int.self, forKey: .findingCount) ?? 0 + self.repairableCount = try container.decodeIfPresent(Int.self, forKey: .repairableCount) ?? 0 + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.stats = try container.decodeIfPresent(JSONValue.self, forKey: .stats) + self.report = try container.decodeIfPresent(String.self, forKey: .report) + self.telemetryResult = try container.decodeIfPresent(JSONValue.self, forKey: .telemetryResult) + self.error = try container.decodeIfPresent(String.self, forKey: .error) + self.summary = try container.decode(String.self, forKey: .summary) + self.summaryText = try container.decodeIfPresent(String.self, forKey: .summaryText) + } +} + +struct MaintenanceResultPayload: Decodable, Equatable { + let schemaVersion: Int + let summary: String + let message: String? + let requiresReboot: Bool? + let rebooted: Bool? + let rebootRequested: Bool? + let waited: Bool? + let verified: Bool? + let returncode: Int? + let counts: [String: Int]? + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case summary + case message + case requiresReboot = "requires_reboot" + case rebooted + case rebootRequested = "reboot_requested" + case waited + case verified + case returncode + case counts + } +} + +struct PlannedCheckPayload: Decodable, Equatable { + let id: String + let description: String +} + +struct BackendRecoveryPayload: Decodable, Equatable { + let title: String + let message: String? + let actions: [String] + let actionIDs: [String] + let retryable: Bool + let suggestedOperation: String? + let docsAnchor: String? + + enum CodingKeys: String, CodingKey { + case title + case message + case actions + case actionIDs = "action_ids" + case retryable + case suggestedOperation = "suggested_operation" + case docsAnchor = "docs_anchor" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.title = try container.decode(String.self, forKey: .title) + self.message = try container.decodeIfPresent(String.self, forKey: .message) + self.actions = try container.decodeIfPresent([String].self, forKey: .actions) ?? [] + self.actionIDs = try container.decodeIfPresent([String].self, forKey: .actionIDs) ?? [] + self.retryable = try container.decode(Bool.self, forKey: .retryable) + self.suggestedOperation = try container.decodeIfPresent(String.self, forKey: .suggestedOperation) + self.docsAnchor = try container.decodeIfPresent(String.self, forKey: .docsAnchor) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift new file mode 100644 index 0000000..61037c4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift @@ -0,0 +1,55 @@ +import Foundation + +struct OperationStageState: Equatable { + let operation: String + let stage: String + let risk: String? + let cancellable: Bool? + let description: String? + + init?(event: BackendEvent) { + guard event.type == "stage", let stage = event.stage else { + return nil + } + self.operation = event.operation + self.stage = stage + self.risk = event.risk + self.cancellable = event.cancellable + self.description = event.description + } +} + +struct BackendErrorViewModel: Equatable { + let operation: String + let code: String + let message: String + let recovery: BackendRecoveryPayload? + + init(event: BackendEvent) { + self.operation = event.operation + self.code = event.code ?? "operation_failed" + self.message = event.message ?? event.summary + self.recovery = try? event.recovery?.decode(BackendRecoveryPayload.self) + } + + init(operation: String, code: String, message: String, recovery: BackendRecoveryPayload? = nil) { + self.operation = operation + self.code = code + self.message = message + self.recovery = recovery + } +} + +extension BackendEvent { + var payloadSummaryText: String? { + guard let payload else { + return nil + } + for key in ["summary", "message", "summary_text"] { + if let value = payload.stringValue(for: key) { + return value + } + } + return nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift new file mode 100644 index 0000000..bfb51ee --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift @@ -0,0 +1,152 @@ +import Foundation + +public enum BundleRuntimeMode: String, CaseIterable, Equatable, Sendable { + case explicit + case productionBundle + case developmentCheckout +} + +public enum BundleRuntimeIssueSeverity: String, CaseIterable, Equatable, Sendable { + case warning + case error +} + +public enum BundleRuntimeIssueCode: String, CaseIterable, Equatable, Sendable { + case helperMissing + case helperNotExecutable + case distributionRootMissing + case toolsDirectoryMissing + case installValidationFailed + case helperLaunchFailed + case contractDecodeFailed + case operationFailed +} + +public struct BundleRuntimeIssue: Identifiable, Equatable, Sendable { + public var id: String { + "\(code.rawValue):\(message)" + } + + public let code: BundleRuntimeIssueCode + public let severity: BundleRuntimeIssueSeverity + public let message: String + public let recovery: String + + public init( + code: BundleRuntimeIssueCode, + severity: BundleRuntimeIssueSeverity, + message: String, + recovery: String + ) { + self.code = code + self.severity = severity + self.message = message + self.recovery = recovery + } +} + +public struct BundleLayout: Equatable, Sendable { + public let appBundleURL: URL + public let executableURL: URL? + public let resourceURL: URL + public let helperURL: URL + public let distributionRootURL: URL + public let toolsBinURL: URL + public let pythonRuntimeURL: URL? + public let applicationSupportURL: URL + public let configURL: URL + public let stateDirectoryURL: URL + + public init( + appBundleURL: URL, + executableURL: URL? = nil, + resourceURL: URL, + helperURL: URL, + distributionRootURL: URL? = nil, + toolsBinURL: URL? = nil, + pythonRuntimeURL: URL? = nil, + applicationSupportURL: URL, + configURL: URL? = nil, + stateDirectoryURL: URL? = nil + ) { + self.appBundleURL = appBundleURL + self.executableURL = executableURL + self.resourceURL = resourceURL + self.helperURL = helperURL + self.distributionRootURL = distributionRootURL ?? resourceURL.appendingPathComponent("Distribution", isDirectory: true) + self.toolsBinURL = toolsBinURL ?? resourceURL.appendingPathComponent("Tools/bin", isDirectory: true) + self.pythonRuntimeURL = pythonRuntimeURL + self.applicationSupportURL = applicationSupportURL + self.configURL = configURL ?? applicationSupportURL.appendingPathComponent(".env") + self.stateDirectoryURL = stateDirectoryURL ?? applicationSupportURL + } + + public static func productionCandidate( + bundle: Bundle = .main, + fileManager: FileManager = .default, + applicationSupportURL: URL? = nil + ) -> BundleLayout? { + let resources = bundle.resourceURL ?? bundle.bundleURL.appendingPathComponent("Contents/Resources", isDirectory: true) + let helper = bundle.bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Helpers", isDirectory: true) + .appendingPathComponent("tcapsule") + guard let appSupport = applicationSupportURL ?? applicationSupportDirectory(fileManager: fileManager) else { + return nil + } + return BundleLayout( + appBundleURL: bundle.bundleURL, + executableURL: bundle.executableURL, + resourceURL: resources, + helperURL: helper, + applicationSupportURL: appSupport + ) + } + + public static func applicationSupportDirectory(fileManager: FileManager = .default) -> URL? { + fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("TimeCapsuleSMB", isDirectory: true) + } + + public func validationIssues(fileManager: FileManager = .default) -> [BundleRuntimeIssue] { + var issues: [BundleRuntimeIssue] = [] + if !fileManager.fileExists(atPath: helperURL.path) { + issues.append(BundleRuntimeIssue( + code: .helperMissing, + severity: .error, + message: "The bundled TimeCapsuleSMB helper is missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } else if !fileManager.isExecutableFile(atPath: helperURL.path) { + issues.append(BundleRuntimeIssue( + code: .helperNotExecutable, + severity: .error, + message: "The bundled TimeCapsuleSMB helper is not executable.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } + if !isDirectory(distributionRootURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .distributionRootMissing, + severity: .error, + message: "The bundled TimeCapsuleSMB distribution is missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } + if !isDirectory(toolsBinURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .toolsDirectoryMissing, + severity: .warning, + message: "Bundled command-line tools are missing.", + recovery: "Some diagnostics may be unavailable until the app bundle is repaired." + )) + } + return issues + } + + private func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift new file mode 100644 index 0000000..647e0bd --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift @@ -0,0 +1,81 @@ +import Foundation + +@MainActor +protocol ConfiguredDeviceProfileSaving: AnyObject { + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + password: String, + preferredID: DeviceProfile.ID + ) throws -> DeviceProfile +} + +@MainActor +final class ConfiguredDeviceProfileSaver: ConfiguredDeviceProfileSaving { + private enum PasswordRollback { + case delete + case restore(String) + } + + private let registry: DeviceRegistryStore + private let passwordStore: PasswordStore + + init(registry: DeviceRegistryStore, passwordStore: PasswordStore) { + self.registry = registry + self.passwordStore = passwordStore + } + + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + password: String, + preferredID: DeviceProfile.ID + ) throws -> DeviceProfile { + let profile = registry.makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: .available, + preferredID: preferredID + ) + let wasSavedProfile = registry.profile(id: profile.id) != nil + let rollback = try passwordRollback(for: profile.keychainAccount) + + do { + try passwordStore.save(password, for: profile.keychainAccount) + } catch { + if !wasSavedProfile { + registry.discardArtifacts(for: profile) + } + throw error + } + + do { + return try registry.saveProfileMergingDuplicates(profile) + } catch { + rollbackPassword(rollback, account: profile.keychainAccount) + if !wasSavedProfile { + registry.discardArtifacts(for: profile) + } + throw error + } + } + + private func passwordRollback(for account: String) throws -> PasswordRollback { + do { + return .restore(try passwordStore.password(for: account)) + } catch PasswordStoreError.missing { + return .delete + } catch { + throw error + } + } + + private func rollbackPassword(_ rollback: PasswordRollback, account: String) { + switch rollback { + case .delete: + try? passwordStore.deletePassword(for: account) + case .restore(let password): + try? passwordStore.save(password, for: account) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift new file mode 100644 index 0000000..a0e01a9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift @@ -0,0 +1,188 @@ +import SwiftUI + +struct ConnectView: View { + @ObservedObject var store: ConnectionWorkflowStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("panel.connect")) + .font(.title2.weight(.semibold)) + + HStack { + TextField(L10n.string("field.host"), text: $store.manualHost) + SecureField(L10n.string("field.password"), text: $password) + TextField(L10n.string("field.bonjour_timeout"), text: $store.bonjourTimeout) + .frame(width: 180) + } + + Toggle(L10n.string("toggle.enable_debug_logging"), isOn: $store.debugLogging) + + HStack { + Button { + store.runDiscover() + } label: { + Label(L10n.string("button.discover"), systemImage: "network") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + + Button { + store.runConfigure(password: password) + } label: { + Label(L10n.string("button.configure"), systemImage: "lock.open") + } + .disabled(!store.canConfigure(password: password)) + + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if !store.devices.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(store.devices) { device in + Button { + store.select(device) + } label: { + DeviceRow( + device: device, + selected: store.selectedDeviceID == device.id + ) + } + .buttonStyle(.plain) + } + } + } + + if let configuredDevice = store.configuredDevice { + ConfiguredDeviceView(device: configuredDevice) + } + + if let error = store.error { + ErrorBlock(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusIcon: String { + switch store.state { + case .idle: + return "circle" + case .discovering, .configuring: + return "hourglass" + case .discoveryReady, .configured: + return "checkmark.circle" + case .discoveryEmpty: + return "magnifyingglass" + case .discoveryFailed, .configureFailed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .discoveryReady, .configured: + return .green + case .discoveryFailed, .configureFailed: + return .red + default: + return .secondary + } + } +} + +private struct DeviceRow: View { + let device: DiscoveredDevice + let selected: Bool + + var body: some View { + HStack(spacing: 10) { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.body.weight(.medium)) + HStack(spacing: 8) { + if !device.host.isEmpty { + Text(device.host) + } + if !device.hostname.isEmpty { + Text(device.hostname) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if let model = device.model { + Text(model) + .font(.caption) + .foregroundStyle(.secondary) + } else if let syap = device.syap { + Text("syAP \(syap)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(selected ? Color.accentColor.opacity(0.12) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +private struct ConfiguredDeviceView: View { + let device: ConfiguredDeviceState + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Configured Host") + .foregroundStyle(.secondary) + Text(device.host) + } + GridRow { + Text("Config") + .foregroundStyle(.secondary) + Text(device.configPath) + .lineLimit(1) + .truncationMode(.middle) + } + if let model = device.model { + GridRow { + Text("Model") + .foregroundStyle(.secondary) + Text(model) + } + } + if let syap = device.syap { + GridRow { + Text("syAP") + .foregroundStyle(.secondary) + Text(syap) + } + } + if let compatibility = device.compatibility { + GridRow { + Text("Payload") + .foregroundStyle(.secondary) + Text(compatibility.payloadFamily ?? "unknown") + } + } + } + .font(.caption) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift new file mode 100644 index 0000000..ec47013 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift @@ -0,0 +1,366 @@ +import Combine +import Foundation + +enum ConnectionWorkflowState: String, CaseIterable, Equatable { + case idle + case discovering + case discoveryReady + case discoveryEmpty + case discoveryFailed + case configuring + case configured + case configureFailed + + var title: String { + switch self { + case .idle: + return "Idle" + case .discovering: + return "Discovering" + case .discoveryReady: + return "Devices Found" + case .discoveryEmpty: + return "No Devices Found" + case .discoveryFailed: + return "Discovery Failed" + case .configuring: + return "Configuring" + case .configured: + return "Configured" + case .configureFailed: + return "Configure Failed" + } + } +} + +struct DiscoveredDevice: Identifiable, Equatable { + let id: String + let name: String + let host: String + let hostname: String + let addresses: [String] + let syap: String? + let model: String? + let rawRecord: JSONValue + + init(payload: DiscoveredDevicePayload, index: Int) { + self.id = payload.id.isEmpty ? "discovered-\(index)" : payload.id + self.name = payload.name.isEmpty ? (payload.hostname.isEmpty ? "AirPort Device" : payload.hostname) : payload.name + self.host = payload.host + self.hostname = payload.hostname + self.addresses = payload.addresses.isEmpty ? payload.ipv4 + payload.ipv6 : payload.addresses + self.syap = payload.syap + self.model = payload.model + self.rawRecord = payload.selectedRecord + } + + init(record: BonjourResolvedServicePayload, index: Int) { + let stableParts = [ + record.fullname, + record.serviceType, + record.name, + record.hostname, + record.ipv4.joined(separator: ","), + record.ipv6.joined(separator: ",") + ] + let stableID = stableParts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "|") + + self.id = stableID.isEmpty ? "discovered-\(index)" : stableID + self.name = record.name.isEmpty ? (record.hostname.isEmpty ? "AirPort Device" : record.hostname) : record.name + self.hostname = record.hostname + self.addresses = record.ipv4 + record.ipv6 + self.host = Self.displayHost(record) + self.syap = record.properties["syAP"] ?? record.properties["syap"] + self.model = record.properties["model"] ?? record.properties["am"] + self.rawRecord = record.jsonValue + } + + private static func displayHost(_ record: BonjourResolvedServicePayload) -> String { + if let address = record.ipv4.first ?? record.ipv6.first { + return address + } + return record.hostname + } +} + +struct ConfiguredDeviceState: Equatable { + let host: String + let configPath: String + let configureId: String + let sshAuthenticated: Bool + let syap: String? + let model: String? + let compatibility: DeviceCompatibilityPayload? + + init(payload: ConfigurePayload) { + self.host = payload.host + self.configPath = payload.configPath + self.configureId = payload.configureId + self.sshAuthenticated = payload.sshAuthenticated + self.syap = payload.deviceSyap ?? payload.device?.syap + self.model = payload.deviceModel ?? payload.device?.model + self.compatibility = payload.compatibility + } +} + +@MainActor +final class ConnectionWorkflowStore: ObservableObject { + @Published var manualHost = "" + @Published var bonjourTimeout = "6" + @Published var debugLogging = false + @Published private(set) var state: ConnectionWorkflowState = .idle + @Published private(set) var devices: [DiscoveredDevice] = [] + @Published var selectedDeviceID: DiscoveredDevice.ID? + @Published private(set) var configuredDevice: ConfiguredDeviceState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let backend: BackendClient + + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + var selectedDevice: DiscoveredDevice? { + guard let selectedDeviceID else { + return nil + } + return devices.first { $0.id == selectedDeviceID } + } + + func canConfigure(password: String) -> Bool { + !backend.isRunning + && !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (selectedDevice != nil || !manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + func runDiscover() { + guard let timeout = bonjourTimeoutValue else { + failLocally(operation: "discover", state: .discoveryFailed, message: "Bonjour timeout must be a non-negative number.") + return + } + resetRunState(clearDevices: true, clearConfiguredDevice: true) + state = .discovering + backend.run(operation: "discover", params: OperationParams.discover(timeout: timeout)) + } + + func runConfigure(password: String) { + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPassword.isEmpty else { + failLocally(operation: "configure", state: .configureFailed, message: "Password is required.") + return + } + let selectedDevice = selectedDevice + let trimmedHost = manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard selectedDevice != nil || !trimmedHost.isEmpty else { + failLocally(operation: "configure", state: .configureFailed, message: "Choose a discovered device or enter a host.") + return + } + + resetRunState(clearDevices: false, clearConfiguredDevice: true) + state = .configuring + let params = OperationParams.configure( + host: trimmedHost, + selectedRecord: selectedDevice?.rawRecord, + password: password, + debugLogging: debugLogging + ) + backend.run(operation: "configure", params: params) + } + + func select(_ device: DiscoveredDevice) { + selectedDeviceID = device.id + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + state = .idle + devices = [] + selectedDeviceID = nil + configuredDevice = nil + error = nil + currentStage = nil + } + + func cancel() { + backend.cancel() + } + + private func resetRunState(clearDevices: Bool, clearConfiguredDevice: Bool) { + backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + if clearDevices { + devices = [] + selectedDeviceID = nil + } + if clearConfiguredDevice { + configuredDevice = nil + } + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "discover" || event.operation == "configure" else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + + if event.ok == false { + applyFailureResult(event) + return + } + + switch event.operation { + case "discover": + applyDiscoverResult(event) + case "configure": + applyConfigureResult(event) + default: + break + } + } + + private func applyDiscoverResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(DiscoverPayload.self) + let discoveredDevices = payload.devices.isEmpty + ? payload.resolved.enumerated().map { index, record in DiscoveredDevice(record: record, index: index) } + : payload.devices.enumerated().map { index, device in DiscoveredDevice(payload: device, index: index) } + devices = discoveredDevices + selectedDeviceID = discoveredDevices.count == 1 ? discoveredDevices[0].id : nil + error = nil + state = discoveredDevices.isEmpty ? .discoveryEmpty : .discoveryReady + } catch { + failContract(operation: "discover", state: .discoveryFailed, error: error) + } + } + + private func applyConfigureResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(ConfigurePayload.self) + configuredDevice = ConfiguredDeviceState(payload: payload) + error = nil + state = .configured + } catch { + failContract(operation: "configure", state: .configureFailed, error: error) + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + switch event.operation { + case "discover": + state = .discoveryFailed + case "configure": + state = .configureFailed + default: + break + } + } + + private func applyFailureResult(_ event: BackendEvent) { + let message = event.payloadSummaryText ?? event.summary + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: message + ) + switch event.operation { + case "discover": + state = .discoveryFailed + case "configure": + state = .configureFailed + default: + break + } + } + + private func failContract(operation: String, state: ConnectionWorkflowState, error: Error) { + self.error = BackendErrorViewModel( + operation: operation, + code: "contract_decode_failed", + message: error.localizedDescription + ) + self.state = state + } + + private func failLocally(operation: String, state: ConnectionWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: operation, + code: "validation_failed", + message: message + ) + currentStage = nil + self.state = state + } + + private func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift new file mode 100644 index 0000000..26cd937 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -0,0 +1,1073 @@ +import AppKit +import SwiftUI + +public struct ContentView: View { + @StateObject private var appStore: AppStore + @StateObject private var addDeviceStore: AddDeviceFlowStore + @StateObject private var dashboardStore: DashboardStore + @State private var diagnosticsPresented = false + @State private var replacementPassword = "" + @State private var profilePendingDeletion: DeviceProfile? + @State private var deleteErrorMessage: String? + + @MainActor + public init() { + let appStore = AppStore() + _appStore = StateObject(wrappedValue: appStore) + _addDeviceStore = StateObject(wrappedValue: AddDeviceFlowStore( + coordinator: appStore.operationCoordinator, + registry: appStore.deviceRegistry, + passwordStore: appStore.passwordStore + )) + _dashboardStore = StateObject(wrappedValue: DashboardStore(appStore: appStore)) + } + + public var body: some View { + NavigationSplitView { + sidebar + } detail: { + VStack(spacing: 0) { + if case .blocked = appStore.appReadinessStore.state { + AppReadinessBlockedView(store: appStore.appReadinessStore) { + diagnosticsPresented = true + } + } else { + AppReadinessBannerView(store: appStore.appReadinessStore) { + diagnosticsPresented = true + } + detail + Divider() + ActivityCompactView( + activityStore: appStore.activityStore, + registry: appStore.deviceRegistry + ) + } + } + .toolbar { + ToolbarItemGroup { + Button { + appStore.showAddDevice() + } label: { + Label(L10n.string("toolbar.add"), systemImage: "plus") + } + Button { + diagnosticsPresented = true + } label: { + Label(L10n.string("toolbar.diagnostics"), systemImage: "wrench.and.screwdriver") + } + Button { + if let profile = appStore.selectedProfile { + profilePendingDeletion = profile + } else { + appStore.operationCoordinator.clear() + } + } label: { + Label(appStore.selectedProfile == nil ? L10n.string("toolbar.clear") : L10n.string("toolbar.forget"), systemImage: "trash") + } + .disabled(appStore.backend.isRunning) + Button { + appStore.operationCoordinator.cancel() + } label: { + Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") + } + .disabled(!appStore.backend.canCancel) + } + } + } + .frame(minWidth: 1080, minHeight: 720) + .task { + appStore.start() + } + .onChange(of: addDeviceStore.savedProfile) { profile in + guard let profile else { return } + appStore.select(profile) + } + .sheet(isPresented: $diagnosticsPresented) { + AppDiagnosticsView( + store: appStore.appReadinessStore, + events: appStore.backend.events, + helperPath: Binding( + get: { appStore.backend.helperPath }, + set: { appStore.backend.helperPath = $0 } + ) + ) + } + .confirmationDialog( + L10n.string("dialog.forget.title"), + isPresented: deleteConfirmationPresented, + presenting: profilePendingDeletion + ) { profile in + Button(L10n.format("dialog.forget.action", profile.title), role: .destructive) { + do { + try appStore.forget(profile) + profilePendingDeletion = nil + } catch { + deleteErrorMessage = error.localizedDescription + } + } + Button(L10n.string("action.cancel"), role: .cancel) { + profilePendingDeletion = nil + } + } message: { profile in + Text(L10n.format("dialog.forget.message", profile.title)) + } + .alert(L10n.string("dialog.forget.error_title"), isPresented: deleteErrorPresented) { + Button(L10n.string("action.ok"), role: .cancel) { + deleteErrorMessage = nil + } + } message: { + Text(deleteErrorMessage ?? "") + } + .alert( + appStore.backend.pendingConfirmation?.title ?? "", + isPresented: confirmationPresented, + presenting: appStore.backend.pendingConfirmation + ) { confirmation in + Button(confirmation.actionTitle, role: .destructive) { + appStore.backend.confirmPending() + } + Button(L10n.string("action.cancel"), role: .cancel) { + appStore.backend.pendingConfirmation = nil + } + } message: { confirmation in + Text(confirmation.message) + } + } + + private var deleteConfirmationPresented: Binding { + Binding( + get: { profilePendingDeletion != nil }, + set: { isPresented in + if !isPresented { + profilePendingDeletion = nil + } + } + ) + } + + private var deleteErrorPresented: Binding { + Binding( + get: { deleteErrorMessage != nil }, + set: { isPresented in + if !isPresented { + deleteErrorMessage = nil + } + } + ) + } + + private var confirmationPresented: Binding { + Binding( + get: { appStore.backend.pendingConfirmation != nil }, + set: { isPresented in + if !isPresented { + appStore.backend.pendingConfirmation = nil + } + } + ) + } + + private var sidebarSelection: Binding { + Binding( + get: { + if appStore.showingAddDevice { + return "add" + } + if let selectedDeviceID = appStore.selectedDeviceID { + return "device:\(selectedDeviceID)" + } + return "all" + }, + set: { value in + guard let value else { return } + if value == "add" { + appStore.showAddDevice() + } else if value == "all" { + appStore.selectedDeviceID = nil + appStore.showingAddDevice = false + } else if value.hasPrefix("device:") { + let id = String(value.dropFirst("device:".count)) + if let profile = appStore.deviceRegistry.profile(id: id) { + appStore.select(profile) + } + } + } + ) + } + + private var sidebar: some View { + List(selection: sidebarSelection) { + Label(L10n.string("sidebar.all_time_capsules"), systemImage: "externaldrive.connected.to.line.below") + .tag("all") + + Section(L10n.string("sidebar.devices")) { + ForEach(appStore.deviceRegistry.profiles) { profile in + DeviceSidebarRow( + profile: profile, + summary: appStore.dashboardSummary(for: profile) + ) + .tag("device:\(profile.id)") + } + } + + Section { + Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") + .tag("add") + } + } + .navigationTitle("TimeCapsuleSMB") + .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) + } + + @ViewBuilder + private var detail: some View { + if appStore.showingAddDevice { + AddDeviceView(store: addDeviceStore) + } else if let profile = appStore.selectedProfile { + DeviceDashboardView( + profile: profile, + dashboardStore: dashboardStore, + appStore: appStore, + replacementPassword: $replacementPassword, + showDiagnostics: { + diagnosticsPresented = true + } + ) + } else { + DeviceListOverviewView(appStore: appStore) + } + } +} + +private struct DeviceListOverviewView: View { + @ObservedObject var appStore: AppStore + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(appStore.deviceRegistry.profiles.isEmpty ? L10n.string("overview.empty.title") : L10n.string("sidebar.all_time_capsules")) + .font(.title2.weight(.semibold)) + if appStore.deviceRegistry.profiles.isEmpty { + Text(L10n.string("overview.empty.message")) + .foregroundStyle(.secondary) + Button { + appStore.showAddDevice() + } label: { + Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") + } + } else { + ForEach(appStore.deviceRegistry.profiles) { profile in + let summary = appStore.dashboardSummary(for: profile) + Button { + appStore.select(profile) + } label: { + HStack { + VStack(alignment: .leading) { + Text(profile.title) + .font(.body.weight(.medium)) + Text(profile.host) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Label(summary.displayStatus.title, systemImage: summary.displayStatus.systemImage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + Divider() + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +private struct AddDeviceView: View { + @ObservedObject var store: AddDeviceFlowStore + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text(L10n.string("add_device.title")) + .font(.title2.weight(.semibold)) + Spacer() + Picker(L10n.string("add_device.connection_method"), selection: Binding( + get: { store.entryMode }, + set: { store.setEntryMode($0) } + )) { + ForEach(AddDeviceEntryMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(width: 360) + } + + HStack { + if store.entryMode == .discover { + Text(store.currentStage?.description ?? L10n.string("add_device.discover.placeholder")) + .foregroundStyle(.secondary) + Button { + store.runDiscover() + } label: { + Label(L10n.string("button.discover"), systemImage: "network") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + } + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + .frame(minHeight: 28, alignment: .center) + + if store.entryMode == .discover && !store.devices.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("add_device.discovered_devices")) + .font(.headline) + ForEach(store.devices) { device in + Button { + store.select(device) + } label: { + DeviceCandidateRow(device: device, selected: store.selectedDeviceID == device.id) + } + .buttonStyle(.plain) + } + } + } + + HStack { + TextField(L10n.string("add_device.host_or_ip"), text: Binding( + get: { store.hostFieldText }, + set: { store.manualHost = $0 } + )) + .disabled(!store.isHostFieldEditable) + SecureField(L10n.string("add_device.password"), text: $store.password) + } + + HStack { + Button { + store.runConfigure() + } label: { + Label(L10n.string("add_device.save_device"), systemImage: "checkmark.circle") + } + .disabled(!store.canConfigure) + + Button { + store.reset() + } label: { + Label(L10n.string("add_device.reset"), systemImage: "arrow.counterclockwise") + } + .disabled(store.isRunning) + } + + if let profile = store.savedProfile { + Label(L10n.format("add_device.saved", profile.title), systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + + if let error = store.error { + ErrorBlock(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var statusIcon: String { + switch store.state { + case .idle, .manualEntry, .passwordEntry: + return "circle" + case .discovering, .configuring, .savingProfile: + return "hourglass" + case .discoveryReady, .saved: + return "checkmark.circle" + case .discoveryEmpty: + return "magnifyingglass" + case .authFailed, .unsupported, .failed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .discoveryReady, .saved: + return .green + case .authFailed, .unsupported, .failed: + return .red + default: + return .secondary + } + } +} + +private struct DeviceCandidateRow: View { + let device: DiscoveredDevice + let selected: Bool + + var body: some View { + HStack { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading) { + Text(device.name) + Text([device.host, device.hostname].filter { !$0.isEmpty }.joined(separator: " ")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(device.model ?? device.syap ?? "") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + } +} + +private struct DeviceDashboardView: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var appStore: AppStore + @Binding var replacementPassword: String + let showDiagnostics: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Picker("", selection: $dashboardStore.selectedTab) { + ForEach(DeviceDashboardTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + + Divider() + + ScrollView { + Group { + switch dashboardStore.selectedTab { + case .overview: + OverviewTab(profile: profile, dashboardStore: dashboardStore, appStore: appStore, replacementPassword: $replacementPassword) + case .install: + InstallTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) + case .checkup: + CheckupTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) + case .maintenance: + MaintenanceTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) + case .advanced: + AdvancedTab(profile: profile, appStore: appStore) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +private struct OverviewTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var appStore: AppStore + @Binding var replacementPassword: String + + var body: some View { + let summary = dashboardStore.summary(for: profile) + VStack(alignment: .leading, spacing: 16) { + if let warning = summary.hostWarning { + WarningBanner(warning: warning) + } + + Text(profile.title) + .font(.title2.weight(.semibold)) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { Text(L10n.string("dashboard.overview.status")).foregroundStyle(.secondary); Text(summary.displayStatus.title) } + GridRow { Text(L10n.string("dashboard.overview.host")).foregroundStyle(.secondary); Text(profile.host) } + GridRow { Text(L10n.string("dashboard.overview.model")).foregroundStyle(.secondary); Text(profile.model ?? L10n.string("value.unknown")) } + GridRow { Text(L10n.string("dashboard.overview.generation")).foregroundStyle(.secondary); Text(profile.deviceGeneration ?? L10n.string("value.unknown")) } + GridRow { Text(L10n.string("dashboard.overview.payload")).foregroundStyle(.secondary); Text(profile.payloadFamily ?? L10n.string("value.unknown")) } + GridRow { Text(L10n.string("dashboard.overview.password")).foregroundStyle(.secondary); Text(summary.passwordState.title) } + GridRow { Text(L10n.string("dashboard.overview.last_checkup")).foregroundStyle(.secondary); Text(profile.lastCheckup?.summary ?? L10n.string("value.never")) } + GridRow { Text(L10n.string("dashboard.overview.last_install")).foregroundStyle(.secondary); Text(profile.lastDeploy?.summary ?? L10n.string("value.never")) } + } + + HStack { + Button(primaryActionTitle(summary.primaryAction)) { + runPrimary(summary.primaryAction) + } + .buttonStyle(.borderedProminent) + + Button { + dashboardStore.runCheckup(profile: profile) + } label: { + Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") + } + } + + HStack { + SecureField(L10n.string("dashboard.replacement_password"), text: $replacementPassword) + Button { + try? appStore.savePassword(replacementPassword, for: profile) + replacementPassword = "" + } label: { + Label(L10n.string("dashboard.action.save_password"), systemImage: "key") + } + .disabled(replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if let passwordError = dashboardStore.passwordError { + Text(passwordError) + .foregroundStyle(.red) + } + } + } + + private func primaryActionTitle(_ action: DashboardPrimaryAction) -> String { + switch action { + case .addDevice: + return L10n.string("sidebar.add_time_capsule") + case .replacePassword: + return L10n.string("dashboard.action.replace_password") + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .installSMB: + return L10n.string("dashboard.action.install_smb") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .openSMB: + return L10n.string("dashboard.action.open_smb") + } + } + + private func runPrimary(_ action: DashboardPrimaryAction) { + switch action { + case .replacePassword: + replacementPassword = "" + case .runCheckup: + dashboardStore.runCheckup(profile: profile) + case .viewCheckup: + dashboardStore.selectedTab = .checkup + case .openSMB: + openSMBAddress() + case .installSMB: + dashboardStore.runInstallPlan(profile: profile) + case .addDevice: + appStore.showAddDevice() + } + } + + private func openSMBAddress() { + let host = profile.host + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"^.*@"#, with: "", options: .regularExpression) + guard !host.isEmpty, let url = URL(string: "smb://\(host)") else { + return + } + NSWorkspace.shared.open(url) + } +} + +private struct InstallTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + let showDiagnostics: () -> Void + + var body: some View { + let store = dashboardStore.deployStore + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.install")) + .font(.title2.weight(.semibold)) + HStack { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $dashboardStore.deployStore.nbnsEnabled) + Toggle(L10n.string("toggle.no_reboot"), isOn: $dashboardStore.deployStore.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $dashboardStore.deployStore.noWait) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $dashboardStore.deployStore.debugLogging) + TextField(L10n.string("field.mount_wait"), text: $dashboardStore.deployStore.mountWait) + .frame(width: 150) + } + HStack { + Button { + dashboardStore.runInstallPlan(profile: profile) + } label: { + Label(L10n.string("deploy.action.plan_install"), systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + Button { + dashboardStore.runInstall(profile: profile) + } label: { + Label(L10n.string("dashboard.action.install_smb"), systemImage: "square.and.arrow.up") + } + .disabled(!store.canDeploy) + Label(store.state.title, systemImage: "circle") + } + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let plan = store.plan { + let presentation = DeployPlanPresentation( + plan: plan, + profile: profile, + hostWarning: HostCompatibilityPolicy.warning() + ) + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + DisclosureGroup(L10n.string("deploy.advanced_plan_details")) { + SummaryGrid(rows: presentation.advancedRows.map { ($0.label, $0.value) }) + .padding(.top, 6) + } + } + if let result = store.result { + SummaryGrid(rows: [ + (L10n.string("deploy.result.verified"), result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")), + (L10n.string("deploy.result.reboot_requested"), result.rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), + (L10n.string("deploy.result.message"), result.message ?? L10n.string("deploy.result.default_message")) + ]) + } + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } + } + } + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + } +} + +private struct CheckupTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + let showDiagnostics: () -> Void + + var body: some View { + let store = dashboardStore.doctorStore + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.checkup")) + .font(.title2.weight(.semibold)) + HStack { + TextField(L10n.string("field.bonjour_timeout"), text: $dashboardStore.doctorStore.bonjourTimeout) + .frame(width: 180) + Button { + dashboardStore.runCheckup(profile: profile) + } label: { + Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + Label(store.state.title, systemImage: "circle") + } + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let summary = store.summary { + let presentation = CheckupPresentation(summary: summary, state: store.state) + Text(presentation.headline) + .font(.headline) + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + ForEach(presentation.groups) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.domain).font(.headline) + ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in + HStack { + Text(check.status) + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(check.message) + .font(.caption) + } + } + } + } + } + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } + } + } + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + } +} + +private struct MaintenanceTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + let showDiagnostics: () -> Void + + var body: some View { + let store = dashboardStore.maintenanceStore + let presentation = MaintenanceWorkflowPresentation.presentation(for: store.selectedWorkflow) + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.maintenance")) + .font(.title2.weight(.semibold)) + Picker(L10n.string("dashboard.tab.maintenance"), selection: $dashboardStore.maintenanceStore.selectedWorkflow) { + Text(L10n.string("maintenance.workflow.activate")).tag(MaintenanceWorkflow.activate) + Text(L10n.string("maintenance.workflow.uninstall")).tag(MaintenanceWorkflow.uninstall) + Text(L10n.string("maintenance.workflow.fsck")).tag(MaintenanceWorkflow.fsck) + Text(L10n.string("maintenance.workflow.repair_xattrs")).tag(MaintenanceWorkflow.repairXattrs) + } + .pickerStyle(.segmented) + + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.headline) + Text(presentation.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + Label(presentation.risk, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + TextField(L10n.string("field.mount_wait"), text: $dashboardStore.maintenanceStore.mountWait) + .frame(width: 150) + Toggle(L10n.string("toggle.no_reboot"), isOn: $dashboardStore.maintenanceStore.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $dashboardStore.maintenanceStore.noWait) + } + + maintenanceControls(store: store) + FlashBootHookSection(profile: profile) + + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let error = store.error { + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } + } + } + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + } + + @ViewBuilder + private func maintenanceControls(store: MaintenanceStore) -> some View { + switch store.selectedWorkflow { + case .activate: + HStack { + Button(L10n.string("maintenance.action.plan_start_smb")) { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.planActivation(password: password, profile: profile) + } + } + Button(L10n.string("maintenance.action.start_smb")) { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.runActivation(password: password, profile: profile) + } + } + .disabled(!store.canRunActivation) + Label(store.activateState.title, systemImage: "circle") + } + case .uninstall: + HStack { + Button(L10n.string("maintenance.action.plan_uninstall")) { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.planUninstall(password: password, profile: profile) + } + } + Button(L10n.string("maintenance.action.uninstall")) { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.runUninstall(password: password, profile: profile) + } + } + .disabled(!store.canRunUninstall) + Label(store.uninstallState.title, systemImage: "circle") + } + case .fsck: + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(L10n.string("maintenance.action.find_volumes")) { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.refreshFsckTargets(password: password, profile: profile) + } + } + Button(L10n.string("maintenance.action.plan_disk_repair")) { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.planFsck(password: password, profile: profile) + } + } + .disabled(!store.canPlanFsck) + Button(L10n.string("maintenance.action.run_disk_repair")) { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.runFsck(password: password, profile: profile) + } + } + .disabled(!store.canRunFsck) + Label(store.fsckState.title, systemImage: "circle") + } + ForEach(store.fsckTargets) { target in + Button { + store.selectedFsckTargetID = target.id + } label: { + HStack { + Image(systemName: store.selectedFsckTargetID == target.id ? "checkmark.circle.fill" : "circle") + Text(target.name ?? target.device) + Text(target.mountpoint).foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + } + case .repairXattrs: + VStack(alignment: .leading, spacing: 8) { + HStack { + TextField(L10n.string("field.repair_xattrs_path"), text: $dashboardStore.maintenanceStore.repairPath) + Button { + chooseRepairPath(store: store) + } label: { + Label(L10n.string("maintenance.action.choose_folder"), systemImage: "folder") + } + } + HStack { + Button(L10n.string("maintenance.action.scan_metadata")) { + store.scanRepairXattrs() + } + Button(L10n.string("maintenance.action.repair_metadata")) { + store.runRepairXattrs() + } + .disabled(!store.canRepairXattrs) + Label(store.repairState.title, systemImage: "circle") + } + if let scan = store.repairScan { + Text(L10n.format("maintenance.repairable_count", scan.repairableCount)) + .foregroundStyle(.secondary) + } + } + } + } + + private func chooseRepairPath(store: MaintenanceStore) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = L10n.string("maintenance.action.choose") + if panel.runModal() == .OK, let url = panel.url { + store.repairPath = url.path + } + } +} + +private struct AdvancedTab: View { + let profile: DeviceProfile + @ObservedObject var appStore: AppStore + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.advanced")) + .font(.title2.weight(.semibold)) + SummaryGrid(rows: [ + (L10n.string("advanced.profile_id"), profile.id), + (L10n.string("advanced.config"), profile.configPath), + (L10n.string("advanced.helper"), appStore.backend.helperPath.isEmpty ? L10n.string("value.auto") : appStore.backend.helperPath) + ]) + EventList(events: appStore.backend.events) + } + } +} + +private struct AppReadinessBannerView: View { + @ObservedObject var store: AppReadinessStore + let showDiagnostics: () -> Void + + var body: some View { + switch store.state { + case .idle, .ready: + EmptyView() + case .resolvingBundle, .checkingCapabilities, .validatingInstall: + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(title) + .font(.caption) + if let stage = store.currentStage?.description ?? store.currentStage?.stage { + Text(stage) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.08)) + case .degraded(_, let issues): + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + Text(issues.first?.message ?? L10n.string("readiness.warning.default")) + .font(.caption) + Spacer() + Button(L10n.string("toolbar.diagnostics"), action: showDiagnostics) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.yellow.opacity(0.12)) + case .blocked: + EmptyView() + } + } + + private var title: String { + switch store.state.kind { + case .resolvingBundle: + return L10n.string("readiness.state.resolving_bundle") + case .checkingCapabilities: + return L10n.string("readiness.state.checking_capabilities") + case .validatingInstall: + return L10n.string("readiness.state.validating_install") + default: + return "" + } + } +} + +private struct AppReadinessBlockedView: View { + @ObservedObject var store: AppReadinessStore + let showDiagnostics: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label(L10n.string("readiness.blocked.title"), systemImage: "exclamationmark.octagon") + .font(.title2.weight(.semibold)) + .foregroundStyle(.red) + if case .blocked(let issue) = store.state { + Text(issue.message) + Text(issue.recovery) + .foregroundStyle(.secondary) + } + HStack { + Button { + store.start() + } label: { + Label(L10n.string("recovery.action.retry"), systemImage: "arrow.clockwise") + } + .disabled(!store.canRetry) + + Button { + showDiagnostics() + } label: { + Label(L10n.string("toolbar.diagnostics"), systemImage: "wrench.and.screwdriver") + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +private struct AppDiagnosticsView: View { + @ObservedObject var store: AppReadinessStore + let events: [BackendEvent] + @Binding var helperPath: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(L10n.string("diagnostics.title")) + .font(.title2.weight(.semibold)) + Spacer() + Button(L10n.string("action.done")) { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + + TextField(L10n.string("field.helper"), text: $helperPath) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + Text(L10n.string("diagnostics.state")).foregroundStyle(.secondary) + Text(store.state.kind.title) + } + if let capabilities = store.capabilities { + GridRow { + Text(L10n.string("diagnostics.helper")).foregroundStyle(.secondary) + Text(capabilities.helperVersion) + } + GridRow { + Text(L10n.string("diagnostics.distribution")).foregroundStyle(.secondary) + Text(capabilities.distributionRoot) + .lineLimit(1) + .truncationMode(.middle) + } + } + if let validation = store.validation { + GridRow { + Text(L10n.string("diagnostics.validation")).foregroundStyle(.secondary) + Text(validation.summary) + } + } + } + .font(.caption) + + if !store.issues.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("diagnostics.runtime_issues")) + .font(.headline) + ForEach(store.issues) { issue in + VStack(alignment: .leading, spacing: 2) { + Text(issue.message) + Text(issue.recovery) + .foregroundStyle(.secondary) + } + .font(.caption) + } + } + } + + Text(L10n.string("diagnostics.backend_events")) + .font(.headline) + EventList(events: events) + } + .padding() + .frame(minWidth: 720, minHeight: 520) + } +} + +private struct EventList: View { + let events: [BackendEvent] + + var body: some View { + List(events) { event in + VStack(alignment: .leading, spacing: 4) { + Text(event.summary) + .font(.body) + if let payload = event.payload, event.type == "result" { + Text(payload.displayText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(6) + } + } + .padding(.vertical, 3) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift new file mode 100644 index 0000000..5fc4486 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift @@ -0,0 +1,119 @@ +import Foundation + +struct PresentationRow: Equatable, Identifiable { + var id: String { + "\(label):\(value)" + } + + let label: String + let value: String +} + +struct DeployPlanPresentation: Equatable { + let title: String + let summaryRows: [PresentationRow] + let advancedRows: [PresentationRow] + let warnings: [String] + + init(plan: DeployPlanPayload, profile: DeviceProfile, hostWarning: HostCompatibilityWarning? = nil) { + self.title = plan.netbsd4 + ? L10n.string("deploy.presentation.title.netbsd4") + : L10n.string("deploy.presentation.title.standard") + self.summaryRows = [ + PresentationRow(label: L10n.string("deploy.presentation.row.target"), value: profile.title), + PresentationRow(label: L10n.string("deploy.presentation.row.host"), value: plan.host), + PresentationRow(label: L10n.string("deploy.presentation.row.payload"), value: plan.payloadFamily ?? profile.payloadFamily ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("deploy.presentation.row.disk_location"), value: plan.volumeRoot ?? plan.payloadDir), + PresentationRow(label: L10n.string("deploy.presentation.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), + PresentationRow( + label: L10n.string("deploy.presentation.row.expected_changes"), + value: L10n.format("deploy.presentation.expected_changes", plan.uploads.count, plan.postUploadActions.count) + ) + ] + self.advancedRows = [ + PresentationRow(label: L10n.string("deploy.presentation.row.payload_directory"), value: plan.payloadDir), + PresentationRow(label: L10n.string("deploy.presentation.row.pre_upload_actions"), value: "\(plan.preUploadActions.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.post_upload_actions"), value: "\(plan.postUploadActions.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.activation_actions"), value: "\(plan.activationActions.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.post_install_checks"), value: plan.postDeployChecks.map(\.description).joined(separator: ", ")) + ] + var warnings: [String] = [] + if plan.netbsd4 { + warnings.append(L10n.string("deploy.presentation.warning.netbsd4_activation")) + } + if let hostWarning { + warnings.append(hostWarning.message) + } + self.warnings = warnings + } +} + +struct CheckupPresentation: Equatable { + let headline: String + let summaryRows: [PresentationRow] + let groups: [DoctorCheckGroup] + + init(summary: DoctorSummary, state: DoctorWorkflowState) { + switch state { + case .passed: + self.headline = L10n.string("checkup.presentation.headline.passed") + case .warning: + self.headline = L10n.string("checkup.presentation.headline.warning") + case .failed: + self.headline = L10n.string("checkup.presentation.headline.failed") + case .runFailed: + self.headline = L10n.string("checkup.presentation.headline.run_failed") + case .idle: + self.headline = L10n.string("checkup.presentation.headline.idle") + case .running: + self.headline = L10n.string("checkup.presentation.headline.running") + } + self.summaryRows = [ + PresentationRow(label: L10n.string("checkup.presentation.row.pass"), value: "\(summary.passCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.warning"), value: "\(summary.warnCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.fail"), value: "\(summary.failCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.info"), value: "\(summary.infoCount)") + ] + self.groups = summary.groups + } +} + +struct MaintenanceWorkflowPresentation: Equatable { + let title: String + let subtitle: String + let primaryAction: String + let risk: String + + static func presentation(for workflow: MaintenanceWorkflow) -> MaintenanceWorkflowPresentation { + switch workflow { + case .activate: + return MaintenanceWorkflowPresentation( + title: L10n.string("maintenance.presentation.activate.title"), + subtitle: L10n.string("maintenance.presentation.activate.subtitle"), + primaryAction: L10n.string("maintenance.presentation.activate.primary_action"), + risk: L10n.string("maintenance.presentation.risk.remote_write") + ) + case .uninstall: + return MaintenanceWorkflowPresentation( + title: L10n.string("maintenance.presentation.uninstall.title"), + subtitle: L10n.string("maintenance.presentation.uninstall.subtitle"), + primaryAction: L10n.string("maintenance.presentation.uninstall.primary_action"), + risk: L10n.string("maintenance.presentation.risk.destructive") + ) + case .fsck: + return MaintenanceWorkflowPresentation( + title: L10n.string("maintenance.presentation.fsck.title"), + subtitle: L10n.string("maintenance.presentation.fsck.subtitle"), + primaryAction: L10n.string("maintenance.presentation.fsck.primary_action"), + risk: L10n.string("maintenance.presentation.risk.destructive") + ) + case .repairXattrs: + return MaintenanceWorkflowPresentation( + title: L10n.string("maintenance.presentation.repair_xattrs.title"), + subtitle: L10n.string("maintenance.presentation.repair_xattrs.subtitle"), + primaryAction: L10n.string("maintenance.presentation.repair_xattrs.primary_action"), + risk: L10n.string("maintenance.presentation.risk.local_destructive") + ) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift new file mode 100644 index 0000000..333b9f1 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift @@ -0,0 +1,288 @@ +import Combine +import Foundation +#if canImport(AppKit) +import AppKit +#endif + +enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { + case overview + case install + case checkup + case maintenance + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: + return L10n.string("dashboard.tab.overview") + case .install: + return L10n.string("dashboard.tab.install") + case .checkup: + return L10n.string("dashboard.tab.checkup") + case .maintenance: + return L10n.string("dashboard.tab.maintenance") + case .advanced: + return L10n.string("dashboard.tab.advanced") + } + } +} + +@MainActor +final class DashboardStore: ObservableObject { + @Published var selectedTab: DeviceDashboardTab = .overview + @Published private(set) var passwordError: String? + + let appStore: AppStore + var deployStore: DeployWorkflowStore + var doctorStore: DoctorStore + var maintenanceStore: MaintenanceStore + + private var activeCheckupOperation: ActiveOperation? + private var activeDeployOperation: ActiveOperation? + private var cancellables: Set = [] + + init(appStore: AppStore) { + self.appStore = appStore + self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator) + self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator) + self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator) + forwardChildChanges() + observeSnapshots() + } + + func summary(for profile: DeviceProfile) -> DeviceDashboardSummary { + appStore.dashboardSummary(for: profile) + } + + func runCheckup(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + passwordError = L10n.string("password.error.required") + return + } + passwordError = nil + selectedTab = .checkup + if case .started(let operation) = doctorStore.runDoctor(password: password, profile: profile) { + activeCheckupOperation = operation + } + } + + func runInstallPlan(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + passwordError = L10n.string("password.error.required") + return + } + passwordError = nil + selectedTab = .install + deployStore.nbnsEnabled = profile.settings.nbnsEnabled + deployStore.debugLogging = profile.settings.debugLogging + deployStore.mountWait = String(profile.settings.mountWaitSeconds) + _ = deployStore.runPlan(password: password, profile: profile) + } + + func runInstall(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + passwordError = L10n.string("password.error.required") + return + } + passwordError = nil + selectedTab = .install + if case .started(let operation) = deployStore.runDeploy(password: password, profile: profile) { + activeDeployOperation = operation + } + } + + func maintenancePassword(for profile: DeviceProfile) -> String? { + guard let password = appStore.password(for: profile) else { + passwordError = L10n.string("password.error.required") + return nil + } + passwordError = nil + selectedTab = .maintenance + return password + } + + @discardableResult + func handleRecoveryAction(_ action: RecoveryAction, error: BackendErrorViewModel, profile: DeviceProfile) -> Bool { + switch action.kind { + case .retry: + return retry(error: error, profile: profile) + case .runCheckup: + runCheckup(profile: profile) + return true + case .installSMB: + runInstallPlan(profile: profile) + return true + case .startSMB: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .activate + return true + case .uninstall: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .uninstall + return true + case .diskRepair: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .fsck + return true + case .metadataRepair: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .repairXattrs + return true + case .replacePassword: + selectedTab = .overview + return true + case .openFinder: + openSMBAddress(for: profile) + return true + case .diagnostics, .copyDiagnostics, .generic: + return false + } + } + + private func observeSnapshots() { + doctorStore.$state + .sink { [weak self] state in + Task { @MainActor in + self?.updateCheckupSnapshot(state: state) + } + } + .store(in: &cancellables) + doctorStore.$passwordInvalidProfileID + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor in + self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + } + } + .store(in: &cancellables) + deployStore.$state + .sink { [weak self] state in + Task { @MainActor in + self?.updateDeploySnapshot(state: state) + } + } + .store(in: &cancellables) + deployStore.$passwordInvalidProfileID + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor in + self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + } + } + .store(in: &cancellables) + maintenanceStore.$passwordInvalidProfileID + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor in + self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + } + } + .store(in: &cancellables) + } + + private func retry(error: BackendErrorViewModel, profile: DeviceProfile) -> Bool { + switch error.operation { + case "doctor": + runCheckup(profile: profile) + return true + case "deploy": + runInstallPlan(profile: profile) + return true + case "activate": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .activate + return true + case "uninstall": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .uninstall + return true + case "fsck": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .fsck + return true + case "repair-xattrs": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .repairXattrs + return true + default: + return false + } + } + + private func openSMBAddress(for profile: DeviceProfile) { + let host = profile.host + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"^.*@"#, with: "", options: .regularExpression) + guard !host.isEmpty, let url = URL(string: "smb://\(host)") else { + return + } + #if canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + } + + private func forwardChildChanges() { + deployStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + doctorStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + maintenanceStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + private func updateCheckupSnapshot(state: DoctorWorkflowState) { + guard [.passed, .warning, .failed, .runFailed].contains(state) else { + return + } + defer { + activeCheckupOperation = nil + } + guard [.passed, .warning, .failed].contains(state), + let profileID = activeCheckupOperation?.profileID, + let summary = doctorStore.summary else { + return + } + appStore.deviceRegistry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(), + state: state, + passCount: summary.passCount, + warnCount: summary.warnCount, + failCount: summary.failCount, + summary: L10n.format("summary.checkup_counts", summary.passCount, summary.warnCount, summary.failCount) + ), for: profileID) + } + + private func updateDeploySnapshot(state: DeployWorkflowState) { + guard [.deployed, .deployFailed].contains(state) else { + return + } + defer { + activeDeployOperation = nil + } + guard state == .deployed, + let profileID = activeDeployOperation?.profileID, + let profile = appStore.deviceRegistry.profile(id: profileID), + let result = deployStore.result else { + return + } + appStore.deviceRegistry.updateDeploy(DeviceDeploySnapshot( + deployedAt: Date(), + state: state, + payloadFamily: deployStore.plan?.payloadFamily ?? profile.payloadFamily, + rebootRequested: result.rebootRequested, + verified: result.verified, + summary: result.message ?? L10n.string("deploy.result.default_message") + ), for: profile.id) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift new file mode 100644 index 0000000..c625e06 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift @@ -0,0 +1,201 @@ +import SwiftUI + +struct DeployView: View { + @ObservedObject var store: DeployWorkflowStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.deploy")) + .font(.title2.weight(.semibold)) + + HStack { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.nbnsEnabled) + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.debugLogging) + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + } + + HStack { + Button { + store.runPlan(password: password) + } label: { + Label(L10n.string("button.plan_deploy"), systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + + Button { + store.runDeploy(password: password) + } label: { + Label(L10n.string("button.deploy"), systemImage: "square.and.arrow.up") + } + .disabled(!store.canDeploy) + + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let plan = store.plan { + DeployPlanSummaryView(plan: plan, stale: store.state == .planStale) + } + + if let result = store.result { + DeployResultSummaryView(result: result) + } + + if let error = store.error { + DeployErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusIcon: String { + switch store.state { + case .idle: + return "circle" + case .planning, .deploying: + return "hourglass" + case .planReady, .deployed: + return "checkmark.circle" + case .planStale, .awaitingConfirmation: + return "exclamationmark.circle" + case .planFailed, .deployFailed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .planReady, .deployed: + return .green + case .planStale, .awaitingConfirmation: + return .orange + case .planFailed, .deployFailed: + return .red + default: + return .secondary + } + } +} + +private struct DeployPlanSummaryView: View { + let plan: DeployPlanPayload + let stale: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(stale ? "Deploy Plan Stale" : "Deploy Plan") + .font(.body.weight(.medium)) + .foregroundStyle(stale ? .orange : .primary) + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Host").foregroundStyle(.secondary) + Text(plan.host) + } + GridRow { + Text("Payload").foregroundStyle(.secondary) + Text(plan.payloadFamily ?? "unknown") + } + GridRow { + Text("NetBSD4").foregroundStyle(.secondary) + Text(plan.netbsd4 ? "yes" : "no") + } + GridRow { + Text("Reboot").foregroundStyle(.secondary) + Text(plan.requiresReboot ? "required" : "not required") + } + GridRow { + Text("Payload Dir").foregroundStyle(.secondary) + Text(plan.payloadDir) + .lineLimit(1) + .truncationMode(.middle) + } + GridRow { + Text("Actions").foregroundStyle(.secondary) + Text("\(plan.preUploadActions.count) pre, \(plan.uploads.count) uploads, \(plan.postUploadActions.count) post, \(plan.activationActions.count) activation") + } + } + if !plan.postDeployChecks.isEmpty { + Text("Post-deploy checks: \(plan.postDeployChecks.map(\.description).joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .font(.caption) + } +} + +private struct DeployResultSummaryView: View { + let result: DeployResultPayload + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Deploy Result") + .font(.body.weight(.medium)) + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Payload Dir").foregroundStyle(.secondary) + Text(result.payloadDir) + .lineLimit(1) + .truncationMode(.middle) + } + GridRow { + Text("Reboot Requested").foregroundStyle(.secondary) + Text(result.rebootRequested == true ? "yes" : "no") + } + GridRow { + Text("Waited").foregroundStyle(.secondary) + Text(result.waited == true ? "yes" : "no") + } + GridRow { + Text("Verified").foregroundStyle(.secondary) + Text(result.verified == true ? "yes" : "no") + } + } + if let message = result.message { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .font(.caption) + } +} + +private struct DeployErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift new file mode 100644 index 0000000..9a9afd3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift @@ -0,0 +1,399 @@ +import Combine +import Foundation + +struct DeployOptions: Equatable { + let nbnsEnabled: Bool + let noReboot: Bool + let noWait: Bool + let debugLogging: Bool + let mountWait: Int +} + +enum DeployWorkflowState: String, CaseIterable, Equatable, Codable { + case idle + case planning + case planReady + case planStale + case planFailed + case deploying + case awaitingConfirmation + case deployed + case deployFailed + + var title: String { + switch self { + case .idle: + return "Idle" + case .planning: + return "Planning" + case .planReady: + return "Plan Ready" + case .planStale: + return "Plan Stale" + case .planFailed: + return "Plan Failed" + case .deploying: + return "Deploying" + case .awaitingConfirmation: + return "Awaiting Confirmation" + case .deployed: + return "Deployed" + case .deployFailed: + return "Deploy Failed" + } + } +} + +@MainActor +final class DeployWorkflowStore: ObservableObject { + @Published var nbnsEnabled = true { + didSet { markPlanStaleIfNeeded() } + } + @Published var noReboot = false { + didSet { markPlanStaleIfNeeded() } + } + @Published var noWait = false { + didSet { markPlanStaleIfNeeded() } + } + @Published var debugLogging = false { + didSet { markPlanStaleIfNeeded() } + } + @Published var mountWait = "30" { + didSet { markPlanStaleIfNeeded() } + } + + @Published private(set) var state: DeployWorkflowState = .idle + @Published private(set) var plan: DeployPlanPayload? + @Published private(set) var result: DeployResultPayload? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var plannedOptions: DeployOptions? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + + let backend: BackendClient + private let coordinator: OperationCoordinator? + + private var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + self.coordinator = nil + observeBackend(backend) + } + + init(coordinator: OperationCoordinator) { + self.backend = coordinator.backend + self.coordinator = coordinator + observeBackend(coordinator.backend) + } + + private func observeBackend(_ backend: BackendClient) { + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var mountWaitValue: Int? { + nonNegativeInteger(mountWait) + } + + var canDeploy: Bool { + !backend.isRunning && state == .planReady && plan != nil && currentOptions == plannedOptions + } + + @discardableResult + func runPlan(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard let options = currentOptions else { + failLocally(state: .planFailed, message: "Mount wait must be a non-negative integer.") + return .rejected("Mount wait must be a non-negative integer.") + } + guard !backend.isRunning else { + rejectRun(state: .planFailed, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } + backend.clear() + let start = run( + operation: "deploy", + params: OperationParams.deployPlan( + noReboot: options.noReboot, + noWait: options.noWait, + nbnsEnabled: options.nbnsEnabled, + debugLogging: options.debugLogging, + mountWait: Double(options.mountWait), + password: password + ), + profile: profile + ) + guard case .started(let operation) = start else { + rejectRun(state: .planFailed, message: start.rejectionMessage ?? "Operation could not start.") + return start + } + lastProcessedEventCount = 0 + activeOperation = operation + state = .planning + plan = nil + result = nil + error = nil + currentStage = nil + plannedOptions = options + passwordInvalidProfileID = nil + return start + } + + @discardableResult + func runDeploy(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard let options = plannedOptions, plan != nil, currentOptions == options else { + state = .planStale + error = BackendErrorViewModel( + operation: "deploy", + code: "plan_stale", + message: "Review and regenerate the deploy plan before deploying." + ) + return .rejected("Review and regenerate the deploy plan before deploying.") + } + guard state == .planReady else { + return .rejected("Deploy plan is not ready.") + } + guard !backend.isRunning else { + rejectRun(state: .deployFailed, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } + backend.clear() + let start = run( + operation: "deploy", + params: OperationParams.deployRun( + noReboot: options.noReboot, + noWait: options.noWait, + nbnsEnabled: options.nbnsEnabled, + debugLogging: options.debugLogging, + mountWait: Double(options.mountWait), + password: password + ), + profile: profile + ) + guard case .started(let operation) = start else { + rejectRun(state: .deployFailed, message: start.rejectionMessage ?? "Operation could not start.") + return start + } + lastProcessedEventCount = 0 + activeOperation = operation + state = .deploying + result = nil + error = nil + currentStage = nil + passwordInvalidProfileID = nil + return start + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + state = .idle + plan = nil + result = nil + error = nil + currentStage = nil + plannedOptions = nil + passwordInvalidProfileID = nil + activeOperation = nil + } + + func cancel() { + backend.cancel() + } + + private var currentOptions: DeployOptions? { + guard let mountWaitValue else { + return nil + } + return DeployOptions( + nbnsEnabled: nbnsEnabled, + noReboot: noReboot, + noWait: noWait, + debugLogging: debugLogging, + mountWait: mountWaitValue + ) + } + + private func markPlanStaleIfNeeded() { + guard state == .planReady, currentOptions != plannedOptions else { + return + } + state = .planStale + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "deploy" else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .deploying + } + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFailureResult(event) + return + } + + switch state { + case .planning: + applyPlanResult(event) + case .deploying, .awaitingConfirmation: + applyDeployResult(event) + default: + break + } + } + + private func applyPlanResult(_ event: BackendEvent) { + do { + plan = try event.decodePayload(DeployPlanPayload.self) + result = nil + error = nil + state = .planReady + activeOperation = nil + } catch { + failContract(state: .planFailed, error: error) + } + } + + private func applyDeployResult(_ event: BackendEvent) { + do { + result = try event.decodePayload(DeployResultPayload.self) + error = nil + state = .deployed + activeOperation = nil + } catch { + failContract(state: .deployFailed, error: error) + } + } + + private func applyError(_ event: BackendEvent) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation?.profileID + } + error = BackendErrorViewModel(event: event) + state = state == .planning ? .planFailed : .deployFailed + activeOperation = nil + } + + private func applyFailureResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: "deploy", + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + state = state == .planning ? .planFailed : .deployFailed + activeOperation = nil + } + + private func failContract(state: DeployWorkflowState, error: Error) { + self.error = BackendErrorViewModel( + operation: "deploy", + code: "contract_decode_failed", + message: error.localizedDescription + ) + self.state = state + activeOperation = nil + } + + private func failLocally(state: DeployWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: "deploy", + code: "validation_failed", + message: message + ) + currentStage = nil + self.state = state + activeOperation = nil + } + + private func rejectRun(state: DeployWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: "deploy", + code: "operation_rejected", + message: message + ) + currentStage = nil + self.state = state + activeOperation = nil + } + + private func nonNegativeInteger(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), value >= 0 else { + return nil + } + return value + } + + private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { + if let coordinator { + return coordinator.run(operation: operation, params: params, profile: profile) + } else { + guard !backend.isRunning else { + return .rejected("Another operation is already running.") + } + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run(operation: operation, params: params, context: profile?.runtimeContext) + return .started(activeOperation) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift new file mode 100644 index 0000000..34f63ed --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift @@ -0,0 +1,196 @@ +import Foundation + +public struct DeviceRuntimeContext: Equatable, Sendable { + public let profileID: String + public let configURL: URL + + public init(profileID: String, configURL: URL) { + self.profileID = profileID + self.configURL = configURL + } +} + +enum DevicePasswordState: String, Codable, CaseIterable, Equatable { + case unknown + case available + case missing + case invalid + case keychainUnavailable + + var title: String { + switch self { + case .unknown: + return L10n.string("password_state.unknown") + case .available: + return L10n.string("password_state.available") + case .missing: + return L10n.string("password_state.missing") + case .invalid: + return L10n.string("password_state.invalid") + case .keychainUnavailable: + return L10n.string("password_state.keychain_unavailable") + } + } +} + +struct DeviceProfileSettings: Codable, Equatable { + var nbnsEnabled: Bool + var debugLogging: Bool + var mountWaitSeconds: Int + + static let `default` = DeviceProfileSettings( + nbnsEnabled: true, + debugLogging: false, + mountWaitSeconds: 30 + ) +} + +struct DeviceCheckupSnapshot: Codable, Equatable { + var checkedAt: Date + var state: DoctorWorkflowState + var passCount: Int + var warnCount: Int + var failCount: Int + var summary: String +} + +struct DeviceDeploySnapshot: Codable, Equatable { + var deployedAt: Date + var state: DeployWorkflowState + var payloadFamily: String? + var rebootRequested: Bool? + var verified: Bool? + var summary: String +} + +struct DeviceProfile: Codable, Equatable, Identifiable { + typealias ID = String + + var id: ID + var displayName: String + var host: String + var bonjourName: String? + var bonjourFullname: String? + var hostname: String? + var addresses: [String] + var syap: String? + var model: String? + var osName: String? + var osRelease: String? + var arch: String? + var elfEndianness: String? + var payloadFamily: String? + var deviceGeneration: String? + var configPath: String + var keychainAccount: String + var createdAt: Date + var updatedAt: Date + var lastCheckup: DeviceCheckupSnapshot? + var lastDeploy: DeviceDeploySnapshot? + var settings: DeviceProfileSettings + var passwordState: DevicePasswordState + + var title: String { + let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + return trimmedName + } + if let bonjourName = bonjourName?.trimmingCharacters(in: .whitespacesAndNewlines), !bonjourName.isEmpty { + return bonjourName + } + if let model = model?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { + return model + } + return normalizedHost.isEmpty ? "Time Capsule" : normalizedHost + } + + var normalizedHost: String { + Self.normalizedHost(host) + } + + var runtimeContext: DeviceRuntimeContext { + DeviceRuntimeContext(profileID: id, configURL: URL(fileURLWithPath: configPath)) + } + + static func configURL(for id: ID, applicationSupportURL: URL) -> URL { + applicationSupportURL + .appendingPathComponent("Devices", isDirectory: true) + .appendingPathComponent(id, isDirectory: true) + .appendingPathComponent(".env") + } + + static func normalizedHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutUser = trimmed.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false).last.map(String.init) ?? trimmed + return withoutUser + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + } + + static func matches(_ left: DeviceProfile, _ right: DeviceProfile) -> Bool { + if let leftFullname = normalizedOptional(left.bonjourFullname), + let rightFullname = normalizedOptional(right.bonjourFullname), + leftFullname == rightFullname { + return true + } + let leftHost = left.normalizedHost + let rightHost = right.normalizedHost + return !leftHost.isEmpty && leftHost == rightHost + } + + static func make( + id: ID = UUID().uuidString.lowercased(), + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + applicationSupportURL: URL, + existing: DeviceProfile? = nil, + date: Date = Date() + ) -> DeviceProfile { + let resolvedID = existing?.id ?? id + let compatibility = configuredDevice.compatibility + return DeviceProfile( + id: resolvedID, + displayName: existing?.displayName ?? discoveredDevice?.name ?? configuredDevice.model ?? "Time Capsule", + host: configuredDevice.host, + bonjourName: discoveredDevice?.name ?? existing?.bonjourName, + bonjourFullname: discoveredDevice?.fullname ?? existing?.bonjourFullname, + hostname: discoveredDevice?.hostname ?? existing?.hostname, + addresses: discoveredDevice?.addresses ?? existing?.addresses ?? [], + syap: configuredDevice.syap ?? existing?.syap, + model: configuredDevice.model ?? existing?.model, + osName: compatibility?.osName ?? existing?.osName, + osRelease: compatibility?.osRelease ?? existing?.osRelease, + arch: compatibility?.arch ?? existing?.arch, + elfEndianness: compatibility?.elfEndianness ?? existing?.elfEndianness, + payloadFamily: compatibility?.payloadFamily ?? existing?.payloadFamily, + deviceGeneration: compatibility?.deviceGeneration ?? existing?.deviceGeneration, + configPath: Self.configURL(for: resolvedID, applicationSupportURL: applicationSupportURL).path, + keychainAccount: resolvedID, + createdAt: existing?.createdAt ?? date, + updatedAt: date, + lastCheckup: existing?.lastCheckup, + lastDeploy: existing?.lastDeploy, + settings: existing?.settings ?? .default, + passwordState: existing?.passwordState ?? .unknown + ) + } + + private static func normalizedOptional(_ value: String?) -> String? { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !normalized.isEmpty else { + return nil + } + return normalized + } +} + +extension DiscoveredDevice { + var fullname: String? { + guard case .object(let object) = rawRecord, + case .string(let value)? = object["fullname"] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift new file mode 100644 index 0000000..b67194f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift @@ -0,0 +1,32 @@ +import Foundation + +struct DeviceProfileTraits: Equatable { + let isNetBSD4: Bool + let isNetBSD6: Bool + let isSupported: Bool + let supportsFlashBootHook: Bool + let needsActivationAfterReboot: Bool +} + +extension DeviceProfile { + var traits: DeviceProfileTraits { + let isNetBSD4 = payloadFamily?.localizedCaseInsensitiveContains("netbsd4") == true + || osRelease?.hasPrefix("4.") == true + let isNetBSD6 = payloadFamily?.localizedCaseInsensitiveContains("netbsd6") == true + || osRelease?.hasPrefix("6.") == true + let unsupportedValues = [ + payloadFamily, + deviceGeneration + ] + let isSupported = !unsupportedValues.contains { value in + value?.localizedCaseInsensitiveContains("unsupported") == true + } + return DeviceProfileTraits( + isNetBSD4: isNetBSD4, + isNetBSD6: isNetBSD6, + isSupported: isSupported, + supportsFlashBootHook: isNetBSD4, + needsActivationAfterReboot: isNetBSD4 + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift new file mode 100644 index 0000000..7dc28c6 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift @@ -0,0 +1,325 @@ +import Foundation + +enum DeviceRegistryState: String, CaseIterable, Equatable { + case idle + case loading + case empty + case loaded + case saving + case failed +} + +enum DeviceRegistryError: Error, Equatable, LocalizedError { + case applicationSupportUnavailable + case corruptRegistry(String) + case profileNotFound(DeviceProfile.ID) + case duplicateProfile(field: String, value: String, conflictingProfileID: DeviceProfile.ID) + case io(String) + + var errorDescription: String? { + switch self { + case .applicationSupportUnavailable: + return "Application Support is unavailable." + case .corruptRegistry(let message): + return "Saved devices could not be read: \(message)" + case .profileNotFound(let id): + return "Saved device \(id) could not be found." + case .duplicateProfile(let field, let value, let conflictingProfileID): + return "Another saved device already uses \(field) \(value): \(conflictingProfileID)." + case .io(let message): + return message + } + } +} + +@MainActor +final class DeviceRegistryStore: ObservableObject { + @Published private(set) var state: DeviceRegistryState = .idle + @Published private(set) var profiles: [DeviceProfile] = [] + @Published private(set) var error: DeviceRegistryError? + + let applicationSupportURL: URL + let registryURL: URL + let devicesDirectoryURL: URL + + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let now: () -> Date + + convenience init() { + let appSupport = BundleLayout.applicationSupportDirectory() ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/TimeCapsuleSMB", isDirectory: true) + self.init(applicationSupportURL: appSupport) + } + + init( + applicationSupportURL: URL, + fileManager: FileManager = .default, + now: @escaping () -> Date = Date.init + ) { + self.applicationSupportURL = applicationSupportURL + self.registryURL = applicationSupportURL.appendingPathComponent("devices.json") + self.devicesDirectoryURL = applicationSupportURL.appendingPathComponent("Devices", isDirectory: true) + self.fileManager = fileManager + self.now = now + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + } + + var isEmpty: Bool { + profiles.isEmpty + } + + func load() { + state = .loading + error = nil + do { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + guard fileManager.fileExists(atPath: registryURL.path) else { + profiles = [] + state = .empty + return + } + let data = try Data(contentsOf: registryURL) + profiles = try decoder.decode([DeviceProfile].self, from: data) + .sorted { $0.updatedAt > $1.updatedAt } + state = profiles.isEmpty ? .empty : .loaded + } catch let decoding as DecodingError { + profiles = [] + error = .corruptRegistry(String(describing: decoding)) + state = .failed + } catch { + profiles = [] + self.error = .io(error.localizedDescription) + state = .failed + } + } + + @discardableResult + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + ) throws -> DeviceProfile { + let profile = makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID + ) + return try saveProfileMergingDuplicates(profile) + } + + func makeConfiguredDeviceProfile( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + ) -> DeviceProfile { + let existing = matchingProfile(host: configuredDevice.host, bonjourFullname: discoveredDevice?.fullname) + var profile = DeviceProfile.make( + id: preferredID, + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + applicationSupportURL: applicationSupportURL, + existing: existing, + date: now() + ) + profile.passwordState = passwordState + return profile + } + + @discardableResult + func saveProfileMergingDuplicates(_ profile: DeviceProfile) throws -> DeviceProfile { + state = .saving + error = nil + do { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: URL(fileURLWithPath: profile.configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var updated = profiles.filter { !DeviceProfile.matches($0, profile) && $0.id != profile.id } + updated.append(profile) + updated = updated.sorted { $0.updatedAt > $1.updatedAt } + try persist(updated) + profiles = updated + state = profiles.isEmpty ? .empty : .loaded + return profile + } catch { + self.error = .io(error.localizedDescription) + state = .failed + throw error + } + } + + func discardArtifacts(for profile: DeviceProfile) { + let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() + let configDirectoryPath = configDirectory.standardizedFileURL.path + let devicesDirectoryPath = devicesDirectoryURL.standardizedFileURL.path + guard configDirectoryPath.hasPrefix(devicesDirectoryPath + "/") else { + return + } + try? fileManager.removeItem(at: configDirectory) + } + + @discardableResult + func updateProfile(_ profile: DeviceProfile) throws -> DeviceProfile { + guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { + let error = DeviceRegistryError.profileNotFound(profile.id) + self.error = error + throw error + } + if let conflict = duplicateConflict(for: profile, excluding: profile.id) { + self.error = conflict + throw conflict + } + state = .saving + error = nil + var updated = profile + updated.updatedAt = now() + do { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var updatedProfiles = profiles + updatedProfiles[index] = updated + updatedProfiles = updatedProfiles.sorted { $0.updatedAt > $1.updatedAt } + try persist(updatedProfiles) + profiles = updatedProfiles + state = profiles.isEmpty ? .empty : .loaded + return updated + } catch { + self.error = .io(error.localizedDescription) + state = .failed + throw error + } + } + + func delete(_ profile: DeviceProfile) throws { + state = .saving + error = nil + do { + let updatedProfiles = profiles.filter { $0.id != profile.id } + let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() + try persist(updatedProfiles) + profiles = updatedProfiles + if fileManager.fileExists(atPath: configDirectory.path) { + try fileManager.removeItem(at: configDirectory) + } + state = profiles.isEmpty ? .empty : .loaded + } catch { + self.error = .io(error.localizedDescription) + state = .failed + throw error + } + } + + func updatePasswordState(_ state: DevicePasswordState, for profileID: DeviceProfile.ID) { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return + } + guard profiles[index].passwordState != state else { + return + } + var updatedProfiles = profiles + updatedProfiles[index].passwordState = state + updatedProfiles[index].updatedAt = now() + if (try? persist(updatedProfiles)) != nil { + profiles = updatedProfiles + } + } + + func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return + } + var updatedProfiles = profiles + updatedProfiles[index].lastCheckup = snapshot + updatedProfiles[index].updatedAt = now() + if (try? persist(updatedProfiles)) != nil { + profiles = updatedProfiles + } + } + + func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return + } + var updatedProfiles = profiles + updatedProfiles[index].lastDeploy = snapshot + updatedProfiles[index].updatedAt = now() + if (try? persist(updatedProfiles)) != nil { + profiles = updatedProfiles + } + } + + func profile(id: DeviceProfile.ID?) -> DeviceProfile? { + guard let id else { + return nil + } + return profiles.first { $0.id == id } + } + + func matchingProfile(host: String, bonjourFullname: String?) -> DeviceProfile? { + let normalizedFullname = bonjourFullname?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let normalizedFullname, !normalizedFullname.isEmpty, + let profile = profiles.first(where: { $0.bonjourFullname?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalizedFullname }) { + return profile + } + let normalizedHost = DeviceProfile.normalizedHost(host) + guard !normalizedHost.isEmpty else { + return nil + } + return profiles.first { $0.normalizedHost == normalizedHost } + } + + private func duplicateConflict(for profile: DeviceProfile, excluding profileID: DeviceProfile.ID) -> DeviceRegistryError? { + if let normalizedFullname = normalizedBonjourFullname(profile.bonjourFullname), + let conflicting = profiles.first(where: { + $0.id != profileID && normalizedBonjourFullname($0.bonjourFullname) == normalizedFullname + }) { + return .duplicateProfile( + field: "Bonjour fullname", + value: normalizedFullname, + conflictingProfileID: conflicting.id + ) + } + + let normalizedHost = profile.normalizedHost + if !normalizedHost.isEmpty, + let conflicting = profiles.first(where: { $0.id != profileID && $0.normalizedHost == normalizedHost }) { + return .duplicateProfile( + field: "host", + value: normalizedHost, + conflictingProfileID: conflicting.id + ) + } + return nil + } + + private func normalizedBonjourFullname(_ value: String?) -> String? { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !normalized.isEmpty else { + return nil + } + return normalized + } + + private func persist(_ profiles: [DeviceProfile]) throws { + try fileManager.createDirectory(at: applicationSupportURL, withIntermediateDirectories: true) + let data = try encoder.encode(profiles) + try data.write(to: registryURL, options: [.atomic]) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift new file mode 100644 index 0000000..6500043 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift @@ -0,0 +1,170 @@ +import Foundation + +enum DeviceDisplayStatus: String, CaseIterable, Equatable, Identifiable { + case unchecked + case passwordNeeded + case passwordInvalid + case keychainUnavailable + case checking + case installing + case maintaining + case readyToInstall + case healthy + case warning + case failed + case activationNeeded + case removed + case offline + case unsupported + + var id: String { rawValue } + + var title: String { + switch self { + case .unchecked: + return L10n.string("status.unchecked") + case .passwordNeeded: + return L10n.string("status.password_needed") + case .passwordInvalid: + return L10n.string("status.password_invalid") + case .keychainUnavailable: + return L10n.string("status.keychain_unavailable") + case .checking: + return L10n.string("status.checking") + case .installing: + return L10n.string("status.installing") + case .maintaining: + return L10n.string("status.maintenance") + case .readyToInstall: + return L10n.string("status.ready_to_install") + case .healthy: + return L10n.string("status.healthy") + case .warning: + return L10n.string("status.warning") + case .failed: + return L10n.string("status.failed") + case .activationNeeded: + return L10n.string("status.activation_needed") + case .removed: + return L10n.string("status.removed") + case .offline: + return L10n.string("status.offline") + case .unsupported: + return L10n.string("status.unsupported") + } + } + + var systemImage: String { + switch self { + case .unchecked: + return "circle" + case .passwordNeeded, .passwordInvalid, .keychainUnavailable: + return "key" + case .checking: + return "stethoscope" + case .installing: + return "square.and.arrow.up" + case .maintaining: + return "wrench.and.screwdriver" + case .readyToInstall: + return "arrow.down.circle" + case .healthy: + return "checkmark.circle" + case .warning, .activationNeeded: + return "exclamationmark.triangle" + case .failed, .offline, .unsupported: + return "xmark.octagon" + case .removed: + return "trash" + } + } +} + +enum DeviceStatusPolicy { + static func status( + for profile: DeviceProfile, + passwordState: DevicePasswordState, + activeOperation: ActiveOperation? + ) -> DeviceDisplayStatus { + if let activeOperation, activeOperation.profileID == profile.id { + switch activeOperation.operation { + case "doctor": + return .checking + case "deploy": + return .installing + case "activate", "uninstall", "fsck", "repair-xattrs", "flash": + return .maintaining + default: + break + } + } + + switch passwordState { + case .missing, .unknown: + return .passwordNeeded + case .invalid: + return .passwordInvalid + case .keychainUnavailable: + return .keychainUnavailable + case .available: + break + } + + if !profile.traits.isSupported { + return .unsupported + } + + guard let checkup = profile.lastCheckup else { + return .unchecked + } + + if checkup.failCount > 0 || checkup.state == .failed || checkup.state == .runFailed { + return .failed + } + if profile.traits.needsActivationAfterReboot, profile.lastDeploy != nil, checkup.warnCount > 0 { + return .activationNeeded + } + if checkup.warnCount > 0 || checkup.state == .warning { + return .warning + } + if profile.lastDeploy == nil { + return .readyToInstall + } + return .healthy + } + +} + +enum DashboardPrimaryActionPolicy { + static func primaryAction( + for profile: DeviceProfile, + passwordState: DevicePasswordState, + activeOperation: ActiveOperation? + ) -> DashboardPrimaryAction { + let status = DeviceStatusPolicy.status( + for: profile, + passwordState: passwordState, + activeOperation: activeOperation + ) + switch status { + case .passwordNeeded, .passwordInvalid, .keychainUnavailable: + return .replacePassword + case .unchecked: + return .runCheckup + case .readyToInstall: + return .installSMB + case .warning, .failed, .activationNeeded: + return .viewCheckup + case .healthy: + return .openSMB + case .checking: + return .viewCheckup + case .installing: + return .installSMB + case .maintaining: + return .viewCheckup + case .removed, .offline, .unsupported: + return .runCheckup + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift new file mode 100644 index 0000000..ecd9961 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift @@ -0,0 +1,314 @@ +import Combine +import Foundation + +struct DoctorOptions: Equatable { + let bonjourTimeout: Double + let skipSSH: Bool + let skipBonjour: Bool + let skipSMB: Bool +} + +enum DoctorWorkflowState: String, CaseIterable, Equatable, Codable { + case idle + case running + case passed + case warning + case failed + case runFailed + + var title: String { + switch self { + case .idle: + return "Idle" + case .running: + return "Running" + case .passed: + return "Passed" + case .warning: + return "Warning" + case .failed: + return "Failed" + case .runFailed: + return "Run Failed" + } + } +} + +struct DoctorCheckGroup: Identifiable, Equatable { + let domain: String + let checks: [DoctorCheckPayload] + + var id: String { + domain + } +} + +struct DoctorSummary: Equatable { + let passCount: Int + let warnCount: Int + let failCount: Int + let infoCount: Int + let groups: [DoctorCheckGroup] + + init(payload: DoctorPayload) { + self.passCount = Self.count(status: "PASS", in: payload) + self.warnCount = Self.count(status: "WARN", in: payload) + self.failCount = Self.count(status: "FAIL", in: payload) + self.infoCount = Self.count(status: "INFO", in: payload) + self.groups = Self.group(payload.results) + } + + private static func count(status: String, in payload: DoctorPayload) -> Int { + payload.counts[status] ?? payload.results.filter { $0.status == status }.count + } + + private static func group(_ checks: [DoctorCheckPayload]) -> [DoctorCheckGroup] { + let grouped = Dictionary(grouping: checks) { check in + check.details.stringValue(for: "domain") ?? "General" + } + return grouped + .map { DoctorCheckGroup(domain: $0.key, checks: $0.value) } + .sorted { left, right in + severityRank(left.checks) == severityRank(right.checks) + ? left.domain < right.domain + : severityRank(left.checks) < severityRank(right.checks) + } + } + + private static func severityRank(_ checks: [DoctorCheckPayload]) -> Int { + if checks.contains(where: { $0.status == "FAIL" }) { + return 0 + } + if checks.contains(where: { $0.status == "WARN" }) { + return 1 + } + return 2 + } +} + +@MainActor +final class DoctorStore: ObservableObject { + @Published var bonjourTimeout = "6" + @Published var skipSSH = false + @Published var skipBonjour = false + @Published var skipSMB = false + @Published private(set) var state: DoctorWorkflowState = .idle + @Published private(set) var payload: DoctorPayload? + @Published private(set) var summary: DoctorSummary? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + + let backend: BackendClient + private let coordinator: OperationCoordinator? + + private var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + self.coordinator = nil + observeBackend(backend) + } + + init(coordinator: OperationCoordinator) { + self.backend = coordinator.backend + self.coordinator = coordinator + observeBackend(coordinator.backend) + } + + private func observeBackend(_ backend: BackendClient) { + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + @discardableResult + func runDoctor(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard let timeout = bonjourTimeoutValue else { + failLocally(message: "Bonjour timeout must be a non-negative number.") + return .rejected("Bonjour timeout must be a non-negative number.") + } + guard !backend.isRunning else { + rejectRun("Another operation is already running.") + return .rejected("Another operation is already running.") + } + backend.clear() + let start = run( + operation: "doctor", + params: OperationParams.doctor( + bonjourTimeout: timeout, + password: password, + skipSSH: skipSSH, + skipBonjour: skipBonjour, + skipSMB: skipSMB + ), + profile: profile + ) + guard case .started(let operation) = start else { + rejectRun(start.rejectionMessage ?? "Operation could not start.") + return start + } + lastProcessedEventCount = 0 + activeOperation = operation + state = .running + payload = nil + summary = nil + error = nil + currentStage = nil + passwordInvalidProfileID = nil + return start + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + state = .idle + payload = nil + summary = nil + error = nil + currentStage = nil + passwordInvalidProfileID = nil + activeOperation = nil + } + + func cancel() { + backend.cancel() + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "doctor" else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation?.profileID + } + error = BackendErrorViewModel(event: event) + state = .runFailed + activeOperation = nil + return + } + + guard event.type == "result" else { + return + } + applyDoctorResult(event) + } + + private func applyDoctorResult(_ event: BackendEvent) { + do { + let decoded = try event.decodePayload(DoctorPayload.self) + payload = decoded + summary = DoctorSummary(payload: decoded) + error = nil + if decoded.fatal || event.ok == false { + state = .failed + } else if summary?.warnCount ?? 0 > 0 { + state = .warning + } else { + state = .passed + } + activeOperation = nil + } catch { + self.error = BackendErrorViewModel( + operation: "doctor", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .runFailed + activeOperation = nil + } + } + + private func failLocally(message: String) { + error = BackendErrorViewModel( + operation: "doctor", + code: "validation_failed", + message: message + ) + currentStage = nil + state = .runFailed + activeOperation = nil + } + + private func rejectRun(_ message: String) { + error = BackendErrorViewModel( + operation: "doctor", + code: "operation_rejected", + message: message + ) + currentStage = nil + state = .runFailed + activeOperation = nil + } + + private func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } + + private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { + if let coordinator { + return coordinator.run(operation: operation, params: params, profile: profile) + } else { + guard !backend.isRunning else { + return .rejected("Another operation is already running.") + } + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run(operation: operation, params: params, context: context) + return .started(activeOperation) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift new file mode 100644 index 0000000..b03568c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +struct DoctorView: View { + @ObservedObject var store: DoctorStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.doctor")) + .font(.title2.weight(.semibold)) + + HStack { + TextField(L10n.string("field.bonjour_timeout"), text: $store.bonjourTimeout) + .frame(width: 180) + Toggle("Skip SSH", isOn: $store.skipSSH) + Toggle("Skip Bonjour", isOn: $store.skipBonjour) + Toggle("Skip SMB", isOn: $store.skipSMB) + } + + HStack { + Button { + store.runDoctor(password: password) + } label: { + Label(L10n.string("button.run_doctor"), systemImage: "stethoscope") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let summary = store.summary { + DoctorSummaryView(summary: summary) + } + + if let error = store.error { + DoctorErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusIcon: String { + switch store.state { + case .idle: + return "circle" + case .running: + return "hourglass" + case .passed: + return "checkmark.circle" + case .warning: + return "exclamationmark.circle" + case .failed, .runFailed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .passed: + return .green + case .warning: + return .orange + case .failed, .runFailed: + return .red + default: + return .secondary + } + } +} + +private struct DoctorSummaryView: View { + let summary: DoctorSummary + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text("PASS \(summary.passCount)").foregroundStyle(.green) + Text("WARN \(summary.warnCount)").foregroundStyle(.orange) + Text("FAIL \(summary.failCount)").foregroundStyle(.red) + Text("INFO \(summary.infoCount)").foregroundStyle(.secondary) + } + .font(.caption.weight(.medium)) + + ForEach(summary.groups) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.domain) + .font(.body.weight(.medium)) + ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in + HStack(alignment: .top) { + Text(check.status) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(color(for: check.status)) + .frame(width: 44, alignment: .leading) + Text(check.message) + .font(.caption) + } + } + } + } + } + } + + private func color(for status: String) -> Color { + switch status { + case "PASS": + return .green + case "WARN": + return .orange + case "FAIL": + return .red + default: + return .secondary + } + } +} + +private struct DoctorErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift new file mode 100644 index 0000000..60db485 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift @@ -0,0 +1,83 @@ +import AppKit +import SwiftUI + +struct ErrorBlock: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + } + .foregroundStyle(.red) + } +} + +struct ErrorRecoveryView: View { + let error: BackendErrorViewModel + let onAction: (RecoveryAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ErrorBlock(error: error) + let actions = RecoveryActionMapper.actions(for: error) + if !actions.isEmpty { + HStack { + ForEach(actions) { action in + Button { + if action.kind == .copyDiagnostics { + copyDiagnostics() + } else { + onAction(action) + } + } label: { + Label(action.title, systemImage: icon(for: action.kind)) + } + .disabled(!isActionable(action)) + } + } + } + } + } + + private func isActionable(_ action: RecoveryAction) -> Bool { + action.kind != .generic + } + + private func copyDiagnostics() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString("\(error.operation) \(error.code): \(error.message)", forType: .string) + } + + private func icon(for kind: RecoveryActionKind) -> String { + switch kind { + case .retry: + return "arrow.clockwise" + case .runCheckup: + return "stethoscope" + case .installSMB: + return "square.and.arrow.up" + case .startSMB: + return "play.circle" + case .uninstall: + return "trash" + case .diskRepair: + return "externaldrive.badge.exclamationmark" + case .metadataRepair: + return "tag" + case .openFinder: + return "folder" + case .replacePassword: + return "key" + case .copyDiagnostics: + return "doc.on.doc" + case .diagnostics: + return "wrench.and.screwdriver" + case .generic: + return "arrow.right.circle" + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift new file mode 100644 index 0000000..1fbb53e --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct FlashBootHookSection: View { + let profile: DeviceProfile + @StateObject private var store = FlashWorkflowStore() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Persistent NetBSD4 Boot Hook") + .font(.headline) + Text(store.eligibilityMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Label(store.state.title, systemImage: "lock") + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { + Button("Back Up and Inspect") {} + .disabled(true) + Button("Patch Boot Hook") {} + .disabled(true) + Button("Restore Apple Firmware") {} + .disabled(true) + } + } + .onAppear { + store.refresh(profile: profile) + } + .onChange(of: profile.id) { _ in + store.refresh(profile: profile) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift new file mode 100644 index 0000000..d226904 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift @@ -0,0 +1,122 @@ +import Foundation + +enum FlashBuildPolicy: String, CaseIterable, Equatable { + case disabled + case readOnly + case writesEnabled +} + +enum FlashWorkflowState: String, CaseIterable, Equatable { + case unavailable + case disabledInThisBuild + case eligibleForReadOnlyAnalysis + case readingBanks + case savingBackup + case analyzingBanks + case planAvailable + case writeLocked + case awaitingStrongConfirmation + case writing + case readbackValidating + case writeValidated + case manualPowerCycleRequired + case restoreRebooting + case failed + + var title: String { + switch self { + case .unavailable: + return "Unavailable" + case .disabledInThisBuild: + return "Disabled in This Build" + case .eligibleForReadOnlyAnalysis: + return "Read-Only Analysis Available" + case .readingBanks: + return "Reading Firmware Banks" + case .savingBackup: + return "Saving Backup" + case .analyzingBanks: + return "Analyzing Firmware" + case .planAvailable: + return "Plan Available" + case .writeLocked: + return "Write Locked" + case .awaitingStrongConfirmation: + return "Awaiting Strong Confirmation" + case .writing: + return "Writing Firmware" + case .readbackValidating: + return "Validating Write" + case .writeValidated: + return "Write Validated" + case .manualPowerCycleRequired: + return "Manual Power Cycle Required" + case .restoreRebooting: + return "Rebooting After Restore" + case .failed: + return "Failed" + } + } +} + +struct FlashEligibility: Equatable { + let state: FlashWorkflowState + let message: String + let readOnlyAllowed: Bool + let writeAllowed: Bool +} + +enum FlashEligibilityPolicy { + static func eligibility(for profile: DeviceProfile, buildPolicy: FlashBuildPolicy = .disabled) -> FlashEligibility { + guard profile.traits.supportsFlashBootHook else { + return FlashEligibility( + state: .unavailable, + message: "Persistent boot hook tools are only for NetBSD4 Time Capsules.", + readOnlyAllowed: false, + writeAllowed: false + ) + } + + switch buildPolicy { + case .disabled: + return FlashEligibility( + state: .disabledInThisBuild, + message: "Firmware boot hook analysis is planned, but disabled in this build.", + readOnlyAllowed: false, + writeAllowed: false + ) + case .readOnly: + return FlashEligibility( + state: .eligibleForReadOnlyAnalysis, + message: "This device can use read-only firmware backup and inspection when the flash API is available.", + readOnlyAllowed: true, + writeAllowed: false + ) + case .writesEnabled: + return FlashEligibility( + state: .writeLocked, + message: "Write actions require backup review and strong confirmation before they can run.", + readOnlyAllowed: true, + writeAllowed: true + ) + } + } +} + +@MainActor +final class FlashWorkflowStore: ObservableObject { + @Published private(set) var state: FlashWorkflowState = .disabledInThisBuild + @Published private(set) var eligibilityMessage = "Firmware boot hook analysis is disabled in this build." + + let buildPolicy: FlashBuildPolicy + + init(buildPolicy: FlashBuildPolicy = .disabled) { + self.buildPolicy = buildPolicy + } + + func refresh(profile: DeviceProfile) { + let eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: buildPolicy) + state = eligibility.state + eligibilityMessage = eligibility.message + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift new file mode 100644 index 0000000..d547232 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift @@ -0,0 +1,244 @@ +import Foundation + +public struct HelperResolution: Equatable { + public let executableURL: URL + public let distributionRootURL: URL? + public let toolsBinURL: URL? + public let mode: BundleRuntimeMode + public let attemptedPaths: [String] +} + +public enum HelperLocatorError: Error, Equatable, LocalizedError { + case notFound([String]) + + public var errorDescription: String? { + switch self { + case .notFound(let attempts): + let attempted = attempts.isEmpty ? "none" : attempts.joined(separator: ", ") + return "Could not find the TimeCapsuleSMB helper. Attempted: \(attempted)" + } + } +} + +public struct HelperLocator { + public var environment: [String: String] + public var currentDirectory: URL + public var bundle: Bundle + public var fileManager: FileManager + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + currentDirectory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true), + bundle: Bundle = .main, + fileManager: FileManager = .default + ) { + self.environment = environment + self.currentDirectory = currentDirectory + self.bundle = bundle + self.fileManager = fileManager + } + + public func resolve(helperPath: String?) throws -> HelperResolution { + var attempts: [String] = [] + if let explicit = normalized(helperPath) { + return try resolveExplicitPath(explicit, attempts: &attempts) + } + if let fromEnvironment = normalized(environment["TCAPSULE_HELPER"]) { + return try resolveExplicitPath(fromEnvironment, attempts: &attempts) + } + + for candidate in bundledHelperCandidates() + devHelperCandidates() { + attempts.append(candidate.url.path) + if isExecutable(candidate.url) { + return HelperResolution( + executableURL: candidate.url, + distributionRootURL: distributionRoot(for: candidate.url, mode: candidate.mode), + toolsBinURL: toolsBinURL(for: candidate.mode), + mode: candidate.mode, + attemptedPaths: attempts + ) + } + } + throw HelperLocatorError.notFound(attempts) + } + + public func helperEnvironment(for resolution: HelperResolution, context: DeviceRuntimeContext? = nil) -> [String: String] { + var output = environment + if let appSupport = applicationSupportDirectory() { + try? fileManager.createDirectory(at: appSupport, withIntermediateDirectories: true) + if let context { + try? fileManager.createDirectory(at: context.configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + output["TCAPSULE_CONFIG"] = context.configURL.path + } else if output["TCAPSULE_CONFIG"] == nil { + output["TCAPSULE_CONFIG"] = appSupport.appendingPathComponent(".env").path + } + if output["TCAPSULE_STATE_DIR"] == nil { + output["TCAPSULE_STATE_DIR"] = appSupport.path + } + } + if output["TCAPSULE_DISTRIBUTION_ROOT"] == nil, let distributionRoot = resolution.distributionRootURL { + output["TCAPSULE_DISTRIBUTION_ROOT"] = distributionRoot.path + } + if let toolsBin = resolution.toolsBinURL, isDirectory(toolsBin) { + output["PATH"] = pathByPrepending(toolsBin.path, to: output["PATH"]) + } + output["PYTHONNOUSERSITE"] = "1" + return output + } + + public func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] { + guard resolution.mode == .productionBundle, + let layout = BundleLayout.productionCandidate(bundle: bundle, fileManager: fileManager) + else { + return [] + } + return layout.validationIssues(fileManager: fileManager) + } + + private func resolveExplicitPath(_ path: String, attempts: inout [String]) throws -> HelperResolution { + let candidate = url(forPath: path) + attempts.append(candidate.path) + guard isExecutable(candidate) else { + throw HelperLocatorError.notFound(attempts) + } + return HelperResolution( + executableURL: candidate, + distributionRootURL: distributionRoot(for: candidate, mode: .explicit), + toolsBinURL: toolsBinURL(for: .explicit), + mode: .explicit, + attemptedPaths: attempts + ) + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func url(forPath path: String) -> URL { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } + return currentDirectory.appendingPathComponent(path) + } + + private func bundledHelperCandidates() -> [HelperCandidate] { + var candidates: [HelperCandidate] = [] + if let layout = BundleLayout.productionCandidate(bundle: bundle, fileManager: fileManager) { + candidates.append(HelperCandidate(url: layout.helperURL, mode: .productionBundle)) + } + if let helper = bundle.url(forResource: "tcapsule", withExtension: nil, subdirectory: "Helpers") { + candidates.append(HelperCandidate(url: helper, mode: .productionBundle)) + } + if let helper = bundle.url(forResource: "tcapsule", withExtension: nil) { + candidates.append(HelperCandidate(url: helper, mode: .productionBundle)) + } + return candidates + } + + private func devHelperCandidates() -> [HelperCandidate] { + var roots: [URL] = [] + if let explicitRoot = normalized(environment["TCAPSULE_SOURCE_ROOT"]) { + roots.append(url(forPath: explicitRoot)) + } + roots.append(contentsOf: ancestorDirectories(startingAt: currentDirectory)) + return unique(roots).map { + HelperCandidate(url: $0.appendingPathComponent(".venv/bin/tcapsule"), mode: .developmentCheckout) + } + } + + private func distributionRoot(for helperURL: URL, mode: BundleRuntimeMode) -> URL? { + if let explicit = normalized(environment["TCAPSULE_DISTRIBUTION_ROOT"]) { + return url(forPath: explicit) + } + if mode == .productionBundle, + let bundled = BundleLayout.productionCandidate(bundle: bundle, fileManager: fileManager)?.distributionRootURL, + isDirectory(bundled) { + return bundled + } + if let repo = repoRoot(containing: helperURL) { + return repo + } + if let bundled = bundle.resourceURL?.appendingPathComponent("Distribution"), isDirectory(bundled) { + return bundled + } + return nil + } + + private func toolsBinURL(for mode: BundleRuntimeMode) -> URL? { + guard mode == .productionBundle else { + return nil + } + return BundleLayout.productionCandidate(bundle: bundle, fileManager: fileManager)?.toolsBinURL + } + + private func repoRoot(containing helperURL: URL) -> URL? { + for candidate in ancestorDirectories(startingAt: helperURL.deletingLastPathComponent()) { + if isRepoRoot(candidate) { + return candidate + } + } + return nil + } + + private func ancestorDirectories(startingAt start: URL) -> [URL] { + var output: [URL] = [] + var current = start.standardizedFileURL.path + while true { + output.append(URL(fileURLWithPath: current, isDirectory: true)) + let parent = (current as NSString).deletingLastPathComponent + if parent == current || parent.isEmpty { + break + } + current = parent + } + return output + } + + private func unique(_ urls: [URL]) -> [URL] { + var seen: Set = [] + var output: [URL] = [] + for url in urls { + let path = url.standardizedFileURL.path + if seen.insert(path).inserted { + output.append(url.standardizedFileURL) + } + } + return output + } + + private func isExecutable(_ url: URL) -> Bool { + fileManager.isExecutableFile(atPath: url.path) + } + + private func isDirectory(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } + + private func isRepoRoot(_ url: URL) -> Bool { + let pyproject = url.appendingPathComponent("pyproject.toml") + let bin = url.appendingPathComponent("bin") + let sourcePackage = url.appendingPathComponent("src/timecapsulesmb") + return fileManager.fileExists(atPath: pyproject.path) + && isDirectory(bin) + && isDirectory(sourcePackage) + } + + private func applicationSupportDirectory() -> URL? { + BundleLayout.applicationSupportDirectory(fileManager: fileManager) + } + + private func pathByPrepending(_ prefix: String, to path: String?) -> String { + guard let path, !path.isEmpty else { + return prefix + } + return "\(prefix):\(path)" + } +} + +private struct HelperCandidate { + let url: URL + let mode: BundleRuntimeMode +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift new file mode 100644 index 0000000..4d800db --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift @@ -0,0 +1,107 @@ +import Foundation + +public protocol HelperRequestWriting: Sendable { + func write(_ data: Data, to handle: FileHandle) async throws +} + +public final class PipeRequestWriter: HelperRequestWriting, @unchecked Sendable { + private let chunkSize: Int + + public init(chunkSize: Int = 4096) { + self.chunkSize = chunkSize + } + + public func write(_ data: Data, to handle: FileHandle) async throws { + try Task.checkCancellation() + guard !data.isEmpty else { + return + } + + let state = PipeRequestWriteState(data: data, handle: handle, chunkSize: chunkSize) + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + state.start(continuation: continuation) + } + } onCancel: { + state.cancel() + } + } +} + +private final class PipeRequestWriteState: @unchecked Sendable { + private let data: Data + private let handle: FileHandle + private let chunkSize: Int + private let lock = NSLock() + private var offset = 0 + private var continuation: CheckedContinuation? + private var completed = false + + init(data: Data, handle: FileHandle, chunkSize: Int) { + self.data = data + self.handle = handle + self.chunkSize = max(1, chunkSize) + } + + func start(continuation: CheckedContinuation) { + lock.lock() + if completed { + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + lock.unlock() + + handle.writeabilityHandler = { [weak self] writableHandle in + self?.writeNextChunk(to: writableHandle) + } + writeNextChunk(to: handle) + } + + func cancel() { + complete(.failure(CancellationError())) + } + + private func writeNextChunk(to handle: FileHandle) { + let chunk: Data + lock.lock() + guard !completed else { + lock.unlock() + return + } + let end = min(offset + chunkSize, data.count) + chunk = data.subdata(in: offset..= data.count + lock.unlock() + if finished { + complete(.success(())) + } + } + + private func complete(_ result: Result) { + lock.lock() + guard !completed else { + lock.unlock() + return + } + completed = true + let continuation = self.continuation + self.continuation = nil + lock.unlock() + + handle.writeabilityHandler = nil + continuation?.resume(with: result) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift new file mode 100644 index 0000000..14d56bc --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -0,0 +1,268 @@ +import Darwin +import Foundation + +public struct HelperRunResult: Equatable, Sendable { + public let exitCode: Int32 + public let sawTerminalEvent: Bool + public let stderr: String +} + +public protocol HelperRunning: Sendable { + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult +} + +public final class HelperRunner: @unchecked Sendable, HelperRunning { + private static let pipeReadChunkSize = 4096 + + private let locator: HelperLocator + private let stderrLimit: Int + private let requestWriter: any HelperRequestWriting + + public init( + locator: HelperLocator = HelperLocator(), + stderrLimit: Int = 64 * 1024, + requestWriter: any HelperRequestWriting = PipeRequestWriter() + ) { + self.locator = locator + self.stderrLimit = stderrLimit + self.requestWriter = requestWriter + } + + public func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext? = nil, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let terminalTracker = TerminalEventTracker() + let eventSink: @Sendable (BackendEvent) async -> Void = { event in + await terminalTracker.record(event) + await onEvent(event) + } + + let resolution: HelperResolution + do { + resolution = try locator.resolve(helperPath: helperPath) + } catch { + await eventSink(BackendEvent.error(operation: operation, code: "helper_not_found", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let process = Process() + process.executableURL = resolution.executableURL + process.arguments = ["api"] + process.environment = locator.helperEnvironment(for: resolution, context: context) + + let input = Pipe() + let output = Pipe() + let error = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = error + + do { + try process.run() + } catch { + await eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let stdoutTask = Task.detached { + await Self.readOutput(output.fileHandleForReading, onEvent: eventSink) + } + let stderrLimit = self.stderrLimit + let stderrTask = Task.detached { + Self.readCapped(error.fileHandleForReading, limit: stderrLimit) + } + + let requestData: Data + do { + var requestParams = params + if let context, requestParams["config"] == nil { + requestParams["config"] = .string(context.configURL.path) + } + let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(requestParams)] + requestData = try JSONEncoder().encode(JSONValue.object(request)) + } catch { + await Self.terminate(process) + await eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) + await stdoutTask.value + let stderr = await stderrTask.value + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) + } + + let requestWriter = self.requestWriter + let writeResult: Result = await withTaskCancellationHandler { + do { + try await requestWriter.write(requestData, to: input.fileHandleForWriting) + try input.fileHandleForWriting.close() + return .success(()) + } catch { + return .failure(error) + } + } onCancel: { + try? input.fileHandleForWriting.close() + Task { + await Self.terminate(process) + } + } + + if case .failure(let error) = writeResult { + try? input.fileHandleForWriting.close() + await Self.terminate(process) + await stdoutTask.value + let stderr = await stderrTask.value + if Task.isCancelled || error is CancellationError { + await eventSink(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + debug: stderr.isEmpty ? nil : .object(["stderr": .string(stderr)]) + )) + let sawTerminalEvent = await terminalTracker.sawTerminalEvent + return HelperRunResult(exitCode: 130, sawTerminalEvent: sawTerminalEvent, stderr: stderr) + } + await eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) + } + + await withTaskCancellationHandler { + await Self.waitForExit(process) + } onCancel: { + Task { + await Self.terminate(process) + } + } + let cancelled = Task.isCancelled + + await stdoutTask.value + let stderrText = await stderrTask.value + let sawTerminalEvent = await terminalTracker.sawTerminalEvent + if cancelled { + await eventSink(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) + )) + } else if !sawTerminalEvent { + await eventSink(BackendEvent.error( + operation: operation, + code: "missing_terminal_event", + message: L10n.string("helper.error.missing_terminal_event"), + debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) + )) + } + let finalSawTerminalEvent = await terminalTracker.sawTerminalEvent + + return HelperRunResult( + exitCode: cancelled ? 130 : process.terminationStatus, + sawTerminalEvent: finalSawTerminalEvent, + stderr: stderrText + ) + } + + private static func readOutput( + _ handle: FileHandle, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async { + var parser = OutputLineParser() + while let data = readChunk(from: handle) { + for event in parser.append(data) { + await onEvent(event) + } + } + for event in parser.finish() { + await onEvent(event) + } + } + + private static func readCapped(_ handle: FileHandle, limit: Int) -> String { + var output = Data() + while let data = readChunk(from: handle) { + if output.count < limit { + output.append(data.prefix(limit - output.count)) + } + } + return String(decoding: output, as: UTF8.self) + } + + private static func readChunk(from handle: FileHandle) -> Data? { + let data: Data? + do { + data = try handle.read(upToCount: pipeReadChunkSize) + } catch { + return nil + } + guard let data, !data.isEmpty else { + return nil + } + return data + } + + private static func waitForExit(_ process: Process) async { + if !process.isRunning { + return + } + await withCheckedContinuation { (continuation: CheckedContinuation) in + let box = TerminationContinuation(continuation) + process.terminationHandler = { _ in + box.resume() + } + if !process.isRunning { + box.resume() + } + } + process.terminationHandler = nil + } + + private static func terminate(_ process: Process) async { + process.terminate() + for _ in 0..<10 { + if !process.isRunning { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + } +} + +private final class TerminationContinuation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume() { + lock.lock() + let continuation = continuation + self.continuation = nil + lock.unlock() + continuation?.resume() + } +} + +private actor TerminalEventTracker { + private var seen = false + + var sawTerminalEvent: Bool { + seen + } + + func record(_ event: BackendEvent) { + guard event.type == "result" || event.type == "error" else { return } + seen = true + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift new file mode 100644 index 0000000..5c96dea --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift @@ -0,0 +1,49 @@ +import Foundation + +struct HostCompatibilityWarning: Equatable { + let title: String + let message: String +} + +private struct KnownHostCompatibilityIssue { + let majorVersion: Int + let minorVersion: Int + let patchVersions: Set? + + func matches(_ version: OperatingSystemVersion) -> Bool { + guard version.majorVersion == majorVersion, version.minorVersion == minorVersion else { + return false + } + guard let patchVersions else { + return true + } + return patchVersions.contains(version.patchVersion) + } +} + +enum HostCompatibilityPolicy { + // Product guidance tracks macOS 26.4.x separately from the 15.7 patch band. + private static let knownTimeMachineIssues = [ + KnownHostCompatibilityIssue(majorVersion: 15, minorVersion: 7, patchVersions: [5, 6, 7]), + KnownHostCompatibilityIssue(majorVersion: 26, minorVersion: 4, patchVersions: nil) + ] + + static func warning(for version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion) -> HostCompatibilityWarning? { + guard knownTimeMachineIssues.contains(where: { $0.matches(version) }) else { + return nil + } + return timeMachineWarning(version: version) + } + + private static func timeMachineWarning(version: OperatingSystemVersion) -> HostCompatibilityWarning { + HostCompatibilityWarning( + title: L10n.string("host_warning.time_machine.title"), + message: L10n.format( + "host_warning.time_machine.message", + version.majorVersion, + version.minorVersion, + version.patchVersion + ) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift new file mode 100644 index 0000000..7ac2503 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift @@ -0,0 +1,11 @@ +import Foundation + +enum L10n { + static func string(_ key: String) -> String { + NSLocalizedString(key, bundle: .module, comment: "") + } + + static func format(_ key: String, _ arguments: CVarArg...) -> String { + String(format: string(key), locale: Locale.current, arguments: arguments) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift new file mode 100644 index 0000000..6dfd0b5 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift @@ -0,0 +1,873 @@ +import Combine +import Foundation + +enum MaintenanceWorkflow: String, CaseIterable, Equatable, Identifiable { + case activate + case uninstall + case fsck + case repairXattrs + + var id: String { rawValue } + + var title: String { + switch self { + case .activate: + return "Activate" + case .uninstall: + return "Uninstall" + case .fsck: + return "fsck" + case .repairXattrs: + return "Repair xattrs" + } + } +} + +enum MaintenanceOperationState: String, CaseIterable, Equatable { + case idle + case loading + case listReady + case planning + case planReady + case planStale + case scanning + case scanReady + case scanStale + case awaitingConfirmation + case running + case repairing + case succeeded + case repaired + case failed + + var title: String { + switch self { + case .idle: + return "Idle" + case .loading: + return "Loading" + case .listReady: + return "List Ready" + case .planning: + return "Planning" + case .planReady: + return "Plan Ready" + case .planStale: + return "Plan Stale" + case .scanning: + return "Scanning" + case .scanReady: + return "Scan Ready" + case .scanStale: + return "Scan Stale" + case .awaitingConfirmation: + return "Awaiting Confirmation" + case .running: + return "Running" + case .repairing: + return "Repairing" + case .succeeded: + return "Succeeded" + case .repaired: + return "Repaired" + case .failed: + return "Failed" + } + } +} + +struct MaintenanceOptions: Equatable { + let noReboot: Bool + let noWait: Bool + let mountWait: Int +} + +struct FsckTargetViewModel: Identifiable, Equatable { + let id: String + let device: String + let mountpoint: String + let name: String? + let builtin: Bool? + + init(payload: FsckTargetPayload) { + self.id = "\(payload.device)|\(payload.mountpoint)" + self.device = payload.device + self.mountpoint = payload.mountpoint + self.name = payload.name + self.builtin = payload.builtin + } + + var volumeParam: String { + device + } +} + +@MainActor +final class MaintenanceStore: ObservableObject { + @Published var selectedWorkflow: MaintenanceWorkflow = .activate + @Published var mountWait = "30" { + didSet { markPlansStaleForOptionChange() } + } + @Published var noReboot = false { + didSet { markPlansStaleForOptionChange() } + } + @Published var noWait = false { + didSet { markPlansStaleForOptionChange() } + } + @Published var repairPath = "" { + didSet { markRepairStaleForPathChange() } + } + @Published var selectedFsckTargetID: FsckTargetViewModel.ID? { + didSet { markFsckPlanStaleIfNeeded() } + } + + @Published private(set) var activateState: MaintenanceOperationState = .idle + @Published private(set) var uninstallState: MaintenanceOperationState = .idle + @Published private(set) var fsckState: MaintenanceOperationState = .idle + @Published private(set) var repairState: MaintenanceOperationState = .idle + + @Published private(set) var activationPlan: ActivationPlanPayload? + @Published private(set) var activationResult: ActivationResultPayload? + @Published private(set) var uninstallPlan: UninstallPlanPayload? + @Published private(set) var uninstallResult: MaintenanceResultPayload? + @Published private(set) var fsckTargets: [FsckTargetViewModel] = [] + @Published private(set) var fsckPlan: FsckPlanPayload? + @Published private(set) var fsckResult: FsckResultPayload? + @Published private(set) var repairScan: RepairXattrsPayload? + @Published private(set) var repairResult: RepairXattrsPayload? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + + let backend: BackendClient + private let coordinator: OperationCoordinator? + + private var plannedUninstallOptions: MaintenanceOptions? + private var plannedFsckOptions: MaintenanceOptions? + private var plannedFsckTargetID: FsckTargetViewModel.ID? + private var scannedRepairPath: String? + private var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + self.coordinator = nil + observeBackend(backend) + } + + init(coordinator: OperationCoordinator) { + self.backend = coordinator.backend + self.coordinator = coordinator + observeBackend(coordinator.backend) + } + + private func observeBackend(_ backend: BackendClient) { + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var mountWaitValue: Int? { + nonNegativeInteger(mountWait) + } + + var selectedFsckTarget: FsckTargetViewModel? { + guard let selectedFsckTargetID else { + return nil + } + return fsckTargets.first { $0.id == selectedFsckTargetID } + } + + var canRunActivation: Bool { + !backend.isRunning && activationPlan != nil && activateState == .planReady + } + + var canRunUninstall: Bool { + !backend.isRunning && uninstallPlan != nil && uninstallState == .planReady && currentOptions == plannedUninstallOptions + } + + var canPlanFsck: Bool { + !backend.isRunning && selectedFsckTarget != nil && currentOptions != nil + } + + var canRunFsck: Bool { + !backend.isRunning + && fsckPlan != nil + && fsckState == .planReady + && currentOptions == plannedFsckOptions + && selectedFsckTargetID == plannedFsckTargetID + } + + var canRepairXattrs: Bool { + !backend.isRunning + && repairState == .scanReady + && repairScan?.repairableCount ?? 0 > 0 + && scannedRepairPath == trimmedRepairPath + } + + @discardableResult + func planActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + let start = startRun( + operation: "activate", + params: OperationParams.activatePlan(password: password), + profile: profile, + workflow: .activate + ) + guard case .started = start else { + return start + } + selectedWorkflow = .activate + activateState = .planning + activationPlan = nil + activationResult = nil + return start + } + + @discardableResult + func runActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .activate, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } + guard canRunActivation else { + failLocally(workflow: .activate, message: "Plan NetBSD4 activation before running it.") + return .rejected("Plan NetBSD4 activation before running it.") + } + let start = startRun( + operation: "activate", + params: OperationParams.activateRun(password: password), + profile: profile, + workflow: .activate + ) + guard case .started = start else { + return start + } + selectedWorkflow = .activate + activateState = .running + activationResult = nil + return start + } + + @discardableResult + func planUninstall(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard let options = currentOptions else { + failLocally(workflow: .uninstall, message: "Mount wait must be a non-negative integer.") + return .rejected("Mount wait must be a non-negative integer.") + } + let start = startRun( + operation: "uninstall", + params: OperationParams.uninstallPlan( + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ), + profile: profile, + workflow: .uninstall + ) + guard case .started = start else { + return start + } + selectedWorkflow = .uninstall + uninstallState = .planning + uninstallPlan = nil + uninstallResult = nil + plannedUninstallOptions = options + return start + } + + @discardableResult + func runUninstall(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .uninstall, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } + guard let options = plannedUninstallOptions, currentOptions == options, uninstallPlan != nil else { + uninstallState = .planStale + error = BackendErrorViewModel( + operation: "uninstall", + code: "plan_stale", + message: "Review and regenerate the uninstall plan before running it." + ) + return .rejected("Review and regenerate the uninstall plan before running it.") + } + guard uninstallState == .planReady else { + return .rejected("Uninstall plan is not ready.") + } + let start = startRun( + operation: "uninstall", + params: OperationParams.uninstallRun( + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ), + profile: profile, + workflow: .uninstall + ) + guard case .started = start else { + return start + } + selectedWorkflow = .uninstall + uninstallState = .running + uninstallResult = nil + return start + } + + @discardableResult + func refreshFsckTargets(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard let mountWaitValue else { + failLocally(workflow: .fsck, message: "Mount wait must be a non-negative integer.") + return .rejected("Mount wait must be a non-negative integer.") + } + let start = startRun( + operation: "fsck", + params: OperationParams.fsckList(mountWait: Double(mountWaitValue), password: password), + profile: profile, + workflow: .fsck + ) + guard case .started = start else { + return start + } + selectedWorkflow = .fsck + fsckState = .loading + fsckTargets = [] + selectedFsckTargetID = nil + fsckPlan = nil + fsckResult = nil + return start + } + + @discardableResult + func planFsck(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard let options = currentOptions else { + failLocally(workflow: .fsck, message: "Mount wait must be a non-negative integer.") + return .rejected("Mount wait must be a non-negative integer.") + } + guard let target = selectedFsckTarget else { + failLocally(workflow: .fsck, message: "Select a mounted HFS volume before planning fsck.") + return .rejected("Select a mounted HFS volume before planning fsck.") + } + let start = startRun( + operation: "fsck", + params: OperationParams.fsckPlan( + volume: target.volumeParam, + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ), + profile: profile, + workflow: .fsck + ) + guard case .started = start else { + return start + } + selectedWorkflow = .fsck + fsckState = .planning + fsckPlan = nil + fsckResult = nil + plannedFsckOptions = options + plannedFsckTargetID = target.id + return start + } + + @discardableResult + func runFsck(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .fsck, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } + guard let options = plannedFsckOptions, + let target = selectedFsckTarget, + selectedFsckTargetID == plannedFsckTargetID, + currentOptions == options, + fsckPlan != nil else { + fsckState = .planStale + error = BackendErrorViewModel( + operation: "fsck", + code: "plan_stale", + message: "Review and regenerate the fsck plan before running it." + ) + return .rejected("Review and regenerate the fsck plan before running it.") + } + guard fsckState == .planReady else { + return .rejected("fsck plan is not ready.") + } + let start = startRun( + operation: "fsck", + params: OperationParams.fsckRun( + volume: target.volumeParam, + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ), + profile: profile, + workflow: .fsck + ) + guard case .started = start else { + return start + } + selectedWorkflow = .fsck + fsckState = .running + fsckResult = nil + return start + } + + @discardableResult + func scanRepairXattrs() -> OperationStartResult { + guard !trimmedRepairPath.isEmpty else { + failLocally(workflow: .repairXattrs, message: "Choose a mounted SMB share path before scanning.") + return .rejected("Choose a mounted SMB share path before scanning.") + } + let path = trimmedRepairPath + let start = startRun( + operation: "repair-xattrs", + params: OperationParams.repairXattrsScan(path: path), + profile: nil, + workflow: .repairXattrs + ) + guard case .started = start else { + return start + } + selectedWorkflow = .repairXattrs + repairState = .scanning + repairScan = nil + repairResult = nil + scannedRepairPath = path + return start + } + + @discardableResult + func runRepairXattrs() -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .repairXattrs, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } + guard canRepairXattrs else { + repairState = .scanStale + error = BackendErrorViewModel( + operation: "repair-xattrs", + code: "scan_stale", + message: "Run a fresh xattr scan before repairing." + ) + return .rejected("Run a fresh xattr scan before repairing.") + } + let start = startRun( + operation: "repair-xattrs", + params: OperationParams.repairXattrsRun(path: trimmedRepairPath), + profile: nil, + workflow: .repairXattrs + ) + guard case .started = start else { + return start + } + selectedWorkflow = .repairXattrs + repairState = .repairing + repairResult = nil + return start + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + activateState = .idle + uninstallState = .idle + fsckState = .idle + repairState = .idle + activationPlan = nil + activationResult = nil + uninstallPlan = nil + uninstallResult = nil + fsckTargets = [] + selectedFsckTargetID = nil + fsckPlan = nil + fsckResult = nil + repairScan = nil + repairResult = nil + currentStage = nil + error = nil + passwordInvalidProfileID = nil + plannedUninstallOptions = nil + plannedFsckOptions = nil + plannedFsckTargetID = nil + scannedRepairPath = nil + activeOperation = nil + } + + func cancel() { + backend.cancel() + } + + private var currentOptions: MaintenanceOptions? { + guard let mountWaitValue else { + return nil + } + return MaintenanceOptions(noReboot: noReboot, noWait: noWait, mountWait: mountWaitValue) + } + + private var trimmedRepairPath: String { + repairPath.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func resetRunState() { + backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + passwordInvalidProfileID = nil + activeOperation = nil + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard ["activate", "uninstall", "fsck", "repair-xattrs"].contains(event.operation) else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if event.operation == "activate", activateState == .awaitingConfirmation { + activateState = .running + } else if event.operation == "uninstall", uninstallState == .awaitingConfirmation { + uninstallState = .running + } else if event.operation == "fsck", fsckState == .awaitingConfirmation { + fsckState = .running + } else if event.operation == "repair-xattrs", repairState == .awaitingConfirmation { + repairState = .repairing + } + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + + if event.ok == false { + applyFalseResult(event) + return + } + + switch event.operation { + case "activate": + handleActivateResult(event) + case "uninstall": + handleUninstallResult(event) + case "fsck": + handleFsckResult(event) + case "repair-xattrs": + handleRepairResult(event) + default: + break + } + } + + private func handleActivateResult(_ event: BackendEvent) { + if activateState == .planning { + do { + activationPlan = try event.decodePayload(ActivationPlanPayload.self) + activateState = .planReady + activeOperation = nil + } catch { + failContract(workflow: .activate, error: error) + } + return + } + do { + activationResult = try event.decodePayload(ActivationResultPayload.self) + activateState = .succeeded + error = nil + activeOperation = nil + } catch { + failContract(workflow: .activate, error: error) + } + } + + private func handleUninstallResult(_ event: BackendEvent) { + if uninstallState == .planning { + do { + uninstallPlan = try event.decodePayload(UninstallPlanPayload.self) + uninstallState = .planReady + activeOperation = nil + } catch { + failContract(workflow: .uninstall, error: error) + } + return + } + do { + uninstallResult = try event.decodePayload(MaintenanceResultPayload.self) + uninstallState = .succeeded + error = nil + activeOperation = nil + } catch { + failContract(workflow: .uninstall, error: error) + } + } + + private func handleFsckResult(_ event: BackendEvent) { + switch fsckState { + case .loading: + do { + let payload = try event.decodePayload(FsckVolumeListPayload.self) + fsckTargets = payload.targets.map(FsckTargetViewModel.init) + selectedFsckTargetID = fsckTargets.count == 1 ? fsckTargets[0].id : nil + fsckState = .listReady + error = nil + activeOperation = nil + } catch { + failContract(workflow: .fsck, error: error) + } + case .planning: + do { + fsckPlan = try event.decodePayload(FsckPlanPayload.self) + fsckState = .planReady + error = nil + activeOperation = nil + } catch { + failContract(workflow: .fsck, error: error) + } + default: + do { + fsckResult = try event.decodePayload(FsckResultPayload.self) + fsckState = .succeeded + error = nil + activeOperation = nil + } catch { + failContract(workflow: .fsck, error: error) + } + } + } + + private func handleRepairResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(RepairXattrsPayload.self) + if repairState == .scanning { + repairScan = payload + repairState = .scanReady + activeOperation = nil + } else { + repairResult = payload + repairState = .repaired + activeOperation = nil + } + error = nil + } catch { + failContract(workflow: .repairXattrs, error: error) + } + } + + private func applyError(_ event: BackendEvent) { + if event.code == "confirmation_required" { + error = nil + switch event.operation { + case "activate": + activateState = .awaitingConfirmation + case "uninstall": + uninstallState = .awaitingConfirmation + case "fsck": + fsckState = .awaitingConfirmation + case "repair-xattrs": + repairState = .awaitingConfirmation + default: + break + } + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation?.profileID + } + error = BackendErrorViewModel(event: event) + failState(for: event.operation) + } + + private func applyFalseResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + failState(for: event.operation) + } + + private func failContract(workflow: MaintenanceWorkflow, error: Error) { + self.error = BackendErrorViewModel( + operation: operationName(for: workflow), + code: "contract_decode_failed", + message: error.localizedDescription + ) + setState(.failed, for: workflow) + activeOperation = nil + } + + private func failLocally(workflow: MaintenanceWorkflow, message: String) { + error = BackendErrorViewModel( + operation: operationName(for: workflow), + code: "validation_failed", + message: message + ) + selectedWorkflow = workflow + currentStage = nil + setState(.failed, for: workflow) + activeOperation = nil + } + + private func rejectRun(workflow: MaintenanceWorkflow, message: String) { + error = BackendErrorViewModel( + operation: operationName(for: workflow), + code: "operation_rejected", + message: message + ) + selectedWorkflow = workflow + currentStage = nil + setState(.failed, for: workflow) + activeOperation = nil + } + + private func failState(for operation: String) { + switch operation { + case "activate": + activateState = .failed + case "uninstall": + uninstallState = .failed + case "fsck": + fsckState = .failed + case "repair-xattrs": + repairState = .failed + default: + break + } + activeOperation = nil + } + + private func setState(_ state: MaintenanceOperationState, for workflow: MaintenanceWorkflow) { + switch workflow { + case .activate: + activateState = state + case .uninstall: + uninstallState = state + case .fsck: + fsckState = state + case .repairXattrs: + repairState = state + } + } + + private func operationName(for workflow: MaintenanceWorkflow) -> String { + switch workflow { + case .activate: + return "activate" + case .uninstall: + return "uninstall" + case .fsck: + return "fsck" + case .repairXattrs: + return "repair-xattrs" + } + } + + private func markPlansStaleForOptionChange() { + if uninstallState == .planReady, currentOptions != plannedUninstallOptions { + uninstallState = .planStale + } + markFsckPlanStaleIfNeeded() + } + + private func markFsckPlanStaleIfNeeded() { + if fsckState == .planReady, + currentOptions != plannedFsckOptions || selectedFsckTargetID != plannedFsckTargetID { + fsckState = .planStale + } + } + + private func markRepairStaleForPathChange() { + if repairState == .scanReady, scannedRepairPath != trimmedRepairPath { + repairState = .scanStale + } + } + + private func nonNegativeInteger(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), value >= 0 else { + return nil + } + return value + } + + private func startRun( + operation: String, + params: [String: JSONValue], + profile: DeviceProfile?, + workflow: MaintenanceWorkflow + ) -> OperationStartResult { + guard !backend.isRunning else { + let message = "Another operation is already running." + rejectRun(workflow: workflow, message: message) + return .rejected(message) + } + resetRunState() + let start = run(operation: operation, params: params, profile: profile) + switch start { + case .started(let operation): + activeOperation = operation + case .rejected(let message): + rejectRun(workflow: workflow, message: message) + } + return start + } + + private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { + if let coordinator { + return coordinator.run(operation: operation, params: params, profile: profile) + } else { + guard !backend.isRunning else { + return .rejected("Another operation is already running.") + } + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run(operation: operation, params: params, context: context) + return .started(activeOperation) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift new file mode 100644 index 0000000..93fb23a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift @@ -0,0 +1,282 @@ +import SwiftUI + +struct MaintenanceView: View { + @ObservedObject var store: MaintenanceStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.maintenance")) + .font(.title2.weight(.semibold)) + + Picker("Maintenance", selection: $store.selectedWorkflow) { + ForEach(MaintenanceWorkflow.allCases) { workflow in + Text(workflow.title).tag(workflow) + } + } + .pickerStyle(.segmented) + + sharedOptions + + switch store.selectedWorkflow { + case .activate: + activatePanel + case .uninstall: + uninstallPanel + case .fsck: + fsckPanel + case .repairXattrs: + repairPanel + } + + if let stage = store.currentStage { + StageLine(stage: stage) + } + + if let error = store.error { + MaintenanceErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var sharedOptions: some View { + HStack { + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + } + } + + private var activatePanel: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + store.planActivation(password: password) + } label: { + Label("Plan Activation", systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning) + + Button { + store.runActivation(password: password) + } label: { + Label(L10n.string("button.activate"), systemImage: "power") + } + .disabled(!store.canRunActivation) + + StatusLabel(state: store.activateState) + } + + if let plan = store.activationPlan { + Text("\(plan.actions.count) action(s), \(plan.postActivationChecks.count) post-check(s)") + .font(.caption) + .foregroundStyle(.secondary) + } + if let result = store.activationResult { + Text(result.summary) + .font(.caption) + if let message = result.message { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var uninstallPanel: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + store.planUninstall(password: password) + } label: { + Label(L10n.string("button.uninstall_plan"), systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + + Button { + store.runUninstall(password: password) + } label: { + Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") + } + .disabled(!store.canRunUninstall) + + StatusLabel(state: store.uninstallState) + } + + if let plan = store.uninstallPlan { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Host").foregroundStyle(.secondary) + Text(plan.host) + } + GridRow { + Text("Reboot").foregroundStyle(.secondary) + Text(plan.requiresReboot ? "required" : "not required") + } + GridRow { + Text("Payload Dirs").foregroundStyle(.secondary) + Text(plan.payloadDirs.joined(separator: ", ")) + .lineLimit(1) + .truncationMode(.middle) + } + } + .font(.caption) + } + if let result = store.uninstallResult { + Text("\(result.summary) rebooted: \(yesNo(result.rebooted)), waited: \(yesNo(result.waited)), verified: \(yesNo(result.verified))") + .font(.caption) + } + } + } + + private var fsckPanel: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + store.refreshFsckTargets(password: password) + } label: { + Label(L10n.string("button.list_fsck_volumes"), systemImage: "list.bullet.rectangle") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + + Button { + store.planFsck(password: password) + } label: { + Label(L10n.string("button.plan_fsck"), systemImage: "doc.text.magnifyingglass") + } + .disabled(!store.canPlanFsck) + + Button { + store.runFsck(password: password) + } label: { + Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") + } + .disabled(!store.canRunFsck) + + StatusLabel(state: store.fsckState) + } + + if !store.fsckTargets.isEmpty { + Picker("Volume", selection: $store.selectedFsckTargetID) { + Text("Select volume").tag(Optional.none) + ForEach(store.fsckTargets) { target in + Text("\(target.device) on \(target.mountpoint)").tag(Optional(target.id)) + } + } + .frame(maxWidth: 520) + } + if let plan = store.fsckPlan { + Text("Plan: \(plan.device) on \(plan.mountpoint), reboot: \(yesNo(plan.rebootRequired)), wait: \(yesNo(plan.waitAfterReboot))") + .font(.caption) + } + if let result = store.fsckResult { + Text("Result: \(result.device) return \(result.returncode.map(String.init) ?? "n/a"), waited: \(yesNo(result.waited)), verified: \(yesNo(result.verified))") + .font(.caption) + } + } + } + + private var repairPanel: some View { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.string("field.repair_xattrs_path"), text: $store.repairPath) + HStack { + Button { + store.scanRepairXattrs() + } label: { + Label(L10n.string("button.scan_xattrs"), systemImage: "wand.and.stars") + } + .disabled(store.isRunning || store.repairPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button { + store.runRepairXattrs() + } label: { + Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") + } + .disabled(!store.canRepairXattrs) + + StatusLabel(state: store.repairState) + } + + if let scan = store.repairScan { + Text("Scan: \(scan.findingCount) finding(s), \(scan.repairableCount) repairable.") + .font(.caption) + if let report = scan.report, !report.isEmpty { + Text(report) + .font(.system(.caption, design: .monospaced)) + .lineLimit(4) + .foregroundStyle(.secondary) + } + } + if let result = store.repairResult { + Text("Repair: \(result.summary)") + .font(.caption) + } + } + } + + private func yesNo(_ value: Bool?) -> String { + value == true ? "yes" : "no" + } +} + +private struct StatusLabel: View { + let state: MaintenanceOperationState + + var body: some View { + Label(state.title, systemImage: icon) + .foregroundStyle(color) + } + + private var icon: String { + switch state { + case .idle: + return "circle" + case .loading, .planning, .scanning, .running, .repairing: + return "hourglass" + case .listReady, .planReady, .scanReady, .succeeded, .repaired: + return "checkmark.circle" + case .planStale, .scanStale, .awaitingConfirmation: + return "exclamationmark.circle" + case .failed: + return "exclamationmark.triangle" + } + } + + private var color: Color { + switch state { + case .listReady, .planReady, .scanReady, .succeeded, .repaired: + return .green + case .planStale, .scanStale, .awaitingConfirmation: + return .orange + case .failed: + return .red + default: + return .secondary + } + } +} + +private struct MaintenanceErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift new file mode 100644 index 0000000..6037582 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -0,0 +1,209 @@ +import Foundation + +public enum JSONValue: Codable, Hashable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + self = .array(try container.decode([JSONValue].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + public var displayText: String { + switch self { + case .string(let value): + return value + case .number(let value): + return String(value) + case .bool(let value): + return value ? "true" : "false" + case .object, .array: + guard + let data = try? JSONEncoder().encode(self), + let text = String(data: data, encoding: .utf8) + else { + return "" + } + return text + case .null: + return "null" + } + } + + public func stringValue(for key: String) -> String? { + guard case .object(let values) = self, case .string(let value)? = values[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value + } +} + +public struct BackendEvent: Decodable, Identifiable, Sendable { + public let id = UUID() + public let schemaVersion: Int? + public let requestId: String? + public let type: String + public let operation: String + public let code: String? + public let stage: String? + public let level: String? + public let message: String? + public let status: String? + public let ok: Bool? + public let payload: JSONValue? + public let details: JSONValue? + public let debug: JSONValue? + public let recovery: JSONValue? + public let risk: String? + public let cancellable: Bool? + public let description: String? + + public init( + schemaVersion: Int? = 1, + requestId: String? = UUID().uuidString, + type: String, + operation: String, + code: String? = nil, + stage: String? = nil, + level: String? = nil, + message: String? = nil, + status: String? = nil, + ok: Bool? = nil, + payload: JSONValue? = nil, + details: JSONValue? = nil, + debug: JSONValue? = nil, + recovery: JSONValue? = nil, + risk: String? = nil, + cancellable: Bool? = nil, + description: String? = nil + ) { + self.schemaVersion = schemaVersion + self.requestId = requestId + self.type = type + self.operation = operation + self.code = code + self.stage = stage + self.level = level + self.message = message + self.status = status + self.ok = ok + self.payload = payload + self.details = details + self.debug = debug + self.recovery = recovery + self.risk = risk + self.cancellable = cancellable + self.description = description + } + + public static func error( + operation: String, + code: String, + message: String, + debug: JSONValue? = nil + ) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: code, + message: message, + debug: debug + ) + } + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case requestId = "request_id" + case type + case operation + case code + case stage + case level + case message + case status + case ok + case payload + case details + case debug + case recovery + case risk + case cancellable + case description + } + + public var summary: String { + switch type { + case "stage": + return stage.map { L10n.format("event.summary.stage", operation, $0) } ?? operation + case "check": + return L10n.format( + "event.summary.check", + status ?? L10n.string("event.summary.check.default_status"), + message ?? "" + ) + case "result": + if let payloadSummary = payloadSummary { + return payloadSummary + } + let result = ok == true + ? L10n.string("event.summary.result.finished") + : L10n.string("event.summary.result.failed") + return L10n.format("event.summary.result", operation, result) + case "error": + return L10n.format( + "event.summary.error", + operation, + message ?? L10n.string("event.summary.error.default_message") + ) + default: + return message ?? stage ?? operation + } + } + + private var payloadSummary: String? { + guard let payload else { + return nil + } + for key in ["summary", "message", "summary_text"] { + if let value = payload.stringValue(for: key) { + return value + } + } + return nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift new file mode 100644 index 0000000..505c753 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift @@ -0,0 +1,124 @@ +import Combine +import Foundation + +struct ActiveOperation: Equatable, Identifiable { + let id: UUID + let operation: String + let profileID: DeviceProfile.ID? + let context: DeviceRuntimeContext? + + init( + id: UUID = UUID(), + operation: String, + profileID: DeviceProfile.ID?, + context: DeviceRuntimeContext? + ) { + self.id = id + self.operation = operation + self.profileID = profileID + self.context = context + } +} + +enum OperationStartResult: Equatable { + case started(ActiveOperation) + case rejected(String) + + var operation: ActiveOperation? { + guard case .started(let operation) = self else { + return nil + } + return operation + } + + var rejectionMessage: String? { + guard case .rejected(let message) = self else { + return nil + } + return message + } +} + +@MainActor +final class OperationCoordinator: ObservableObject { + @Published private(set) var activeOperation: ActiveOperation? + @Published private(set) var activeDeviceID: DeviceProfile.ID? + @Published private(set) var rejectedOperationMessage: String? + + let backend: BackendClient + + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + self?.activeOperation = nil + self?.activeDeviceID = nil + } + .store(in: &cancellables) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + password: password + ) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + context: DeviceRuntimeContext?, + activeDeviceID: DeviceProfile.ID?, + password: String? = nil + ) -> OperationStartResult { + guard !backend.isRunning else { + let message = L10n.string("operation.error.already_running") + rejectedOperationMessage = message + return .rejected(message) + } + var updatedParams = params + if let password, + !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + updatedParams["credentials"] == nil { + updatedParams["credentials"] = .object(["password": .string(password)]) + } + let activeOperation = ActiveOperation( + operation: operation, + profileID: activeDeviceID, + context: context + ) + rejectedOperationMessage = nil + self.activeOperation = activeOperation + self.activeDeviceID = activeDeviceID + backend.run(operation: operation, params: updatedParams, context: context) + return .started(activeOperation) + } + + func cancel() { + backend.cancel() + } + + func clear() { + backend.clear() + rejectedOperationMessage = nil + activeOperation = nil + activeDeviceID = nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift new file mode 100644 index 0000000..a8b3908 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -0,0 +1,163 @@ +import Foundation + +enum OperationParams { + private static func rootSSHTarget(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("@") else { + return trimmed + } + return "root@\(trimmed)" + } + + private static func withCredentials(_ params: [String: JSONValue], password: String) -> [String: JSONValue] { + let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return params + } + var updated = params + updated["credentials"] = .object(["password": .string(password)]) + return updated + } + + static func discover(timeout: Double) -> [String: JSONValue] { + ["timeout": .number(timeout)] + } + + static func configure( + host: String = "", + selectedRecord: JSONValue? = nil, + password: String, + debugLogging: Bool + ) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "password": .string(password), + "persist_password": .bool(false) + ] + if let selectedRecord { + params["selected_record"] = selectedRecord + } else { + params["host"] = .string(rootSSHTarget(host)) + } + if debugLogging { + params["debug_logging"] = .bool(true) + } + return params + } + + static func doctor( + bonjourTimeout: Double, + password: String, + skipSSH: Bool = false, + skipBonjour: Bool = false, + skipSMB: Bool = false + ) -> [String: JSONValue] { + withCredentials([ + "bonjour_timeout": .number(bonjourTimeout), + "skip_ssh": .bool(skipSSH), + "skip_bonjour": .bool(skipBonjour), + "skip_smb": .bool(skipSMB) + ], password: password) + } + + static func deployPlan( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWait: Double, + password: String + ) -> [String: JSONValue] { + withCredentials([ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait) + ], password: password) + } + + static func deployRun( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWait: Double, + password: String + ) -> [String: JSONValue] { + withCredentials([ + "dry_run": .bool(false), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait) + ], password: password) + } + + static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ], password: password) + } + + static func uninstallRun(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ + "dry_run": .bool(false), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ], password: password) + } + + static func activatePlan(password: String) -> [String: JSONValue] { + withCredentials(["dry_run": .bool(true)], password: password) + } + + static func activateRun(password: String) -> [String: JSONValue] { + withCredentials(["dry_run": .bool(false)], password: password) + } + + static func fsckList(mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ + "list_volumes": .bool(true), + "mount_wait": .number(mountWait) + ], password: password) + } + + static func fsckPlan(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), + "volume": .string(volume) + ], password: password) + } + + static func fsckRun(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), + "volume": .string(volume) + ], password: password) + } + + static func repairXattrsScan(path: String) -> [String: JSONValue] { + [ + "path": .string(path), + "dry_run": .bool(true) + ] + } + + static func repairXattrsRun(path: String) -> [String: JSONValue] { + [ + "path": .string(path), + "dry_run": .bool(false) + ] + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift new file mode 100644 index 0000000..75de9a6 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift @@ -0,0 +1,148 @@ +import Foundation + +struct OperationTimelineItem: Equatable, Identifiable { + enum State: String, Equatable { + case pending + case running + case succeeded + case warning + case failed + } + + let id: String + let operation: String + let title: String + let detail: String? + let state: State + let risk: String? + let cancellable: Bool? +} + +enum OperationTimelineBuilder { + static func timeline(from events: [BackendEvent]) -> [OperationTimelineItem] { + events.enumerated().compactMap { index, event in + switch event.type { + case "stage": + return OperationTimelineItem( + id: "\(index):\(event.operation):\(event.stage ?? "stage")", + operation: event.operation, + title: title(for: event.operation, stage: event.stage), + detail: event.description, + state: .running, + risk: event.risk, + cancellable: event.cancellable + ) + case "result": + return OperationTimelineItem( + id: "\(index):\(event.operation):result", + operation: event.operation, + title: event.ok == true ? L10n.string("timeline.result.done") : L10n.string("timeline.result.failed"), + detail: event.payloadSummaryText ?? event.summary, + state: event.ok == true ? .succeeded : .failed, + risk: nil, + cancellable: nil + ) + case "error": + return OperationTimelineItem( + id: "\(index):\(event.operation):error", + operation: event.operation, + title: event.code == "confirmation_required" + ? L10n.string("timeline.error.needs_confirmation") + : L10n.string("timeline.error.needs_attention"), + detail: event.message, + state: event.code == "confirmation_required" ? .warning : .failed, + risk: event.risk, + cancellable: event.cancellable + ) + default: + return nil + } + } + } + + static func operationTitle(_ operation: String) -> String { + switch operation { + case "discover": + return L10n.string("timeline.operation.discovery") + case "configure": + return L10n.string("timeline.operation.configure") + case "deploy": + return L10n.string("timeline.operation.deploy") + case "doctor": + return L10n.string("timeline.operation.doctor") + case "activate": + return L10n.string("timeline.operation.activate") + case "fsck": + return L10n.string("timeline.operation.fsck") + case "repair-xattrs": + return L10n.string("timeline.operation.repair_xattrs") + case "uninstall": + return L10n.string("timeline.operation.uninstall") + case "capabilities", "validate-install", "paths": + return L10n.string("timeline.operation.readiness") + case "flash": + return L10n.string("timeline.operation.flash") + default: + return operation + } + } + + private static func title(for operation: String, stage: String?) -> String { + guard let stage else { + return operationTitle(operation) + } + switch (operation, stage) { + case ("discover", "bonjour_discovery"): + return L10n.string("timeline.stage.finding_time_capsules") + case ("configure", "ssh_probe"), ("configure", "ssh_probe_after_acp"): + return L10n.string("timeline.stage.checking_ssh") + case ("configure", "acp_enable_ssh"): + return L10n.string("timeline.stage.enabling_ssh") + case ("configure", "wait_for_ssh_after_acp"): + return L10n.string("timeline.stage.waiting_for_device") + case ("configure", "write_env"): + return L10n.string("timeline.stage.saving_device") + case ("deploy", "build_deployment_plan"): + return L10n.string("timeline.stage.planning_install") + case ("deploy", "validate_artifacts"): + return L10n.string("timeline.stage.checking_bundled_files") + case ("deploy", "read_mast"), ("deploy", "select_payload_home"): + return L10n.string("timeline.stage.finding_disk") + case ("deploy", "upload_payload"): + return L10n.string("timeline.stage.uploading") + case ("deploy", "flush_payload_upload"): + return L10n.string("timeline.stage.syncing_to_disk") + case ("deploy", "reboot"), ("deploy", "wait_for_reboot_down"), ("deploy", "wait_for_reboot_up"): + return L10n.string("timeline.stage.rebooting") + case ("deploy", "netbsd4_activation"): + return L10n.string("timeline.stage.starting_smb") + case ("deploy", "verify_runtime_activation"), ("deploy", "verify_runtime_reboot"): + return L10n.string("timeline.stage.verifying_smb") + case ("doctor", "run_checks"): + return L10n.string("timeline.stage.running_checkup") + case ("activate", "build_activation_plan"): + return L10n.string("timeline.stage.planning_start_smb") + case ("activate", "run_activation"): + return L10n.string("timeline.stage.starting_smb") + case ("uninstall", "build_uninstall_plan"): + return L10n.string("timeline.stage.planning_uninstall") + case ("uninstall", "uninstall_payload"): + return L10n.string("timeline.stage.removing_managed_files") + case ("fsck", "read_mast"), ("fsck", "select_fsck_volume"): + return L10n.string("timeline.stage.finding_volumes") + case ("fsck", "run_fsck"): + return L10n.string("timeline.stage.repairing_disk") + case ("repair-xattrs", "scan_findings"): + return L10n.string("timeline.stage.scanning_metadata") + case ("repair-xattrs", "repair_findings"): + return L10n.string("timeline.stage.repairing_metadata") + case ("validate-install", "validate_install"): + return L10n.string("timeline.stage.validating_app_bundle") + default: + return stage + .split(separator: "_") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift new file mode 100644 index 0000000..50761c3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct OutputLineParser { + private var buffer = Data() + private let decoder = JSONDecoder() + + public init() { + } + + public mutating func append(_ data: Data) -> [BackendEvent] { + buffer.append(data) + return consumeCompleteLines() + } + + public mutating func finish() -> [BackendEvent] { + guard !buffer.isEmpty else { return [] } + let event = decode(buffer) + buffer.removeAll() + return event.map { [$0] } ?? [] + } + + private mutating func consumeCompleteLines() -> [BackendEvent] { + var events: [BackendEvent] = [] + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer.prefix(upTo: newline) + buffer.removeSubrange(...newline) + if let event = decode(line) { + events.append(event) + } + } + return events + } + + private func decode(_ line: Data.SubSequence) -> BackendEvent? { + guard !line.isEmpty else { return nil } + return try? decoder.decode(BackendEvent.self, from: Data(line)) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift new file mode 100644 index 0000000..da2e834 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift @@ -0,0 +1,207 @@ +import Foundation +import Security + +protocol KeychainClient: AnyObject { + func copyMatching(_ query: [String: Any], result: inout CFTypeRef?) -> OSStatus + func add(_ query: [String: Any]) -> OSStatus + func update(_ query: [String: Any], attributes: [String: Any]) -> OSStatus + func delete(_ query: [String: Any]) -> OSStatus + func message(for status: OSStatus) -> String? +} + +final class SystemKeychainClient: KeychainClient { + func copyMatching(_ query: [String: Any], result: inout CFTypeRef?) -> OSStatus { + SecItemCopyMatching(query as CFDictionary, &result) + } + + func add(_ query: [String: Any]) -> OSStatus { + SecItemAdd(query as CFDictionary, nil) + } + + func update(_ query: [String: Any], attributes: [String: Any]) -> OSStatus { + SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + } + + func delete(_ query: [String: Any]) -> OSStatus { + SecItemDelete(query as CFDictionary) + } + + func message(for status: OSStatus) -> String? { + SecCopyErrorMessageString(status, nil) as String? + } +} + +enum PasswordStoreError: Error, Equatable, LocalizedError { + case missing + case unavailable(String) + + var errorDescription: String? { + switch self { + case .missing: + return L10n.string("password.error.missing") + case .unavailable(let message): + return message + } + } +} + +protocol PasswordStore: AnyObject { + func password(for account: String) throws -> String + func save(_ password: String, for account: String) throws + func deletePassword(for account: String) throws + func state(for account: String) -> DevicePasswordState +} + +final class KeychainPasswordStore: PasswordStore { + static let service = "TimeCapsuleSMB.DevicePassword" + + private let service: String + private let accessibility: CFString + private let keychainClient: KeychainClient + + init( + service: String = KeychainPasswordStore.service, + accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + keychainClient: KeychainClient = SystemKeychainClient() + ) { + self.service = service + self.accessibility = accessibility + self.keychainClient = keychainClient + } + + func password(for account: String) throws -> String { + var query = baseQuery(account: account) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnData as String] = true + + var result: CFTypeRef? + let status = keychainClient.copyMatching(query, result: &result) + if status == errSecItemNotFound { + throw PasswordStoreError.missing + } + guard status == errSecSuccess else { + throw PasswordStoreError.unavailable(message(for: status)) + } + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + throw PasswordStoreError.unavailable(L10n.string("password.error.unreadable_keychain_item")) + } + return password + } + + func save(_ password: String, for account: String) throws { + let data = Data(password.utf8) + var query = baseQuery(account: account) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: accessibility + ] + let status = keychainClient.update(query, attributes: attributes) + if status == errSecSuccess { + return + } + if status != errSecItemNotFound { + throw PasswordStoreError.unavailable(message(for: status)) + } + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = accessibility + let addStatus = keychainClient.add(query) + guard addStatus == errSecSuccess else { + throw PasswordStoreError.unavailable(message(for: addStatus)) + } + } + + func deletePassword(for account: String) throws { + let status = keychainClient.delete(baseQuery(account: account)) + if status == errSecSuccess || status == errSecItemNotFound { + return + } + throw PasswordStoreError.unavailable(message(for: status)) + } + + func state(for account: String) -> DevicePasswordState { + do { + _ = try password(for: account) + return .available + } catch PasswordStoreError.missing { + return .missing + } catch { + return .keychainUnavailable + } + } + + private func baseQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } + + private func message(for status: OSStatus) -> String { + if let message = keychainClient.message(for: status) { + return message + } + return L10n.format("password.error.keychain_status", status) + } +} + +final class InMemoryPasswordStore: PasswordStore { + enum Failure: Error { + case read + case save + case delete + } + + var readFailure: Failure? + var saveFailure: Failure? + var deleteFailure: Failure? + + private var passwords: [String: String] + private var invalidAccounts: Set + + init(passwords: [String: String] = [:], invalidAccounts: Set = []) { + self.passwords = passwords + self.invalidAccounts = invalidAccounts + } + + func password(for account: String) throws -> String { + if readFailure != nil { + throw PasswordStoreError.unavailable(L10n.string("password.error.memory_read_failed")) + } + guard let password = passwords[account] else { + throw PasswordStoreError.missing + } + return password + } + + func save(_ password: String, for account: String) throws { + if saveFailure != nil { + throw PasswordStoreError.unavailable(L10n.string("password.error.memory_save_failed")) + } + passwords[account] = password + invalidAccounts.remove(account) + } + + func deletePassword(for account: String) throws { + if deleteFailure != nil { + throw PasswordStoreError.unavailable(L10n.string("password.error.memory_delete_failed")) + } + passwords.removeValue(forKey: account) + invalidAccounts.remove(account) + } + + func markInvalid(account: String) { + invalidAccounts.insert(account) + } + + func state(for account: String) -> DevicePasswordState { + if readFailure != nil { + return .keychainUnavailable + } + if invalidAccounts.contains(account) { + return .invalid + } + return passwords[account] == nil ? .missing : .available + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift new file mode 100644 index 0000000..6bc0119 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -0,0 +1,43 @@ +import Foundation + +struct PendingConfirmation: Identifiable { + let id = UUID() + let title: String + let message: String + let actionTitle: String + let operation: String + let params: [String: JSONValue] + let context: DeviceRuntimeContext? + + init?( + confirmationEvent event: BackendEvent, + originalParams: [String: JSONValue], + context: DeviceRuntimeContext? = nil + ) { + guard + event.type == "error", + event.code == "confirmation_required", + case .object(let details)? = event.details, + case .string(let confirmationId)? = details["confirmation_id"] + else { + return nil + } + + self.title = Self.detailString(details, "title") ?? L10n.string("confirm.backend.title") + self.message = Self.detailString(details, "message") ?? event.message ?? L10n.string("confirm.backend.message") + self.actionTitle = Self.detailString(details, "action_title") ?? L10n.string("action.confirm") + self.operation = event.operation + var confirmedParams = originalParams + confirmedParams["confirmation_id"] = .string(confirmationId) + self.params = confirmedParams + self.context = context + } + + private static func detailString(_ details: [String: JSONValue], _ key: String) -> String? { + guard case .string(let value)? = details[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift new file mode 100644 index 0000000..41a1f75 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift @@ -0,0 +1,216 @@ +import Combine +import Foundation + +enum ReadinessOperationState: String, CaseIterable, Equatable { + case idle + case running + case succeeded + case failed + + var title: String { + switch self { + case .idle: + return "Idle" + case .running: + return "Running" + case .succeeded: + return "Succeeded" + case .failed: + return "Failed" + } + } +} + +@MainActor +final class ReadinessStore: ObservableObject { + @Published private(set) var capabilitiesState: ReadinessOperationState = .idle + @Published private(set) var pathsState: ReadinessOperationState = .idle + @Published private(set) var validationState: ReadinessOperationState = .idle + @Published private(set) var capabilities: CapabilitiesPayload? + @Published private(set) var paths: PathsPayload? + @Published private(set) var validation: InstallValidationPayload? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let backend: BackendClient + + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + func runCapabilities() { + run(operation: "capabilities") + capabilitiesState = .running + } + + func runPaths() { + run(operation: "paths") + pathsState = .running + } + + func runValidateInstall() { + run(operation: "validate-install") + validationState = .running + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + capabilitiesState = .idle + pathsState = .idle + validationState = .idle + capabilities = nil + paths = nil + validation = nil + error = nil + currentStage = nil + } + + func cancel() { + backend.cancel() + } + + private func run(operation: String) { + backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + backend.run(operation: operation) + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard ["capabilities", "paths", "validate-install"].contains(event.operation) else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + + switch event.operation { + case "capabilities": + applyCapabilitiesResult(event) + case "paths": + applyPathsResult(event) + case "validate-install": + applyValidationResult(event) + default: + break + } + } + + private func applyCapabilitiesResult(_ event: BackendEvent) { + do { + capabilities = try event.decodePayload(CapabilitiesPayload.self) + capabilitiesState = event.ok == true ? .succeeded : .failed + error = event.ok == true ? nil : BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + } catch { + failContract(operation: "capabilities", error: error) + } + } + + private func applyPathsResult(_ event: BackendEvent) { + do { + paths = try event.decodePayload(PathsPayload.self) + pathsState = event.ok == true ? .succeeded : .failed + error = event.ok == true ? nil : BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + } catch { + failContract(operation: "paths", error: error) + } + } + + private func applyValidationResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(InstallValidationPayload.self) + validation = payload + validationState = payload.ok ? .succeeded : .failed + error = nil + } catch { + failContract(operation: "validate-install", error: error) + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + setState(.failed, for: event.operation) + } + + private func failContract(operation: String, error: Error) { + self.error = BackendErrorViewModel( + operation: operation, + code: "contract_decode_failed", + message: error.localizedDescription + ) + setState(.failed, for: operation) + } + + private func setState(_ state: ReadinessOperationState, for operation: String) { + switch operation { + case "capabilities": + capabilitiesState = state + case "paths": + pathsState = state + case "validate-install": + validationState = state + default: + break + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift new file mode 100644 index 0000000..b93680f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct ReadinessView: View { + @ObservedObject var store: ReadinessStore + @Binding var helperPath: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.readiness")) + .font(.title2.weight(.semibold)) + + TextField(L10n.string("field.helper"), text: $helperPath) + + HStack { + readinessButton( + L10n.string("button.capabilities"), + icon: "info.circle", + state: store.capabilitiesState, + action: store.runCapabilities + ) + readinessButton( + L10n.string("button.paths"), + icon: "folder", + state: store.pathsState, + action: store.runPaths + ) + readinessButton( + L10n.string("button.validate"), + icon: "checkmark.seal", + state: store.validationState, + action: store.runValidateInstall + ) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let capabilities = store.capabilities { + CapabilitiesSummaryView(payload: capabilities) + } + + if let paths = store.paths { + PathsSummaryView(payload: paths) + } + + if let validation = store.validation { + ValidationSummaryView(payload: validation) + } + + if let error = store.error { + ReadinessErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func readinessButton( + _ title: String, + icon: String, + state: ReadinessOperationState, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label("\(title) (\(state.title))", systemImage: icon) + } + .disabled(store.isRunning) + } +} + +private struct CapabilitiesSummaryView: View { + let payload: CapabilitiesPayload + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Helper").foregroundStyle(.secondary) + Text("\(payload.helperVersion) (\(payload.helperVersionCode))") + } + GridRow { + Text("API Schema").foregroundStyle(.secondary) + Text(String(payload.apiSchemaVersion)) + } + GridRow { + Text("Confirmations").foregroundStyle(.secondary) + Text(String(payload.confirmationSchemaVersion)) + } + GridRow { + Text("Operations").foregroundStyle(.secondary) + Text(payload.operations.joined(separator: ", ")) + .lineLimit(2) + } + } + .font(.caption) + } +} + +private struct PathsSummaryView: View { + let payload: PathsPayload + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Distribution").foregroundStyle(.secondary) + Text(payload.distributionRoot).lineLimit(1).truncationMode(.middle) + } + GridRow { + Text("Config").foregroundStyle(.secondary) + Text(payload.configPath).lineLimit(1).truncationMode(.middle) + } + GridRow { + Text("State").foregroundStyle(.secondary) + Text(payload.stateDir).lineLimit(1).truncationMode(.middle) + } + } + if !payload.artifacts.isEmpty { + Text("Artifacts") + .font(.body.weight(.medium)) + ForEach(payload.artifacts, id: \.name) { artifact in + HStack { + Image(systemName: artifact.ok ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(artifact.ok ? .green : .red) + Text(artifact.name) + Text(artifact.repoRelativePath) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(artifact.message) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .font(.caption) + } + } + } + .font(.caption) + } +} + +private struct ValidationSummaryView: View { + let payload: InstallValidationPayload + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: payload.ok ? "checkmark.seal" : "xmark.seal") + .foregroundStyle(payload.ok ? .green : .red) + Text(payload.summary) + Text("\(payload.counts["pass"] ?? 0) passed, \(payload.counts["fail"] ?? 0) failed") + .foregroundStyle(.secondary) + } + ForEach(payload.checks, id: \.id) { check in + HStack { + Image(systemName: check.ok ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(check.ok ? .green : .red) + Text(check.id) + Text(check.message) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .font(.caption) + } + } + .font(.caption) + } +} + +private struct ReadinessErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift new file mode 100644 index 0000000..e637b56 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift @@ -0,0 +1,116 @@ +import Foundation + +enum RecoveryActionKind: String, Equatable { + case retry + case runCheckup = "run_checkup" + case installSMB = "install_smb" + case startSMB = "start_smb" + case uninstall + case diskRepair = "disk_repair" + case metadataRepair = "repair_metadata" + case openFinder = "open_finder" + case replacePassword = "replace_password" + case copyDiagnostics = "copy_diagnostics" + case diagnostics = "open_diagnostics" + case generic +} + +struct RecoveryAction: Equatable, Identifiable { + var id: String { + "\(kind.rawValue):\(title)" + } + + let title: String + let kind: RecoveryActionKind +} + +enum RecoveryActionMapper { + static func actions(for error: BackendErrorViewModel) -> [RecoveryAction] { + var actions: [RecoveryAction] = [] + if error.code == "auth_failed" { + actions.append(action(for: .replacePassword)) + } + + for actionID in error.recovery?.actionIDs ?? [] { + guard let kind = RecoveryActionKind(rawValue: actionID), kind != .generic else { + continue + } + actions.append(action(for: kind)) + } + + if let suggested = error.recovery?.suggestedOperation { + actions.append(action(forSuggestedOperation: suggested)) + } + + if error.recovery?.retryable == true || error.code == "operation_failed" { + actions.append(action(for: .retry)) + } + actions.append(action(for: .copyDiagnostics)) + return deduplicated(actions) + } + + private static func action(forSuggestedOperation operation: String) -> RecoveryAction { + switch operation { + case "doctor": + return action(for: .runCheckup) + case "deploy": + return action(for: .installSMB) + case "activate": + return action(for: .startSMB) + case "uninstall": + return action(for: .uninstall) + case "fsck": + return action(for: .diskRepair) + case "repair-xattrs": + return action(for: .metadataRepair) + case "validate-install": + return action(for: .diagnostics) + default: + return RecoveryAction(title: operation, kind: .generic) + } + } + + private static func action(for kind: RecoveryActionKind) -> RecoveryAction { + RecoveryAction(title: title(for: kind), kind: kind) + } + + private static func title(for kind: RecoveryActionKind) -> String { + switch kind { + case .retry: + return L10n.string("recovery.action.retry") + case .runCheckup: + return L10n.string("recovery.action.run_checkup") + case .installSMB: + return L10n.string("recovery.action.install_smb") + case .startSMB: + return L10n.string("recovery.action.start_smb") + case .uninstall: + return L10n.string("recovery.action.uninstall") + case .diskRepair: + return L10n.string("recovery.action.disk_repair") + case .metadataRepair: + return L10n.string("recovery.action.metadata_repair") + case .openFinder: + return L10n.string("recovery.action.open_finder") + case .replacePassword: + return L10n.string("recovery.action.replace_password") + case .copyDiagnostics: + return L10n.string("recovery.action.copy_diagnostics") + case .diagnostics: + return L10n.string("recovery.action.open_diagnostics") + case .generic: + return L10n.string("recovery.action.open") + } + } + + private static func deduplicated(_ actions: [RecoveryAction]) -> [RecoveryAction] { + var seen: Set = [] + var output: [RecoveryAction] = [] + for action in actions { + if seen.insert(action.id).inserted { + output.append(action) + } + } + return output + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..5d1c9b5 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -0,0 +1,319 @@ +"action.activate" = "Activate"; +"action.cancel" = "Cancel"; +"action.confirm" = "Confirm"; +"action.deploy" = "Deploy"; +"action.deploy_allow_reboot" = "Deploy And Allow Reboot"; +"action.done" = "Done"; +"action.ok" = "OK"; +"action.repair_xattrs" = "Repair xattrs"; +"action.run_fsck" = "Run fsck"; +"action.uninstall" = "Uninstall"; +"add_device.connection_method" = "Connection Method"; +"add_device.discover.placeholder" = "Browse for AirPort Bonjour services"; +"add_device.discovered_devices" = "Discovered Devices"; +"add_device.entry.discover" = "Discover"; +"add_device.entry.manual" = "Manual Address"; +"add_device.error.choose_target" = "Choose a discovered device or enter a host."; +"add_device.error.invalid_bonjour_timeout" = "Bonjour timeout must be a non-negative number."; +"add_device.error.password_required" = "Time Capsule password is required."; +"add_device.host_or_ip" = "Host or IP"; +"add_device.password" = "Time Capsule password"; +"add_device.reset" = "Reset"; +"add_device.save_device" = "Save Device"; +"add_device.saved" = "Saved %@"; +"add_device.state.auth_failed" = "Password Rejected"; +"add_device.state.configuring" = "Configuring"; +"add_device.state.discovering" = "Discovering"; +"add_device.state.discovery_empty" = "No Devices Found"; +"add_device.state.discovery_ready" = "Devices Found"; +"add_device.state.failed" = "Failed"; +"add_device.state.idle" = "Idle"; +"add_device.state.manual_entry" = "Manual Address"; +"add_device.state.password_entry" = "Password Required"; +"add_device.state.saved" = "Saved"; +"add_device.state.saving_profile" = "Saving"; +"add_device.state.unsupported" = "Unsupported"; +"add_device.title" = "Add Time Capsule"; +"advanced.config" = "Config"; +"advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; +"advanced.flash_help" = "Use `.venv/bin/tcapsule flash --help` for firmware operations."; +"advanced.helper" = "Helper"; +"advanced.profile_id" = "Profile ID"; +"app_readiness.state.blocked" = "Blocked"; +"app_readiness.state.checking_capabilities" = "Checking helper"; +"app_readiness.state.degraded" = "Degraded"; +"app_readiness.state.idle" = "Idle"; +"app_readiness.state.ready" = "Ready"; +"app_readiness.state.resolving_bundle" = "Preparing app runtime"; +"app_readiness.state.validating_install" = "Validating bundled files"; +"app_readiness.error.unexpected_payload" = "%@ returned an unexpected payload: %@"; +"app_readiness.recovery.contract_mismatch" = "Update or reinstall TimeCapsuleSMB so the app and helper use the same API contract."; +"app_readiness.recovery.helper_missing" = "Reinstall TimeCapsuleSMB or choose a valid helper in Diagnostics."; +"app_readiness.recovery.install_validation_failed" = "Reinstall TimeCapsuleSMB or open Diagnostics for the failed checks."; +"app_readiness.recovery.retry_diagnostics" = "Open Diagnostics and retry app readiness."; +"button.activate" = "Activate"; +"button.capabilities" = "Capabilities"; +"button.configure" = "Configure"; +"button.deploy" = "Deploy"; +"button.discover" = "Discover"; +"button.list_fsck_volumes" = "List fsck Volumes"; +"button.paths" = "Paths"; +"button.plan_deploy" = "Plan Deploy"; +"button.plan_fsck" = "Plan fsck"; +"button.repair_xattrs" = "Repair xattrs"; +"button.run_doctor" = "Run Doctor"; +"button.run_fsck" = "Run fsck"; +"button.scan_xattrs" = "Scan xattrs"; +"button.uninstall" = "Uninstall"; +"button.uninstall_plan" = "Uninstall Plan"; +"button.validate" = "Validate"; +"checkup.presentation.headline.failed" = "Checkup failed."; +"checkup.presentation.headline.idle" = "Run a checkup to inspect this Time Capsule."; +"checkup.presentation.headline.passed" = "Checkup passed."; +"checkup.presentation.headline.run_failed" = "Checkup could not complete."; +"checkup.presentation.headline.running" = "Checkup is running."; +"checkup.presentation.headline.warning" = "Checkup found warnings."; +"checkup.presentation.row.fail" = "Fail"; +"checkup.presentation.row.info" = "Info"; +"checkup.presentation.row.pass" = "Pass"; +"checkup.presentation.row.warning" = "Warning"; +"confirm.activate.message" = "This will restart the deployed Samba runtime on an older NetBSD 4 device."; +"confirm.activate.title" = "Activate NetBSD 4 Runtime?"; +"confirm.backend.message" = "Confirm this operation."; +"confirm.backend.title" = "Confirm Operation"; +"confirm.deploy.no_reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device."; +"confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; +"confirm.deploy.no_wait.message" = "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device."; +"confirm.deploy.no_wait.title" = "Deploy And Skip Waiting?"; +"confirm.deploy.reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately."; +"confirm.deploy.reboot.title" = "Deploy And Reboot?"; +"confirm.fsck.no_reboot.message" = "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward."; +"confirm.fsck.no_reboot.title" = "Run Disk Repair Without Reboot?"; +"confirm.fsck.no_wait.message" = "This will run fsck on the selected Time Capsule disk and return after requesting reboot."; +"confirm.fsck.no_wait.title" = "Run Disk Repair And Skip Waiting?"; +"confirm.fsck.reboot.message" = "This will run fsck on the selected Time Capsule disk and wait for the device to reboot."; +"confirm.fsck.reboot.title" = "Run Disk Repair And Reboot?"; +"confirm.repair_xattrs.message" = "This will repair extended attributes at the selected mounted SMB path."; +"confirm.repair_xattrs.title" = "Repair Extended Attributes?"; +"confirm.uninstall.no_reboot.message" = "This will remove the managed TimeCapsuleSMB payload without rebooting the device."; +"confirm.uninstall.no_reboot.title" = "Uninstall Without Reboot?"; +"confirm.uninstall.no_wait.message" = "This will remove the managed TimeCapsuleSMB payload, request reboot, and return without waiting."; +"confirm.uninstall.no_wait.title" = "Uninstall And Skip Waiting?"; +"confirm.uninstall.reboot.message" = "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."; +"confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; +"dashboard.action.install_smb" = "Install SMB"; +"dashboard.action.open_smb" = "Open SMB Address"; +"dashboard.action.replace_password" = "Replace Password"; +"dashboard.action.run_checkup" = "Run Checkup"; +"dashboard.action.save_password" = "Save Password"; +"dashboard.action.view_checkup" = "View Checkup"; +"dashboard.overview.generation" = "Generation"; +"dashboard.overview.host" = "Host"; +"dashboard.overview.last_checkup" = "Last Checkup"; +"dashboard.overview.last_install" = "Last Install"; +"dashboard.overview.model" = "Model"; +"dashboard.overview.password" = "Password"; +"dashboard.overview.payload" = "Payload"; +"dashboard.overview.status" = "Status"; +"dashboard.replacement_password" = "Replacement password"; +"dashboard.tab.advanced" = "Advanced"; +"dashboard.tab.checkup" = "Checkup"; +"dashboard.tab.install" = "Install / Update"; +"dashboard.tab.maintenance" = "Maintenance"; +"dashboard.tab.overview" = "Overview"; +"deploy.action.plan_install" = "Plan Install"; +"deploy.advanced_plan_details" = "Advanced Plan Details"; +"deploy.presentation.expected_changes" = "%d file upload(s), %d install action(s)"; +"deploy.presentation.row.activation_actions" = "Activation Actions"; +"deploy.presentation.row.disk_location" = "Disk Location"; +"deploy.presentation.row.expected_changes" = "Expected Changes"; +"deploy.presentation.row.host" = "Host"; +"deploy.presentation.row.payload" = "Payload"; +"deploy.presentation.row.payload_directory" = "Payload Directory"; +"deploy.presentation.row.post_install_checks" = "Post-install Checks"; +"deploy.presentation.row.post_upload_actions" = "Post-upload Actions"; +"deploy.presentation.row.pre_upload_actions" = "Pre-upload Actions"; +"deploy.presentation.row.reboot" = "Reboot"; +"deploy.presentation.row.target" = "Target"; +"deploy.presentation.title.netbsd4" = "Install SMB and Start Runtime"; +"deploy.presentation.title.standard" = "Install SMB"; +"deploy.presentation.warning.netbsd4_activation" = "This NetBSD4 device may need Start SMB after future reboots unless the boot hook is patched."; +"deploy.result.default_message" = "Install completed."; +"deploy.result.message" = "Message"; +"deploy.result.reboot_requested" = "Reboot Requested"; +"deploy.result.verified" = "Verified"; +"diagnostics.backend_events" = "Backend Events"; +"diagnostics.distribution" = "Distribution"; +"diagnostics.helper" = "Helper"; +"diagnostics.runtime_issues" = "Runtime Issues"; +"diagnostics.state" = "State"; +"diagnostics.title" = "Diagnostics"; +"diagnostics.validation" = "Validation"; +"dialog.forget.action" = "Forget %@"; +"dialog.forget.error_title" = "Could Not Forget Time Capsule"; +"dialog.forget.message" = "Remove %@ from this Mac. This does not uninstall SMB from the Time Capsule."; +"dialog.forget.title" = "Forget Time Capsule?"; +"event.summary.check" = "%@ %@"; +"event.summary.check.default_status" = "INFO"; +"event.summary.error" = "%@: %@"; +"event.summary.error.default_message" = "error"; +"event.summary.result" = "%@: %@"; +"event.summary.result.failed" = "failed"; +"event.summary.result.finished" = "finished"; +"event.summary.stage" = "%@: %@"; +"field.bonjour_timeout" = "Bonjour timeout seconds"; +"field.fsck_volume" = "fsck volume, optional"; +"field.helper" = "Helper"; +"field.host" = "Host"; +"field.mount_wait" = "Mount wait seconds"; +"field.password" = "Password"; +"field.repair_xattrs_path" = "Repair xattrs path"; +"helper.error.cancelled" = "Operation cancelled."; +"helper.error.missing_terminal_event" = "Helper exited without a result or error event."; +"host_warning.time_machine.message" = "macOS %d.%d.%d has known Time Machine network backup issues. SMB may work, but backup reliability can be affected by the host OS."; +"host_warning.time_machine.title" = "macOS Time Machine Warning"; +"activity.last_operation" = "Last operation"; +"activity.no_active_operation" = "No active operation"; +"maintenance.action.choose" = "Choose"; +"maintenance.action.choose_folder" = "Choose Folder"; +"maintenance.action.find_volumes" = "Find Volumes"; +"maintenance.action.plan_disk_repair" = "Plan Disk Repair"; +"maintenance.action.plan_start_smb" = "Plan Start SMB"; +"maintenance.action.plan_uninstall" = "Plan Uninstall"; +"maintenance.action.repair_metadata" = "Repair Metadata"; +"maintenance.action.run_disk_repair" = "Run Disk Repair"; +"maintenance.action.scan_metadata" = "Scan Metadata"; +"maintenance.action.start_smb" = "Start SMB"; +"maintenance.action.uninstall" = "Uninstall"; +"maintenance.presentation.activate.primary_action" = "Start SMB"; +"maintenance.presentation.activate.subtitle" = "Start the deployed SMB runtime on a NetBSD4 Time Capsule."; +"maintenance.presentation.activate.title" = "NetBSD4 Activation"; +"maintenance.presentation.fsck.primary_action" = "Run Disk Repair"; +"maintenance.presentation.fsck.subtitle" = "Unmount a selected HFS volume and run fsck_hfs on the device."; +"maintenance.presentation.fsck.title" = "Disk Repair"; +"maintenance.presentation.repair_xattrs.primary_action" = "Repair Metadata"; +"maintenance.presentation.repair_xattrs.subtitle" = "Scan and repair macOS metadata on a mounted SMB share."; +"maintenance.presentation.repair_xattrs.title" = "File Metadata Repair"; +"maintenance.presentation.risk.destructive" = "Destructive"; +"maintenance.presentation.risk.local_destructive" = "Local destructive"; +"maintenance.presentation.risk.remote_write" = "Remote write"; +"maintenance.presentation.uninstall.primary_action" = "Uninstall"; +"maintenance.presentation.uninstall.subtitle" = "Remove managed SMB files from the selected Time Capsule."; +"maintenance.presentation.uninstall.title" = "Uninstall"; +"maintenance.repairable_count" = "%d repairable item(s)"; +"maintenance.workflow.activate" = "NetBSD4 Activation"; +"maintenance.workflow.fsck" = "Disk Repair"; +"maintenance.workflow.repair_xattrs" = "File Metadata Repair"; +"maintenance.workflow.uninstall" = "Uninstall"; +"overview.empty.message" = "Add a Time Capsule to configure SMB, run checkups, and manage maintenance tasks."; +"overview.empty.title" = "No Time Capsules Saved"; +"operation.error.already_running" = "Another operation is already running."; +"panel.connect" = "Discover And Connect"; +"password_state.available" = "Available"; +"password_state.invalid" = "Invalid"; +"password_state.keychain_unavailable" = "Keychain unavailable"; +"password_state.missing" = "Missing"; +"password_state.unknown" = "Unknown"; +"password.error.keychain_status" = "Keychain error %d."; +"password.error.memory_delete_failed" = "In-memory password store delete failed."; +"password.error.memory_read_failed" = "In-memory password store read failed."; +"password.error.memory_save_failed" = "In-memory password store save failed."; +"password.error.missing" = "Password is missing."; +"password.error.required" = "Password is required."; +"password.error.unreadable_keychain_item" = "Keychain returned an unreadable password."; +"readiness.blocked.title" = "TimeCapsuleSMB cannot start"; +"readiness.state.checking_capabilities" = "Checking helper"; +"readiness.state.resolving_bundle" = "Preparing app runtime"; +"readiness.state.validating_install" = "Validating bundled files"; +"readiness.warning.default" = "TimeCapsuleSMB is running with warnings."; +"recovery.action.copy_diagnostics" = "Copy Diagnostics"; +"recovery.action.disk_repair" = "Run Disk Repair"; +"recovery.action.install_smb" = "Install SMB"; +"recovery.action.metadata_repair" = "Repair File Metadata"; +"recovery.action.open" = "Open"; +"recovery.action.open_diagnostics" = "Open Diagnostics"; +"recovery.action.open_finder" = "Open Finder"; +"recovery.action.replace_password" = "Replace Password"; +"recovery.action.retry" = "Retry"; +"recovery.action.run_checkup" = "Run Checkup"; +"recovery.action.start_smb" = "Start SMB"; +"recovery.action.uninstall" = "Uninstall"; +"screen.advanced" = "Advanced"; +"screen.connect" = "Connect"; +"screen.deploy" = "Deploy"; +"screen.doctor" = "Doctor"; +"screen.maintenance" = "Maintenance"; +"screen.readiness" = "Readiness"; +"sidebar.add_time_capsule" = "Add Time Capsule"; +"sidebar.all_time_capsules" = "All Time Capsules"; +"sidebar.devices" = "Devices"; +"status.activation_needed" = "Activation Needed"; +"status.checking" = "Checking"; +"status.failed" = "Failed"; +"status.healthy" = "Healthy"; +"status.installing" = "Installing"; +"status.keychain_unavailable" = "Keychain Unavailable"; +"status.maintenance" = "Maintenance"; +"status.offline" = "Offline"; +"status.password_invalid" = "Password Invalid"; +"status.password_needed" = "Password Needed"; +"status.ready_to_install" = "Ready to Install"; +"status.removed" = "Removed"; +"status.unchecked" = "Unchecked"; +"status.unsupported" = "Unsupported"; +"status.warning" = "Warning"; +"summary.checkup_counts" = "PASS %d, WARN %d, FAIL %d"; +"timeline.error.needs_attention" = "Needs Attention"; +"timeline.error.needs_confirmation" = "Needs Confirmation"; +"timeline.operation.activate" = "Start SMB"; +"timeline.operation.configure" = "Add Time Capsule"; +"timeline.operation.deploy" = "Install / Update"; +"timeline.operation.discovery" = "Discovery"; +"timeline.operation.doctor" = "Checkup"; +"timeline.operation.flash" = "Persistent NetBSD4 Boot Hook"; +"timeline.operation.fsck" = "Disk Repair"; +"timeline.operation.readiness" = "App Readiness"; +"timeline.operation.repair_xattrs" = "File Metadata Repair"; +"timeline.operation.uninstall" = "Uninstall"; +"timeline.result.done" = "Done"; +"timeline.result.failed" = "Failed"; +"timeline.stage.checking_bundled_files" = "Checking Bundled Files"; +"timeline.stage.checking_ssh" = "Checking SSH"; +"timeline.stage.enabling_ssh" = "Enabling SSH"; +"timeline.stage.finding_disk" = "Finding Disk"; +"timeline.stage.finding_time_capsules" = "Finding Time Capsules"; +"timeline.stage.finding_volumes" = "Finding Volumes"; +"timeline.stage.planning_install" = "Planning Install"; +"timeline.stage.planning_start_smb" = "Planning Start SMB"; +"timeline.stage.planning_uninstall" = "Planning Uninstall"; +"timeline.stage.rebooting" = "Rebooting"; +"timeline.stage.removing_managed_files" = "Removing Managed Files"; +"timeline.stage.repairing_disk" = "Repairing Disk"; +"timeline.stage.repairing_metadata" = "Repairing Metadata"; +"timeline.stage.running_checkup" = "Running Checkup"; +"timeline.stage.saving_device" = "Saving Device"; +"timeline.stage.scanning_metadata" = "Scanning Metadata"; +"timeline.stage.starting_smb" = "Starting SMB"; +"timeline.stage.syncing_to_disk" = "Syncing to Disk"; +"timeline.stage.uploading" = "Uploading"; +"timeline.stage.validating_app_bundle" = "Validating App Bundle"; +"timeline.stage.verifying_smb" = "Verifying SMB"; +"timeline.stage.waiting_for_device" = "Waiting for Device"; +"toggle.dry_run" = "Dry Run"; +"toggle.enable_debug_logging" = "Enable Debug Logging"; +"toggle.enable_nbns" = "Enable NBNS"; +"toggle.force_debug_logging" = "Force Debug Logging"; +"toggle.no_reboot" = "No Reboot"; +"toggle.no_wait" = "No Wait"; +"toolbar.cancel" = "Cancel"; +"toolbar.add" = "Add"; +"toolbar.clear" = "Clear"; +"toolbar.diagnostics" = "Diagnostics"; +"toolbar.forget" = "Forget"; +"value.auto" = "Auto"; +"value.never" = "Never"; +"value.no" = "no"; +"value.not_required" = "Not required"; +"value.required" = "Required"; +"value.unknown" = "Unknown"; +"value.yes" = "yes"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift new file mode 100644 index 0000000..a3df09b --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct WarningBanner: View { + let warning: HostCompatibilityWarning + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + VStack(alignment: .leading) { + Text(warning.title) + .font(.body.weight(.medium)) + Text(warning.message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(Color.yellow.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +struct SummaryGrid: View { + let rows: [(String, String)] + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + GridRow { + Text(row.0).foregroundStyle(.secondary) + Text(row.1) + .lineLimit(2) + .truncationMode(.middle) + } + } + } + .font(.caption) + } +} + +struct StageLine: View { + let stage: OperationStageState + + var body: some View { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift new file mode 100644 index 0000000..8b84e91 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct DeviceSidebarRow: View { + let profile: DeviceProfile + let summary: DeviceDashboardSummary + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "externaldrive") + VStack(alignment: .leading, spacing: 2) { + Text(profile.title) + .lineLimit(1) + Text(profile.host) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Image(systemName: summary.displayStatus.systemImage) + .foregroundStyle(statusColor) + .help(summary.displayStatus.title) + } + } + + private var statusColor: Color { + switch summary.displayStatus { + case .healthy: + return .green + case .warning, .activationNeeded: + return .yellow + case .failed, .passwordInvalid, .keychainUnavailable, .offline, .unsupported: + return .red + case .installing, .checking, .maintaining, .readyToInstall: + return .accentColor + default: + return .secondary + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift new file mode 100644 index 0000000..1f96ce4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -0,0 +1,19 @@ +import AppKit +import SwiftUI +import TimeCapsuleSMBApp + +@main +struct TimeCapsuleSMBExecutable: App { + init() { + NSApplication.shared.setActivationPolicy(.regular) + DispatchQueue.main.async { + NSApplication.shared.activate(ignoringOtherApps: true) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift new file mode 100644 index 0000000..044848e --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ActivityStoreTests: XCTestCase { + func testActivitySnapshotTracksActiveOperationTimelineAndDevice() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "deploy", + stage: "upload_payload", + description: "Upload managed Samba payload files." + ), + BackendEvent( + type: "result", + operation: "deploy", + ok: true, + payload: .object(["summary": .string("deployment completed.")]) + ) + ], delayNanoseconds: 80_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + XCTAssertEqual(activity.snapshot.operationTitle, "No active operation") + + _ = coordinator.run(operation: "deploy", context: context, activeDeviceID: "device-one") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "Install / Update") + XCTAssertEqual(activity.snapshot.scope, .device("device-one")) + + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.timeline.count == 2 } + XCTAssertEqual(activity.snapshot.timeline.map(\.title), ["Uploading", "Done"]) + XCTAssertEqual(activity.snapshot.latestMessage, "deployment completed.") + } + + func testActivitySnapshotTracksBackendOnlyReadinessOperationAsAppScoped() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "capabilities", + stage: "start", + description: "Inspect helper capabilities." + ), + BackendEvent( + type: "result", + operation: "capabilities", + ok: true, + payload: .object(["schema_version": .number(1)]) + ) + ], delayNanoseconds: 80_000_000) + ]) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let activity = ActivityStore(coordinator: coordinator) + + backend.run(operation: "capabilities") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") + XCTAssertEqual(activity.snapshot.scope, .app) + + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.timeline.count == 2 } + XCTAssertEqual(activity.snapshot.scope, .app) + XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift new file mode 100644 index 0000000..372aba1 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -0,0 +1,389 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AddDeviceFlowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(AddDeviceFlowState.allCases, [ + .idle, + .discovering, + .discoveryEmpty, + .discoveryReady, + .manualEntry, + .passwordEntry, + .configuring, + .savingProfile, + .saved, + .authFailed, + .unsupported, + .failed + ]) + } + + func testEntryModeInventoryIsExplicit() { + XCTAssertEqual(AddDeviceEntryMode.allCases, [.discover, .manual]) + } + + func testDiscoverEmptyReadyAndFailureStates() async throws { + let empty = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ]) + empty.store.runDiscover() + try await waitUntilStoreState { empty.store.state == .discoveryEmpty } + XCTAssertEqual(empty.store.devices, []) + + let ready = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord(name: "A", hostname: "a.local.", ipv4: ["10.0.0.2"], fullname: "A._airport._tcp.local."), + testDeviceRecord(name: "B", hostname: "b.local.", ipv4: ["10.0.0.3"], fullname: "B._airport._tcp.local.") + ])) + ]) + ]) + ready.store.runDiscover() + try await waitUntilStoreState { ready.store.state == .discoveryReady } + XCTAssertEqual(ready.store.devices.count, 2) + XCTAssertNil(ready.store.selectedDeviceID) + + let failed = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "discover", code: "bonjour_failed", message: "mDNS failed") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + failed.store.runDiscover() + try await waitUntilStoreState { failed.store.state == .failed } + XCTAssertEqual(failed.store.error?.code, "bonjour_failed") + } + + func testDiscoverUsesBackendDeviceContractInsteadOfRawBonjourRecords() async throws { + let records = [ + testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["169.254.44.9", "10.0.0.2"], + fullname: "Office Capsule._airport._tcp.local." + ), + testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._smb._tcp.local.", + serviceType: "_smb._tcp.local.", + services: ["_smb._tcp.local."] + ), + testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._adisk._tcp.local.", + serviceType: "_adisk._tcp.local.", + services: ["_adisk._tcp.local."] + ), + testDeviceRecord( + name: "Lab Capsule", + hostname: "lab.local.", + ipv4: ["10.0.0.3"], + fullname: "Lab Capsule._airport._tcp.local." + ), + testDeviceRecord( + name: "Lab Capsule", + hostname: "lab.local.", + ipv4: ["10.0.0.3"], + fullname: "Lab Capsule._smb._tcp.local.", + serviceType: "_smb._tcp.local.", + services: ["_smb._tcp.local."] + ), + testDeviceRecord( + name: "Printer", + hostname: "printer.local.", + ipv4: ["10.0.0.20"], + syap: "", + model: "", + fullname: "Printer._ipp._tcp.local.", + serviceType: "_ipp._tcp.local.", + services: ["_ipp._tcp.local."] + ) + ] + let devices = [ + testDiscoveredDevice( + id: "bonjour:lab-capsule._airport._tcp.local", + name: "Lab Capsule", + host: "10.0.0.3", + hostname: "lab.local.", + fullname: "Lab Capsule._airport._tcp.local.", + selectedRecord: records[3] + ), + testDiscoveredDevice( + id: "bonjour:office-capsule._airport._tcp.local", + name: "Office Capsule", + host: "10.0.0.2", + hostname: "office.local.", + addresses: ["169.254.44.9", "10.0.0.2"], + ipv4: ["169.254.44.9", "10.0.0.2"], + preferredIPv4: "10.0.0.2", + fullname: "Office Capsule._airport._tcp.local.", + selectedRecord: records[0] + ) + ] + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: records, devices: devices)) + ]) + ]) + + fixture.store.runDiscover() + + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + XCTAssertEqual(fixture.store.devices.map(\.name), ["Lab Capsule", "Office Capsule"]) + XCTAssertEqual(fixture.store.devices.map(\.host), ["10.0.0.3", "10.0.0.2"]) + XCTAssertEqual(fixture.store.devices[1].addresses, ["169.254.44.9", "10.0.0.2"]) + } + + func testModeChoiceSeparatesDiscoverAndManualFlows() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]) + ]) + + XCTAssertEqual(fixture.store.entryMode, .discover) + XCTAssertFalse(fixture.store.isHostFieldEditable) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + XCTAssertEqual(fixture.store.selectedDevice?.host, "10.0.0.2") + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.2") + XCTAssertFalse(fixture.store.isHostFieldEditable) + + fixture.store.setEntryMode(.manual) + + XCTAssertEqual(fixture.store.entryMode, .manual) + XCTAssertTrue(fixture.store.isHostFieldEditable) + XCTAssertEqual(fixture.store.devices, []) + XCTAssertNil(fixture.store.selectedDeviceID) + } + + func testResetClearsPasswordAndSetupInputs() throws { + let fixture = try makeStore(responses: []) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.reset() + + XCTAssertEqual(fixture.store.state, .idle) + XCTAssertEqual(fixture.store.entryMode, .discover) + XCTAssertEqual(fixture.store.manualHost, "") + XCTAssertEqual(fixture.store.password, "") + XCTAssertEqual(fixture.store.devices, []) + XCTAssertNil(fixture.store.selectedDeviceID) + } + + func testManualHostConfigureSuccessSavesProfileAndPassword() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@10.0.0.2")) + ]) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + let profile = try XCTUnwrap(fixture.store.savedProfile) + XCTAssertEqual(fixture.registry.profiles.count, 1) + XCTAssertEqual(profile.host, "root@10.0.0.2") + XCTAssertEqual(profile.passwordState, .available) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "secret") + XCTAssertEqual(fixture.runner.calls.count, 1) + XCTAssertEqual(fixture.runner.calls[0].operation, "configure") + XCTAssertEqual(fixture.runner.calls[0].context?.profileID, profile.id) + XCTAssertEqual(fixture.runner.calls[0].params["config"], .string(profile.configPath)) + XCTAssertEqual(fixture.runner.calls[0].params["host"], .string("root@10.0.0.2")) + XCTAssertEqual(fixture.runner.calls[0].params["persist_password"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["password"], .string("secret")) + XCTAssertNil(fixture.runner.calls[0].params["debug_logging"]) + } + + func testConfigureRejectedWhileAnotherOperationRunsSavesNothing() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + _ = fixture.store.coordinator.run(operation: "doctor", profile: nil) + try await waitUntilStoreState { fixture.runner.calls.count == 1 } + XCTAssertTrue(fixture.store.isRunning) + fixture.store.runConfigure() + + XCTAssertEqual(fixture.store.state, .failed) + XCTAssertEqual(fixture.store.error?.code, "operation_rejected") + XCTAssertEqual(fixture.registry.profiles, []) + XCTAssertEqual(fixture.runner.calls.count, 1) + try await waitUntilStoreState { !fixture.store.isRunning } + } + + func testSelectedBonjourConfigureSuccessSavesProfileMetadata() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.5"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.5")) + ]) + ]) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let device = try XCTUnwrap(fixture.store.devices.first) + fixture.store.select(device) + fixture.store.password = "secret" + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + let profile = try XCTUnwrap(fixture.store.savedProfile) + XCTAssertEqual(profile.bonjourFullname, "Office Capsule._airport._tcp.local.") + XCTAssertEqual(profile.hostname, "office.local.") + XCTAssertEqual(profile.addresses, ["10.0.0.5"]) + XCTAssertNotNil(fixture.runner.calls[1].params["selected_record"]) + XCTAssertNil(fixture.runner.calls[1].params["host"]) + } + + func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { + let auth = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "bad password") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + auth.store.startManualEntry() + auth.store.manualHost = "10.0.0.2" + auth.store.password = "bad" + auth.store.runConfigure() + try await waitUntilStoreState { auth.store.state == .authFailed } + XCTAssertEqual(auth.registry.profiles, []) + XCTAssertNil(auth.store.savedProfile) + + let unsupported = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "unsupported_device", message: "unsupported") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + unsupported.store.startManualEntry() + unsupported.store.manualHost = "10.0.0.3" + unsupported.store.password = "pw" + unsupported.store.runConfigure() + try await waitUntilStoreState { unsupported.store.state == .unsupported } + XCTAssertEqual(unsupported.registry.profiles, []) + XCTAssertNil(unsupported.store.savedProfile) + } + + func testDuplicateHostUpdatesExistingProfileAfterConfigureSucceeds() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload( + host: "10.0.0.2", + model: "Updated Capsule" + )) + ]) + ]) + let existing = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Original Capsule"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "existing-device" + ) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "new-secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.registry.profiles.count, 1) + XCTAssertEqual(fixture.store.savedProfile?.id, existing.id) + XCTAssertEqual(fixture.store.savedProfile?.model, "Updated Capsule") + XCTAssertEqual(fixture.runner.calls[0].context?.profileID, existing.id) + } + + func testKeychainSaveFailureDoesNotSaveProfile() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.2")) + ]) + ]) + fixture.passwordStore.saveFailure = .save + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .failed } + XCTAssertEqual(fixture.store.error?.code, "profile_save_failed") + XCTAssertNil(fixture.store.savedProfile) + XCTAssertEqual(fixture.registry.profiles, []) + } + + func testSelectingAlreadySavedDiscoveryRoutesToExistingProfile() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]) + ]) + let existing = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: try DiscoveredDevice(record: record.decode(BonjourResolvedServicePayload.self), index: 0), + passwordState: .available, + preferredID: "existing-device" + ) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + fixture.store.select(try XCTUnwrap(fixture.store.devices.first)) + + XCTAssertEqual(fixture.store.state, .saved) + XCTAssertEqual(fixture.store.savedProfile?.id, existing.id) + XCTAssertEqual(fixture.runner.calls.count, 1) + } + + private func makeStore(responses: [StoreTestRunner.Response]) throws -> ( + store: AddDeviceFlowStore, + runner: StoreTestRunner, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let runner = StoreTestRunner(responses: responses) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let passwordStore = InMemoryPasswordStore() + let store = AddDeviceFlowStore(coordinator: coordinator, registry: registry, passwordStore: passwordStore) + return (store, runner, registry, passwordStore) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift new file mode 100644 index 0000000..04d2ed2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift @@ -0,0 +1,299 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppReadinessStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual( + AppReadinessStateKind.allCases, + [.idle, .resolvingBundle, .checkingCapabilities, .validatingInstall, .ready, .degraded, .blocked] + ) + } + + func testStateTitlesAreLocalized() { + XCTAssertEqual(AppReadinessStateKind.allCases.map(\.title), [ + "Idle", + "Preparing app runtime", + "Checking helper", + "Validating bundled files", + "Ready", + "Degraded", + "Blocked" + ]) + } + + func testSuccessfulReadinessRunsCapabilitiesThenValidation() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "capabilities", stage: "summarize_capabilities"), + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "validate-install", stage: "validate_install"), + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + XCTAssertEqual(store.state.kind, .checkingCapabilities) + try await waitUntilStoreState { store.state.kind == .ready } + XCTAssertEqual(runner.calls.map(\.operation), ["capabilities", "validate-install"]) + XCTAssertEqual(store.currentStage?.stage, "validate_install") + guard case .ready(let summary) = store.state else { + return XCTFail("Expected ready state.") + } + XCTAssertEqual(summary.runtimeMode, .productionBundle) + XCTAssertEqual(summary.helperVersion, "1.2.3") + XCTAssertEqual(summary.distributionRoot, "/bundle/Distribution") + XCTAssertEqual(summary.validationCounts["pass"], 1) + } + + func testValidationFailureBlocksApp() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: false, payload: validationPayload(ok: false)) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .installValidationFailed) + XCTAssertEqual(store.validation?.ok, false) + } + + func testRuntimeWarningProducesDegradedStateAfterValidationSuccess() async throws { + let warning = BundleRuntimeIssue( + code: .toolsDirectoryMissing, + severity: .warning, + message: "missing tools", + recovery: "repair app" + ) + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore(runner: runner, issues: [warning]) + + store.start() + + try await waitUntilStoreState { store.state.kind == .degraded } + guard case .degraded(let summary, let issues) = store.state else { + return XCTFail("Expected degraded state.") + } + XCTAssertEqual(summary.helperVersion, "1.2.3") + XCTAssertEqual(issues, [warning]) + } + + func testRuntimeErrorBlocksBeforeRunningHelper() { + let issue = BundleRuntimeIssue( + code: .distributionRootMissing, + severity: .error, + message: "missing distribution", + recovery: "reinstall" + ) + let runner = StoreTestRunner(responses: []) + let store = makeStore(runner: runner, issues: [issue]) + + store.start() + + XCTAssertEqual(store.state.kind, .blocked) + XCTAssertEqual(runner.calls, []) + guard case .blocked(let blockedIssue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(blockedIssue.code, .distributionRootMissing) + } + + func testResolveFailureBlocksBeforeRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = makeStore(runner: runner, resolveError: NSError(domain: "test", code: 1)) + + store.start() + + XCTAssertEqual(store.state.kind, .blocked) + XCTAssertEqual(runner.calls, []) + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .helperMissing) + } + + func testMalformedCapabilitiesPayloadBlocksWithContractIssue() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .contractDecodeFailed) + XCTAssertEqual(runner.calls.map(\.operation), ["capabilities"]) + } + + func testMalformedValidationPayloadBlocksWithContractIssue() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .contractDecodeFailed) + XCTAssertEqual(runner.calls.map(\.operation), ["capabilities", "validate-install"]) + } + + func testHelperLaunchErrorBlocksWithLaunchIssue() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "capabilities", code: "helper_launch_failed", message: "launch failed") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .helperLaunchFailed) + XCTAssertEqual(issue.message, "launch failed") + } + + func testUnrelatedEventsDoNotAdvanceReadiness() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "paths", ok: true, payload: .object(["ok": .bool(true)])) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { !store.backend.isRunning } + XCTAssertEqual(store.state.kind, .checkingCapabilities) + XCTAssertNil(store.capabilities) + XCTAssertNil(store.validation) + } + + func testClearResetsStateAndPayloads() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + try await waitUntilStoreState { store.state.kind == .ready } + store.clear() + + XCTAssertEqual(store.state.kind, .idle) + XCTAssertNil(store.capabilities) + XCTAssertNil(store.validation) + XCTAssertEqual(store.issues, []) + XCTAssertNil(store.currentStage) + } + + private func makeStore( + runner: StoreTestRunner, + issues: [BundleRuntimeIssue] = [], + resolveError: Error? = nil + ) -> AppReadinessStore { + let backend = BackendClient(runner: runner) + let resolver = TestRuntimeResolver(issues: issues, resolveError: resolveError) + return AppReadinessStore( + backend: backend, + runtimeResolver: resolver, + helperPathProvider: { "" } + ) + } + + private func capabilitiesPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "api_schema_version": .number(1), + "helper_version": .string("1.2.3"), + "helper_version_code": .number(123), + "operations": .array([.string("discover"), .string("configure"), .string("validate-install")]), + "distribution_root": .string("/bundle/Distribution"), + "artifact_manifest_sha256": .string("abc"), + "confirmation_schema_version": .number(1), + "summary": .string("helper capabilities resolved.") + ]) + } + + private func validationPayload(ok: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "ok": .bool(ok), + "checks": .array([ + .object([ + "id": .string(ok ? "python_modules" : "artifact_hashes"), + "ok": .bool(ok), + "message": .string(ok ? "required Python modules import" : "artifact validation failed") + ]) + ]), + "counts": .object([ + "checks": .number(1), + "pass": .number(ok ? 1 : 0), + "fail": .number(ok ? 0 : 1) + ]), + "summary": .string(ok ? "install validation passed." : "install validation failed.") + ]) + } +} + +private struct TestRuntimeResolver: AppRuntimeResolving { + let issues: [BundleRuntimeIssue] + let resolveError: Error? + + func resolve(helperPath: String?) throws -> HelperResolution { + if let resolveError { + throw resolveError + } + return HelperResolution( + executableURL: URL(fileURLWithPath: "/bundle/Contents/Helpers/tcapsule"), + distributionRootURL: URL(fileURLWithPath: "/bundle/Contents/Resources/Distribution", isDirectory: true), + toolsBinURL: URL(fileURLWithPath: "/bundle/Contents/Resources/Tools/bin", isDirectory: true), + mode: .productionBundle, + attemptedPaths: ["/bundle/Contents/Helpers/tcapsule"] + ) + } + + func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] { + issues + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift new file mode 100644 index 0000000..9106817 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -0,0 +1,342 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class BackendClientTests: XCTestCase { + func testRunPublishesEventsAndResetsState() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "stage", operation: "paths", stage: "start"), + BackendEvent(type: "result", operation: "paths", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 50_000_000 + ) + let client = BackendClient(runner: runner, helperPath: " /tmp/tcapsule ") + + client.run(operation: "paths", params: ["dry_run": .bool(true)]) + + XCTAssertTrue(client.isRunning) + try await waitUntil { + !client.isRunning && client.events.count == 2 + } + XCTAssertEqual(client.lastExitCode, 0) + XCTAssertEqual(client.events.map(\.type), ["stage", "result"]) + XCTAssertEqual( + runner.calls, + [RecordingHelperRunner.Call( + helperPath: "/tmp/tcapsule", + operation: "paths", + params: ["dry_run": .bool(true)], + context: nil + )] + ) + } + + func testCancelCancelsDetachedRunAndResetsState() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 1_000_000_000 + ) + let client = BackendClient(runner: runner) + + client.run(operation: "doctor") + try await waitUntil { + runner.calls.count == 1 + } + + client.cancel() + + try await waitUntil { + !client.isRunning && client.lastExitCode == 130 && client.events.last?.code == "cancelled" + } + XCTAssertEqual(client.events.last?.type, "error") + } + + func testDeinitCancelsActiveRun() async throws { + let recorder = CancellationRecorder() + let runner = CancellationObservingRunner(recorder: recorder) + var client: BackendClient? = BackendClient(runner: runner) + + client?.run(operation: "doctor") + try await waitUntilAsync { + await recorder.started + } + + client = nil + + try await waitUntilAsync { + await recorder.cancelled + } + } + + func testStagePolicyControlsCancellation() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "deploy", ok: true) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 50_000_000 + ) + let client = BackendClient(runner: runner) + + client.run(operation: "deploy") + try await waitUntil { + client.currentStage == "upload_payload" + } + + XCTAssertFalse(client.canCancel) + client.cancel() + + try await waitUntil { + !client.isRunning + } + XCTAssertEqual(client.lastExitCode, 0) + } + + func testConfirmationRequiredEventPublishesPendingConfirmation() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deploy.", + details: .object([ + "title": .string("Confirm deployment"), + "message": .string("Deploy and reboot."), + "action_title": .string("Deploy"), + "confirmation_id": .string("confirm-1") + ]) + ) + ], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + let client = BackendClient(runner: runner) + + client.run(operation: "deploy", params: ["dry_run": .bool(false)]) + + try await waitUntil { + client.pendingConfirmation != nil && !client.isRunning + } + XCTAssertEqual(client.pendingConfirmation?.operation, "deploy") + XCTAssertEqual(client.pendingConfirmation?.params["confirmation_id"], .string("confirm-1")) + XCTAssertEqual(client.pendingConfirmation?.params["dry_run"], .bool(false)) + } + + func testProfileContextInjectsConfigAndPreservesExplicitConfig() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: "") + ) + let client = BackendClient(runner: runner) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + client.run(operation: "doctor", params: [:], context: context) + + try await waitUntil { !client.isRunning && runner.calls.count == 1 } + XCTAssertEqual(runner.calls[0].context, context) + XCTAssertEqual(runner.calls[0].params["config"], .string("/tmp/device-one/.env")) + + client.run( + operation: "doctor", + params: ["config": .string("/tmp/manual.env")], + context: context + ) + + try await waitUntil { !client.isRunning && runner.calls.count == 2 } + XCTAssertEqual(runner.calls[1].params["config"], .string("/tmp/manual.env")) + } + + func testConfirmationReplayPreservesDeviceContext() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deploy.", + details: .object([ + "confirmation_id": .string("confirm-1") + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ]) + ]) + let client = BackendClient(runner: runner) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + client.run(operation: "deploy", params: ["dry_run": .bool(false)], context: context) + try await waitUntil { client.pendingConfirmation != nil && !client.isRunning } + XCTAssertEqual(client.pendingConfirmation?.context, context) + + client.confirmPending() + + try await waitUntil { !client.isRunning && runner.calls.count == 2 } + XCTAssertEqual(runner.calls[0].context, context) + XCTAssertEqual(runner.calls[1].context, context) + XCTAssertEqual(runner.calls[1].params["confirmation_id"], .string("confirm-1")) + XCTAssertEqual(runner.calls[1].params["config"], .string("/tmp/device-one/.env")) + } + + func testOperationCoordinatorRejectsSecondOperationWhileActive() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 200_000_000 + ) + let client = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: client) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + guard case .started(let activeOperation) = coordinator.run(operation: "doctor", context: context, activeDeviceID: "device-one") else { + XCTFail("Expected first operation to start.") + return + } + guard case .rejected(let rejectionMessage) = coordinator.run(operation: "deploy", context: context, activeDeviceID: "device-one") else { + XCTFail("Expected second operation to be rejected.") + return + } + XCTAssertEqual(activeOperation.operation, "doctor") + XCTAssertEqual(activeOperation.profileID, "device-one") + XCTAssertEqual(rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.activeOperation, activeOperation) + XCTAssertEqual(coordinator.activeDeviceID, "device-one") + + try await waitUntil { !client.isRunning } + XCTAssertNil(coordinator.activeOperation) + XCTAssertNil(coordinator.activeDeviceID) + } + + private func waitUntil( + timeoutNanoseconds: UInt64 = 2_000_000_000, + _ condition: @escaping @MainActor () -> Bool + ) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !condition() { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + XCTFail("Timed out waiting for BackendClient state change.") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } + + private func waitUntilAsync( + timeoutNanoseconds: UInt64 = 2_000_000_000, + _ condition: @escaping () async -> Bool + ) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !(await condition()) { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + XCTFail("Timed out waiting for async BackendClient state change.") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } +} + +private actor CancellationRecorder { + private var didStart = false + private var didCancel = false + + var started: Bool { + didStart + } + + var cancelled: Bool { + didCancel + } + + func markStarted() { + didStart = true + } + + func markCancelled() { + didCancel = true + } +} + +private final class CancellationObservingRunner: HelperRunning, @unchecked Sendable { + private let recorder: CancellationRecorder + + init(recorder: CancellationRecorder) { + self.recorder = recorder + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + await recorder.markStarted() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 10_000_000) + } + await recorder.markCancelled() + return HelperRunResult(exitCode: 130, sawTerminalEvent: false, stderr: "") + } +} + +private final class RecordingHelperRunner: HelperRunning, @unchecked Sendable { + struct Call: Equatable, Sendable { + let helperPath: String? + let operation: String + let params: [String: JSONValue] + let context: DeviceRuntimeContext? + } + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.RecordingHelperRunner") + private let events: [BackendEvent] + private let result: HelperRunResult + private let delayNanoseconds: UInt64 + private var storedCalls: [Call] = [] + + init(events: [BackendEvent], result: HelperRunResult, delayNanoseconds: UInt64 = 0) { + self.events = events + self.result = result + self.delayNanoseconds = delayNanoseconds + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params, context: context)) + } + + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + if Task.isCancelled { + await onEvent(BackendEvent.error(operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"))) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in events { + await onEvent(event) + } + return result + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift new file mode 100644 index 0000000..ba32dcf --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -0,0 +1,101 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class BackendEventTests: XCTestCase { + func testBackendEventDecodesContractFields() throws { + let data = """ + {"schema_version":1,"request_id":"req-1","type":"error","operation":"deploy","code":"remote_error","message":"failed","debug":{"stderr":"detail"},"recovery":{"title":"No HFS volumes found","retryable":true,"actions":["retry"]}} + """.data(using: .utf8)! + + let event = try JSONDecoder().decode(BackendEvent.self, from: data) + + XCTAssertEqual(event.schemaVersion, 1) + XCTAssertEqual(event.requestId, "req-1") + XCTAssertEqual(event.type, "error") + XCTAssertEqual(event.operation, "deploy") + XCTAssertEqual(event.code, "remote_error") + XCTAssertEqual(event.message, "failed") + XCTAssertEqual(event.debug, .object(["stderr": .string("detail")])) + XCTAssertEqual(event.recovery, .object([ + "title": .string("No HFS volumes found"), + "retryable": .bool(true), + "actions": .array([.string("retry")]) + ])) + } + + func testBackendEventDecodesStagePolicyFields() throws { + let data = """ + {"schema_version":1,"type":"stage","operation":"deploy","stage":"upload_payload","risk":"remote_write","cancellable":false,"description":"Upload managed Samba payload files."} + """.data(using: .utf8)! + + let event = try JSONDecoder().decode(BackendEvent.self, from: data) + + XCTAssertEqual(event.stage, "upload_payload") + XCTAssertEqual(event.risk, "remote_write") + XCTAssertEqual(event.cancellable, false) + XCTAssertEqual(event.description, "Upload managed Samba payload files.") + } + + func testBackendEventSummaryUsesLocalizedFallbackTemplates() { + let stage = BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload") + let check = BackendEvent(type: "check", operation: "doctor", message: "smbd is running") + let success = BackendEvent(type: "result", operation: "deploy", ok: true) + let failure = BackendEvent(type: "result", operation: "deploy", ok: false) + let error = BackendEvent(type: "error", operation: "deploy") + + XCTAssertEqual(stage.summary, "deploy: upload_payload") + XCTAssertEqual(check.summary, "INFO smbd is running") + XCTAssertEqual(success.summary, "deploy: finished") + XCTAssertEqual(failure.summary, "deploy: failed") + XCTAssertEqual(error.summary, "deploy: error") + } + + func testBackendEventResultSummaryPrefersPayloadText() { + let summary = BackendEvent( + type: "result", + operation: "deploy", + ok: true, + payload: .object(["summary": .string("Deployment completed on the Time Capsule.")]) + ) + let message = BackendEvent( + type: "result", + operation: "activate", + ok: true, + payload: .object(["message": .string("Activation completed without reboot.")]) + ) + let legacySummaryText = BackendEvent( + type: "result", + operation: "repair-xattrs", + ok: true, + payload: .object(["summary_text": .string("repair-xattrs found 2 issue(s), 1 repairable.")]) + ) + let blankSummaryFallsBack = BackendEvent( + type: "result", + operation: "doctor", + ok: true, + payload: .object(["summary": .string(" ")]) + ) + + XCTAssertEqual(summary.summary, "Deployment completed on the Time Capsule.") + XCTAssertEqual(message.summary, "Activation completed without reboot.") + XCTAssertEqual(legacySummaryText.summary, "repair-xattrs found 2 issue(s), 1 repairable.") + XCTAssertEqual(blankSummaryFallsBack.summary, "doctor: finished") + } + + func testJSONValueRoundTripsNestedObjects() throws { + let value = JSONValue.object([ + "operation": .string("paths"), + "params": .object([ + "dry_run": .bool(true), + "mount_wait": .number(30), + "items": .array([.string("one"), .null]) + ]) + ]) + + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + + XCTAssertEqual(decoded, value) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift new file mode 100644 index 0000000..59f52ea --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift @@ -0,0 +1,279 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class BackendPayloadTests: XCTestCase { + func testDecodesReadinessPayloads() throws { + let capabilities = try jsonValue(""" + { + "schema_version": 1, + "api_schema_version": 1, + "helper_version": "1.2.3", + "helper_version_code": 123, + "operations": ["discover", "configure"], + "distribution_root": "/repo", + "artifact_manifest_sha256": "abc", + "confirmation_schema_version": 1, + "summary": "helper capabilities resolved." + } + """).decode(CapabilitiesPayload.self) + + XCTAssertEqual(capabilities.helperVersion, "1.2.3") + XCTAssertEqual(capabilities.operations, ["discover", "configure"]) + + let paths = try jsonValue(""" + { + "schema_version": 1, + "distribution_root": "/repo", + "config_path": "/app/.env", + "state_dir": "/app", + "package_root": "/repo/src/timecapsulesmb", + "artifact_manifest": "/repo/src/timecapsulesmb/assets/artifact-manifest.json", + "artifacts": [{ + "name": "smbd", + "repo_relative_path": "bin/samba4/smbd", + "absolute_path": "/repo/bin/samba4/smbd", + "sha256": "hash", + "ok": true, + "message": "ok" + }], + "counts": {"artifacts": 1}, + "summary": "resolved app paths with 1 artifact path(s)." + } + """).decode(PathsPayload.self) + + XCTAssertEqual(paths.artifacts[0].repoRelativePath, "bin/samba4/smbd") + XCTAssertEqual(paths.counts["artifacts"], 1) + + let validation = try jsonValue(""" + { + "schema_version": 1, + "ok": false, + "checks": [{"id": "artifact_hashes", "ok": false, "message": "artifact validation failed", "details": {"failures": ["bad hash"]}}], + "counts": {"checks": 1, "pass": 0, "fail": 1}, + "summary": "install validation failed." + } + """).decode(InstallValidationPayload.self) + + XCTAssertFalse(validation.ok) + XCTAssertEqual(validation.checks[0].details, .object(["failures": .array([.string("bad hash")])])) + } + + func testDecodesDiscoveryAndConfigurePayloads() throws { + let discovery = try jsonValue(""" + { + "schema_version": 1, + "instances": [{"service_type": "_airport._tcp.local.", "name": "TC", "fullname": "TC._airport._tcp.local."}], + "resolved": [{ + "name": "TC", + "hostname": "tc.local.", + "service_type": "_airport._tcp.local.", + "port": 5009, + "ipv4": ["10.0.0.2"], + "ipv6": [], + "services": ["_airport._tcp.local."], + "properties": {"syAP": "119", "model": "Time Capsule"}, + "fullname": "TC._airport._tcp.local." + }], + "devices": [{ + "id": "bonjour:tc._airport._tcp.local", + "name": "TC", + "host": "10.0.0.2", + "ssh_host": "root@10.0.0.2", + "hostname": "tc.local.", + "addresses": ["10.0.0.2"], + "ipv4": ["10.0.0.2"], + "ipv6": [], + "preferred_ipv4": "10.0.0.2", + "link_local_only": false, + "syap": "119", + "model": "Time Capsule", + "service_type": "_airport._tcp.local.", + "fullname": "TC._airport._tcp.local.", + "selected_record": { + "name": "TC", + "hostname": "tc.local.", + "service_type": "_airport._tcp.local.", + "port": 5009, + "ipv4": ["10.0.0.2"], + "ipv6": [], + "services": ["_airport._tcp.local."], + "properties": {"syAP": "119", "model": "Time Capsule"}, + "fullname": "TC._airport._tcp.local." + } + }], + "counts": {"instances": 1, "resolved": 1, "devices": 1}, + "summary": "discovered 1 Time Capsule device(s)." + } + """).decode(DiscoverPayload.self) + + XCTAssertEqual(discovery.resolved[0].name, "TC") + XCTAssertEqual(discovery.devices[0].host, "10.0.0.2") + XCTAssertEqual(discovery.devices[0].selectedRecord.stringValue(for: "fullname"), "TC._airport._tcp.local.") + XCTAssertEqual(discovery.resolved[0].properties["syAP"], "119") + XCTAssertEqual(discovery.resolved[0].jsonValue.stringValue(for: "name"), "TC") + + let configure = try jsonValue(""" + { + "schema_version": 1, + "config_path": "/app/.env", + "host": "root@10.0.0.2", + "configure_id": "cfg-1", + "ssh_authenticated": true, + "device_syap": "119", + "device_model": "Time Capsule", + "compatibility": { + "os_name": "NetBSD", + "os_release": "6.0", + "arch": "evbarm", + "elf_endianness": "little", + "payload_family": "netbsd6_samba4", + "device_generation": "gen5", + "supported": true, + "reason_code": "supported_netbsd6", + "reason_detail": "", + "syap_candidates": ["119"], + "model_candidates": ["Time Capsule"] + }, + "device": {"host": "root@10.0.0.2", "syap": "119", "model": "Time Capsule"}, + "summary": "configuration saved and SSH authentication verified." + } + """).decode(ConfigurePayload.self) + + XCTAssertEqual(configure.host, "root@10.0.0.2") + XCTAssertEqual(configure.compatibility?.payloadFamily, "netbsd6_samba4") + XCTAssertEqual(ConfiguredDeviceState(payload: configure).model, "Time Capsule") + } + + func testDecodesDeployDoctorAndMaintenancePayloads() throws { + let deployPlan = try jsonValue(""" + { + "schema_version": 1, + "host": "root@10.0.0.2", + "volume_root": "/Volumes/dk2", + "payload_dir": "/Volumes/dk2/.samba4", + "payload_family": "netbsd6_samba4", + "netbsd4": false, + "requires_reboot": true, + "reboot_required": true, + "uploads": [{"description": "smbd"}], + "pre_upload_actions": [{"type": "stop_process"}], + "post_upload_actions": [], + "activation_actions": [], + "post_deploy_checks": [{"id": "ssh_returns_after_reboot", "description": "SSH returns after reboot"}], + "summary": "deployment dry-run plan generated." + } + """).decode(DeployPlanPayload.self) + + XCTAssertEqual(deployPlan.payloadFamily, "netbsd6_samba4") + XCTAssertTrue(deployPlan.requiresReboot) + XCTAssertEqual(deployPlan.uploads.count, 1) + + let deployResult = try jsonValue(""" + { + "schema_version": 1, + "payload_dir": "/Volumes/dk2/.samba4", + "netbsd4": false, + "payload_family": "netbsd6_samba4", + "requires_reboot": true, + "rebooted": true, + "reboot_requested": true, + "waited": true, + "verified": true, + "summary": "deployment completed." + } + """).decode(DeployResultPayload.self) + + XCTAssertEqual(deployResult.rebootRequested, true) + XCTAssertEqual(deployResult.verified, true) + + let doctor = try jsonValue(""" + { + "schema_version": 1, + "fatal": true, + "results": [{"status": "FAIL", "message": "smbd is not running", "details": {"domain": "runtime"}}], + "counts": {"FAIL": 1}, + "error": "smbd is not running", + "summary": "doctor found one or more fatal problems." + } + """).decode(DoctorPayload.self) + + XCTAssertTrue(doctor.fatal) + XCTAssertEqual(doctor.results[0].details, .object(["domain": .string("runtime")])) + + let fsckTargets = try jsonValue(""" + { + "schema_version": 1, + "targets": [{"device": "/dev/dk2", "mountpoint": "/Volumes/dk2", "name": "Data", "builtin": true}], + "counts": {"targets": 1}, + "summary": "found 1 mounted HFS volume(s)." + } + """).decode(FsckVolumeListPayload.self) + + XCTAssertEqual(fsckTargets.targets[0].device, "/dev/dk2") + + let maintenance = try jsonValue(""" + { + "schema_version": 1, + "summary": "uninstall completed.", + "requires_reboot": true, + "rebooted": true, + "reboot_requested": true, + "waited": true, + "verified": true, + "counts": {"payload_dirs": 1} + } + """).decode(MaintenanceResultPayload.self) + + XCTAssertEqual(maintenance.rebooted, true) + XCTAssertEqual(maintenance.counts?["payload_dirs"], 1) + } + + func testDecodesRecoveryAndReportsContractFailures() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "remote_error", + message: "failed", + recovery: try jsonValue(""" + { + "title": "No HFS volumes found", + "message": "The device did not report a deployable HFS disk.", + "actions": ["Wake the disk.", "Retry deploy."], + "retryable": true, + "suggested_operation": "deploy", + "docs_anchor": "deploy" + } + """) + ) + + let error = BackendErrorViewModel(event: event) + + XCTAssertEqual(error.recovery?.title, "No HFS volumes found") + XCTAssertEqual(error.recovery?.actions, ["Wake the disk.", "Retry deploy."]) + XCTAssertEqual(error.recovery?.suggestedOperation, "deploy") + + XCTAssertThrowsError(try BackendEvent(type: "result", operation: "paths", ok: true).decodePayload(PathsPayload.self)) { thrown in + XCTAssertEqual(thrown as? BackendContractError, .missingPayload(operation: "paths")) + } + + XCTAssertThrowsError( + try BackendEvent( + type: "result", + operation: "paths", + ok: true, + payload: .object(["schema_version": .string("wrong")]) + ).decodePayload(PathsPayload.self) + ) { thrown in + guard case BackendContractError.payloadDecodeFailed(let operation, let payloadType, _)? = thrown as? BackendContractError else { + return XCTFail("Expected payloadDecodeFailed, got \(thrown)") + } + XCTAssertEqual(operation, "paths") + XCTAssertEqual(payloadType, "PathsPayload") + } + } + + private func jsonValue(_ text: String) throws -> JSONValue { + let data = Data(text.utf8) + return try JSONDecoder().decode(JSONValue.self, from: data) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift new file mode 100644 index 0000000..9d4e19d --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class BundleLayoutTests: XCTestCase { + func testStateInventoriesAreExplicit() { + XCTAssertEqual(BundleRuntimeMode.allCases, [.explicit, .productionBundle, .developmentCheckout]) + XCTAssertEqual(BundleRuntimeIssueSeverity.allCases, [.warning, .error]) + XCTAssertEqual( + BundleRuntimeIssueCode.allCases, + [ + .helperMissing, + .helperNotExecutable, + .distributionRootMissing, + .toolsDirectoryMissing, + .installValidationFailed, + .helperLaunchFailed, + .contractDecodeFailed, + .operationFailed + ] + ) + } + + func testValidProductionLayoutHasNoIssues() throws { + let layout = try makeLayout() + + XCTAssertEqual(layout.validationIssues(), []) + } + + func testMissingHelperIsBlockingIssue() throws { + let layout = try makeLayout(createHelper: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .helperMissing && $0.severity == .error })) + } + + func testNonExecutableHelperIsBlockingIssue() throws { + let layout = try makeLayout(helperPermissions: 0o644) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .helperNotExecutable && $0.severity == .error })) + } + + func testMissingDistributionRootIsBlockingIssue() throws { + let layout = try makeLayout(createDistribution: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .distributionRootMissing && $0.severity == .error })) + } + + func testMissingToolsDirectoryIsWarningIssue() throws { + let layout = try makeLayout(createTools: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .toolsDirectoryMissing && $0.severity == .warning })) + } + + private func makeLayout( + createHelper: Bool = true, + helperPermissions: Int = 0o755, + createDistribution: Bool = true, + createTools: Bool = true + ) throws -> BundleLayout { + let temp = try TemporaryDirectory() + let app = temp.url.appendingPathComponent("TimeCapsuleSMB.app", isDirectory: true) + let resources = app.appendingPathComponent("Contents/Resources", isDirectory: true) + let helpers = app.appendingPathComponent("Contents/Helpers", isDirectory: true) + let appSupport = temp.url.appendingPathComponent("Application Support", isDirectory: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + + let helper = helpers.appendingPathComponent("tcapsule") + if createHelper { + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: helperPermissions], ofItemAtPath: helper.path) + } + if createDistribution { + try FileManager.default.createDirectory( + at: resources.appendingPathComponent("Distribution", isDirectory: true), + withIntermediateDirectories: true + ) + } + if createTools { + try FileManager.default.createDirectory( + at: resources.appendingPathComponent("Tools/bin", isDirectory: true), + withIntermediateDirectories: true + ) + } + return BundleLayout( + appBundleURL: app, + resourceURL: resources, + helperURL: helper, + applicationSupportURL: appSupport + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift new file mode 100644 index 0000000..0185a11 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ConfiguredDeviceProfileSaverTests: XCTestCase { + func testKeychainFailureDoesNotPersistProfile() throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let passwordStore = InMemoryPasswordStore() + passwordStore.saveFailure = .save + let saver = ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) + + XCTAssertThrowsError(try saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + password: "secret", + preferredID: "device-one" + )) + + XCTAssertEqual(registry.profiles, []) + XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) + } + + func testRegistryFailureRollsBackNewKeychainPassword() throws { + let temp = try TemporaryDirectory() + let blockedApplicationSupport = temp.url.appendingPathComponent("not-a-directory") + try "file".write(to: blockedApplicationSupport, atomically: true, encoding: .utf8) + let registry = DeviceRegistryStore(applicationSupportURL: blockedApplicationSupport) + let passwordStore = InMemoryPasswordStore() + let saver = ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) + + XCTAssertThrowsError(try saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + password: "secret", + preferredID: "device-one" + )) + + XCTAssertEqual(registry.profiles, []) + XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) + } + + func testRegistryFailureRestoresExistingKeychainPassword() throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let existing = try registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let passwordStore = InMemoryPasswordStore(passwords: [existing.keychainAccount: "old-secret"]) + let saver = ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) + let blockedRegistryPath = registry.registryURL + try FileManager.default.removeItem(at: blockedRegistryPath) + try FileManager.default.createDirectory(at: blockedRegistryPath, withIntermediateDirectories: false) + + XCTAssertThrowsError(try saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Updated Capsule"), + discoveredDevice: nil, + password: "new-secret", + preferredID: "device-one" + )) + + XCTAssertEqual(try passwordStore.password(for: existing.keychainAccount), "old-secret") + XCTAssertEqual(registry.profile(id: existing.id)?.model, existing.model) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift new file mode 100644 index 0000000..a780640 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift @@ -0,0 +1,428 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ConnectionWorkflowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(ConnectionWorkflowState.allCases, [ + .idle, + .discovering, + .discoveryReady, + .discoveryEmpty, + .discoveryFailed, + .configuring, + .configured, + .configureFailed + ]) + } + + func testInvalidDiscoverTimeoutMovesToDiscoveryFailedWithoutRunningHelper() { + let runner = WorkflowRecordingRunner(responses: []) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "bad" + + store.runDiscover() + + XCTAssertEqual(store.state, .discoveryFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testDiscoverSingleDeviceAutoSelectsAndRecordsStage() async throws { + let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "discover", stage: "bonjour_discovery", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "0.25" + + store.runDiscover() + + XCTAssertEqual(store.state, .discovering) + try await waitUntil { store.state == .discoveryReady } + XCTAssertEqual(store.currentStage?.stage, "bonjour_discovery") + XCTAssertEqual(store.devices.count, 1) + XCTAssertEqual(store.devices[0].name, "TC") + XCTAssertEqual(store.devices[0].syap, "119") + XCTAssertEqual(store.selectedDeviceID, store.devices[0].id) + XCTAssertEqual(runner.calls.first?.operation, "discover") + XCTAssertEqual(runner.calls.first?.params["timeout"], .number(0.25)) + } + + func testDiscoverEmptyResultMovesToDiscoveryEmpty() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryEmpty } + XCTAssertEqual(store.devices, []) + XCTAssertNil(store.selectedDeviceID) + } + + func testDiscoverMultipleDevicesRequiresExplicitSelection() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [ + deviceRecord(name: "TC One", ipv4: ["10.0.0.2"], syap: "119"), + deviceRecord(name: "TC Two", ipv4: ["10.0.0.3"], syap: "120") + ])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryReady } + XCTAssertEqual(store.devices.count, 2) + XCTAssertNil(store.selectedDeviceID) + + store.select(store.devices[1]) + + XCTAssertEqual(store.selectedDeviceID, store.devices[1].id) + XCTAssertEqual(store.selectedDevice?.name, "TC Two") + } + + func testDiscoverBackendErrorMovesToDiscoveryFailedWithRecovery() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "discover", + code: "operation_failed", + message: "Bonjour failed.", + recovery: recovery(title: "Discovery failed", actions: ["Retry discovery."]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryFailed } + XCTAssertEqual(store.error?.message, "Bonjour failed.") + XCTAssertEqual(store.error?.recovery?.title, "Discovery failed") + XCTAssertEqual(store.error?.recovery?.actions, ["Retry discovery."]) + } + + func testMalformedDiscoverPayloadMovesToDiscoveryFailed() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "discover", + ok: true, + payload: .object(["schema_version": .string("wrong")]) + ) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryFailed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testConfigureRejectsMissingPasswordWithoutRunningHelper() { + let runner = WorkflowRecordingRunner(responses: []) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = "root@10.0.0.2" + + store.runConfigure(password: " ") + + XCTAssertEqual(store.state, .configureFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testConfigureRejectsMissingTargetWithoutRunningHelper() { + let runner = WorkflowRecordingRunner(responses: []) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runConfigure(password: "pw") + + XCTAssertEqual(store.state, .configureFailed) + XCTAssertEqual(store.error?.message, "Choose a discovered device or enter a host.") + XCTAssertEqual(runner.calls, []) + } + + func testConfigureSelectedDeviceSendsSelectedRecordAndStoresResult() async throws { + let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "configure", stage: "ssh_probe", risk: "remote_read", cancellable: true), + BackendEvent(type: "result", operation: "configure", ok: true, payload: configurePayload(host: "root@10.0.0.2")) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + try await waitUntil { store.state == .discoveryReady } + store.runConfigure(password: "pw") + + XCTAssertEqual(store.state, .configuring) + try await waitUntil { store.state == .configured } + XCTAssertEqual(store.currentStage?.stage, "ssh_probe") + XCTAssertEqual(store.configuredDevice?.host, "root@10.0.0.2") + XCTAssertEqual(store.configuredDevice?.sshAuthenticated, true) + XCTAssertEqual(runner.calls.count, 2) + XCTAssertNil(runner.calls[1].params["host"]) + XCTAssertEqual(runner.calls[1].params["selected_record"], store.devices[0].rawRecord) + XCTAssertEqual(runner.calls[1].params["password"], .string("pw")) + } + + func testConfigureManualHostSendsHostWhenNoDeviceSelected() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: configurePayload(host: "root@10.0.0.9")) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = " root@10.0.0.9 " + store.debugLogging = true + + store.runConfigure(password: "pw") + + try await waitUntil { store.state == .configured } + XCTAssertEqual(runner.calls.first?.operation, "configure") + XCTAssertEqual(runner.calls.first?.params["host"], .string("root@10.0.0.9")) + XCTAssertNil(runner.calls.first?.params["selected_record"]) + XCTAssertEqual(runner.calls.first?.params["debug_logging"], .bool(true)) + } + + func testConfigureAuthFailurePreservesDiscoverySelectionAndShowsRecovery() async throws { + let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "configure", + code: "auth_failed", + message: "The AirPort admin password did not work.", + recovery: recovery(title: "AirPort password rejected", actions: ["Re-enter the password."]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + try await waitUntil { store.state == .discoveryReady } + let selectedID = store.selectedDeviceID + store.runConfigure(password: "bad") + + try await waitUntil { store.state == .configureFailed } + XCTAssertEqual(store.selectedDeviceID, selectedID) + XCTAssertEqual(store.devices.count, 1) + XCTAssertEqual(store.error?.code, "auth_failed") + XCTAssertEqual(store.error?.recovery?.title, "AirPort password rejected") + } + + func testConfigureFalseResultMovesToConfigureFailed() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: false, + payload: .object(["summary": .string("configuration failed.")]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = "root@10.0.0.2" + + store.runConfigure(password: "pw") + + try await waitUntil { store.state == .configureFailed } + XCTAssertEqual(store.error?.message, "configuration failed.") + } + + func testMalformedConfigurePayloadMovesToConfigureFailed() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: true, + payload: .object(["schema_version": .number(1)]) + ) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = "root@10.0.0.2" + + store.runConfigure(password: "pw") + + try await waitUntil { store.state == .configureFailed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testClearReturnsWorkflowToIdle() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [ + deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + ])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + try await waitUntil { store.state == .discoveryReady } + store.clear() + + XCTAssertEqual(store.state, .idle) + XCTAssertEqual(store.devices, []) + XCTAssertNil(store.selectedDeviceID) + XCTAssertNil(store.configuredDevice) + XCTAssertNil(store.error) + XCTAssertEqual(store.events.count, 0) + } + + private func waitUntil( + timeoutNanoseconds: UInt64 = 2_000_000_000, + _ condition: @escaping @MainActor () -> Bool + ) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !condition() { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + XCTFail("Timed out waiting for connection workflow state change.") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } + + private func deviceRecord(name: String, ipv4: [String], syap: String) -> JSONValue { + .object([ + "name": .string(name), + "hostname": .string("\(name.lowercased().replacingOccurrences(of: " ", with: "-")).local."), + "service_type": .string("_airport._tcp.local."), + "port": .number(5009), + "ipv4": .array(ipv4.map(JSONValue.string)), + "ipv6": .array([]), + "services": .array([.string("_airport._tcp.local.")]), + "properties": .object(["syAP": .string(syap)]), + "fullname": .string("\(name)._airport._tcp.local.") + ]) + } + + private func discoverPayload(records: [JSONValue]) -> JSONValue { + .object([ + "schema_version": .number(1), + "instances": .array([]), + "resolved": .array(records), + "counts": .object([ + "instances": .number(0), + "resolved": .number(Double(records.count)) + ]), + "summary": .string("discovered \(records.count) resolved AirPort service(s).") + ]) + } + + private func configurePayload(host: String) -> JSONValue { + .object([ + "schema_version": .number(1), + "config_path": .string("/app/.env"), + "host": .string(host), + "configure_id": .string("cfg-1"), + "ssh_authenticated": .bool(true), + "device_syap": .string("119"), + "device_model": .string("Time Capsule"), + "compatibility": .object([ + "payload_family": .string("netbsd6_samba4"), + "supported": .bool(true), + "syap_candidates": .array([.string("119")]), + "model_candidates": .array([.string("Time Capsule")]) + ]), + "device": .object([ + "host": .string(host), + "syap": .string("119"), + "model": .string("Time Capsule") + ]), + "summary": .string("configuration saved and SSH authentication verified.") + ]) + } + + private func recovery(title: String, actions: [String]) -> JSONValue { + .object([ + "title": .string(title), + "message": .string(title), + "actions": .array(actions.map(JSONValue.string)), + "retryable": .bool(true), + "suggested_operation": .string("configure") + ]) + } +} + +private final class WorkflowRecordingRunner: HelperRunning, @unchecked Sendable { + struct Call: Equatable, Sendable { + let helperPath: String? + let operation: String + let params: [String: JSONValue] + let context: DeviceRuntimeContext? + } + + struct Response: Sendable { + let events: [BackendEvent] + let result: HelperRunResult + + init( + events: [BackendEvent], + result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: "") + ) { + self.events = events + self.result = result + } + } + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.WorkflowRecordingRunner") + private var storedResponses: [Response] + private var storedCalls: [Call] = [] + + init(responses: [Response]) { + self.storedResponses = responses + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let response = queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params, context: context)) + if storedResponses.isEmpty { + return Response( + events: [BackendEvent.error(operation: operation, code: "missing_test_response", message: "No test response queued.")], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + } + return storedResponses.removeFirst() + } + + for event in response.events { + await onEvent(event) + } + return response.result + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift new file mode 100644 index 0000000..8803d42 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DashboardPresentationTests: XCTestCase { + func testDeployPlanPresentationSeparatesSummaryAdvancedAndWarnings() throws { + let plan = try netbsd4DeployPlan().decode(DeployPlanPayload.self) + let profile = DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(payloadFamily: "netbsd4_samba4"), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + let warning = HostCompatibilityWarning(title: "macOS Warning", message: "Time Machine warning.") + + let presentation = DeployPlanPresentation(plan: plan, profile: profile, hostWarning: warning) + + XCTAssertEqual(presentation.title, "Install SMB and Start Runtime") + XCTAssertTrue(presentation.summaryRows.contains(PresentationRow(label: "Payload", value: "netbsd4_samba4"))) + XCTAssertTrue(presentation.advancedRows.contains(PresentationRow(label: "Activation Actions", value: "1"))) + XCTAssertEqual(presentation.warnings.count, 2) + } + + func testCheckupPresentationHeadlineFollowsState() throws { + let payload = try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device"), + testDoctorCheck(status: "WARN", message: "bonjour missing", domain: "Finder") + ]).decode(DoctorPayload.self) + let summary = DoctorSummary(payload: payload) + + let presentation = CheckupPresentation(summary: summary, state: .warning) + + XCTAssertEqual(presentation.headline, "Checkup found warnings.") + XCTAssertEqual(presentation.summaryRows.first, PresentationRow(label: "Pass", value: "1")) + XCTAssertEqual(presentation.groups.first?.domain, "Finder") + } + + private func netbsd4DeployPlan() -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_root": .string("/Volumes/dk2"), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "payload_family": .string("netbsd4_samba4"), + "netbsd4": .bool(true), + "requires_reboot": .bool(false), + "reboot_required": .bool(false), + "uploads": .array([.object(["description": .string("smbd")])]), + "pre_upload_actions": .array([]), + "post_upload_actions": .array([]), + "activation_actions": .array([.object(["description": .string("start smbd")])]), + "post_deploy_checks": .array([]), + "summary": .string("deployment dry-run plan generated.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift new file mode 100644 index 0000000..3a0a9d1 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -0,0 +1,392 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DashboardStoreTests: XCTestCase { + func testNoDeviceRegistryLeavesNoSelectedProfile() throws { + let fixture = try makeFixture(responses: []) + + XCTAssertEqual(fixture.registry.state, .empty) + XCTAssertNil(fixture.appStore.selectedProfile) + } + + func testPrimaryActionDerivesFromPasswordCheckupAndDeployState() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + + XCTAssertEqual(fixture.appStore.dashboardSummary(for: profile).primaryAction, .replacePassword) + + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: profile).primaryAction, .runCheckup) + + fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 2, + warnCount: 0, + failCount: 0, + summary: "healthy" + ), for: profile.id) + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: checked).primaryAction, .installSMB) + + fixture.registry.updateDeploy(DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 110), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ), for: profile.id) + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: installed).primaryAction, .openSMB) + + fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 120), + state: .warning, + passCount: 1, + warnCount: 1, + failCount: 0, + summary: "warning" + ), for: profile.id) + let warning = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: warning).primaryAction, .viewCheckup) + } + + func testDashboardOperationsUpdateLastCheckupAndDeploySnapshots() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime"), + testDoctorCheck(status: "WARN", message: "bonjour missing", domain: "Bonjour") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) + ]) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + fixture.appStore.select(profile) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: profile) + + try await waitUntilStoreState { dashboard.doctorStore.state == .warning } + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(checked.lastCheckup?.state, .warning) + XCTAssertEqual(checked.lastCheckup?.warnCount, 1) + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(fixture.runner.calls[0].context?.profileID, profile.id) + + dashboard.runInstallPlan(profile: checked) + try await waitUntilStoreState { dashboard.deployStore.state == .planReady } + dashboard.runInstall(profile: checked) + + try await waitUntilStoreState { dashboard.deployStore.state == .deployed } + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(installed.lastDeploy?.state, .deployed) + XCTAssertEqual(installed.lastDeploy?.payloadFamily, "netbsd6_samba4") + XCTAssertEqual(installed.lastDeploy?.verified, true) + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[2].params["dry_run"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[2].context?.profileID, profile.id) + } + + func testCheckupSnapshotUsesStartedProfileWhenSelectionChanges() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ], delayNanoseconds: 100_000_000) + ]) + let first = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + try fixture.passwordStore.save("pw", for: first.keychainAccount) + fixture.appStore.select(first) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: first) + fixture.appStore.select(second) + + try await waitUntilStoreState { dashboard.doctorStore.state == .passed } + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastCheckup?.state, .passed) + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastCheckup) + } + + func testDeploySnapshotUsesStartedProfileWhenSelectionChanges() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) + ], delayNanoseconds: 100_000_000) + ]) + let first = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + try fixture.passwordStore.save("pw", for: first.keychainAccount) + fixture.appStore.select(first) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runInstallPlan(profile: first) + try await waitUntilStoreState { dashboard.deployStore.state == .planReady } + dashboard.runInstall(profile: first) + fixture.appStore.select(second) + + try await waitUntilStoreState { dashboard.deployStore.state == .deployed } + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastDeploy?.state, .deployed) + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeploy) + } + + func testPasswordLookupFailureMarksProfileMissing() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .unknown, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: profile) + + XCTAssertEqual(dashboard.passwordError, "Password is required.") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + } + + func testAuthFailureMarksSavedPasswordInvalid() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "doctor", code: "auth_failed", message: "Password rejected.") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("bad-password", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: profile) + + try await waitUntilStoreState { dashboard.doctorStore.state == .runFailed } + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .invalid) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: fixture.registry.profile(id: profile.id)!).primaryAction, .replacePassword) + } + + func testRecoveryActionsRouteToMaintenanceAndPasswordWorkflows() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let error = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Needs recovery.") + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Run Disk Repair", kind: .diskRepair), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.selectedTab, .maintenance) + XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .fsck) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Repair File Metadata", kind: .metadataRepair), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .repairXattrs) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Start SMB", kind: .startSMB), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .activate) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Replace Password", kind: .replacePassword), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.selectedTab, .overview) + } + + func testRecoveryRunCheckupAndInstallActionsStartBackendOperations() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "Needs recovery.") + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Run Checkup", kind: .runCheckup), + error: error, + profile: profile + )) + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(dashboard.selectedTab, .checkup) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Install SMB", kind: .installSMB), + error: error, + profile: profile + )) + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[1].operation, "deploy") + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[1].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(dashboard.selectedTab, .install) + } + + func testRecoveryRetryUsesFailedOperation() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let doctorError = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Doctor failed.") + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Retry", kind: .retry), + error: doctorError, + profile: profile + )) + + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(dashboard.selectedTab, .checkup) + } + + func testNonActionableRecoveryKindsReturnFalse() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let error = BackendErrorViewModel(operation: "validate-install", code: "operation_failed", message: "Needs diagnostics.") + + XCTAssertFalse(dashboard.handleRecoveryAction( + RecoveryAction(title: "Open Diagnostics", kind: .diagnostics), + error: error, + profile: profile + )) + XCTAssertFalse(dashboard.handleRecoveryAction( + RecoveryAction(title: "Unknown", kind: .generic), + error: error, + profile: profile + )) + } + + func testForgetProfileDeletesRegistryConfigDirectoryAndPassword() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() + XCTAssertTrue(FileManager.default.fileExists(atPath: configDirectory.path)) + fixture.appStore.select(profile) + + try fixture.appStore.forget(profile) + + XCTAssertEqual(fixture.registry.profiles, []) + XCTAssertNil(fixture.appStore.selectedProfile) + XCTAssertNil(fixture.appStore.selectedDeviceID) + XCTAssertFalse(FileManager.default.fileExists(atPath: configDirectory.path)) + XCTAssertEqual(fixture.passwordStore.state(for: profile.keychainAccount), .missing) + } + + private func makeFixture(responses: [StoreTestRunner.Response]) throws -> ( + appStore: AppStore, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore, + runner: StoreTestRunner + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let runner = StoreTestRunner(responses: responses) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let passwordStore = InMemoryPasswordStore() + let appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.backend), + deviceRegistry: registry, + operationCoordinator: coordinator, + passwordStore: passwordStore + ) + return (appStore, registry, passwordStore, runner) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift new file mode 100644 index 0000000..fec0935 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -0,0 +1,293 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeployWorkflowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DeployWorkflowState.allCases, [ + .idle, + .planning, + .planReady, + .planStale, + .planFailed, + .deploying, + .awaitingConfirmation, + .deployed, + .deployFailed + ]) + } + + func testInvalidMountWaitMovesToPlanFailedWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.mountWait = "1.5" + + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testPlanSendsDryRunParamsAndMovesToPlanReady() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "build_deployment_plan", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.mountWait = "45" + store.noReboot = true + store.noWait = true + store.nbnsEnabled = false + store.debugLogging = true + + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planning) + try await waitUntilStoreState { store.state == .planReady } + XCTAssertEqual(store.currentStage?.stage, "build_deployment_plan") + XCTAssertEqual(store.plan?.payloadDir, "/Volumes/dk2/.samba4") + XCTAssertEqual(runner.calls.count, 1) + XCTAssertEqual(runner.calls[0].operation, "deploy") + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["no_reboot"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["nbns_enabled"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["debug_logging"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["mount_wait"], .number(45)) + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + } + + func testRejectedPlanDoesNotEnterPlanning() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeployWorkflowStore(coordinator: coordinator) + + _ = coordinator.run(operation: "doctor", profile: nil) + try await waitUntilStoreState { runner.calls.count == 1 } + let result = store.runPlan(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "operation_rejected") + XCTAssertEqual(runner.calls.count, 1) + try await waitUntilStoreState { !store.isRunning } + } + + func testMalformedPlanPayloadMovesToPlanFailed() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "") + + try await waitUntilStoreState { store.state == .planFailed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testDeployBeforePlanMarksPlanStaleWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + XCTAssertFalse(store.canDeploy) + store.runDeploy(password: "pw") + + XCTAssertEqual(store.state, .planStale) + XCTAssertEqual(store.error?.code, "plan_stale") + XCTAssertEqual(runner.calls, []) + } + + func testOptionChangeAfterPlanMarksPlanStale() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + + store.noWait = true + + XCTAssertEqual(store.state, .planStale) + XCTAssertFalse(store.canDeploy) + } + + func testDeploySendsRunParamsFromPlanOptionsAndStoresResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployResultPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.mountWait = "30" + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw2") + + XCTAssertEqual(store.state, .deploying) + try await waitUntilStoreState { store.state == .deployed } + XCTAssertEqual(store.currentStage?.stage, "upload_payload") + XCTAssertEqual(store.result?.verified, true) + XCTAssertEqual(runner.calls.count, 2) + XCTAssertEqual(runner.calls[1].params["dry_run"], .bool(false)) + XCTAssertEqual(runner.calls[1].params["mount_wait"], .number(30)) + XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) + } + + func testConfirmationRequiredMovesToAwaitingConfirmationThenConfirmedDeployCompletes() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deployment.", + details: .object([ + "title": .string("Confirm deployment"), + "message": .string("Deploy and reboot."), + "action_title": .string("Deploy"), + "confirmation_id": .string("confirm-1") + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "pre_upload_actions", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployResultPayload()) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = DeployWorkflowStore(backend: backend) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + try await waitUntilStoreState { store.state == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.state == .deployed } + XCTAssertEqual(store.currentStage?.stage, "pre_upload_actions") + XCTAssertEqual(runner.calls.count, 3) + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("confirm-1")) + } + + func testDeployBackendErrorMovesToDeployFailedWithRecovery() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "remote_error", + message: "No HFS volumes found.", + recovery: recoveryValue(title: "No HFS volumes found", actions: ["Wake the disk."], suggestedOperation: "deploy") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + + try await waitUntilStoreState { store.state == .deployFailed } + XCTAssertEqual(store.error?.code, "remote_error") + XCTAssertEqual(store.error?.recovery?.title, "No HFS volumes found") + } + + func testFalseDeployResultMovesToDeployFailed() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: false, payload: .object(["summary": .string("deployment failed.")])) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + + try await waitUntilStoreState { store.state == .deployFailed } + XCTAssertEqual(store.error?.message, "deployment failed.") + } + + func testClearResetsDeployState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.clear() + + XCTAssertEqual(store.state, .idle) + XCTAssertNil(store.plan) + XCTAssertNil(store.result) + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + XCTAssertNil(store.plannedOptions) + } + + private func deployPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_root": .string("/Volumes/dk2"), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "payload_family": .string("netbsd6_samba4"), + "netbsd4": .bool(false), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "uploads": .array([.object(["description": .string("smbd")])]), + "pre_upload_actions": .array([.object(["type": .string("stop_process")])]), + "post_upload_actions": .array([]), + "activation_actions": .array([]), + "post_deploy_checks": .array([ + .object(["id": .string("ssh_returns_after_reboot"), "description": .string("SSH returns after reboot")]) + ]), + "summary": .string("deployment dry-run plan generated.") + ]) + } + + private func deployResultPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "netbsd4": .bool(false), + "payload_family": .string("netbsd6_samba4"), + "requires_reboot": .bool(true), + "rebooted": .bool(true), + "reboot_requested": .bool(true), + "waited": .bool(true), + "verified": .bool(true), + "summary": .string("deployment completed.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift new file mode 100644 index 0000000..d780644 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceProfileTests: XCTestCase { + func testStableConfigPathFromProfileID() { + let appSupport = URL(fileURLWithPath: "/tmp/TimeCapsuleSMBTests", isDirectory: true) + + let configURL = DeviceProfile.configURL(for: "profile-1", applicationSupportURL: appSupport) + + XCTAssertEqual(configURL.path, "/tmp/TimeCapsuleSMBTests/Devices/profile-1/.env") + } + + func testDisplayNameFallbackOrder() { + var profile = makeProfile(displayName: " ", host: "10.0.0.2", bonjourName: "Office Capsule", model: "Model") + XCTAssertEqual(profile.title, "Office Capsule") + + profile.bonjourName = " " + XCTAssertEqual(profile.title, "Model") + + profile.model = nil + XCTAssertEqual(profile.title, "10.0.0.2") + + profile.host = " " + XCTAssertEqual(profile.title, "Time Capsule") + } + + func testDuplicateMatchingUsesBonjourFullnameAndNormalizedHostOnly() { + let first = makeProfile( + id: "one", + host: " TCAPSULE.LOCAL. ", + bonjourFullname: "Office Capsule._airport._tcp.local.", + syap: "119", + model: "Time Capsule" + ) + let sameFullname = makeProfile( + id: "two", + host: "10.0.0.9", + bonjourFullname: " office capsule._AIRPORT._tcp.local. " + ) + let sameHost = makeProfile(id: "three", host: "tcapsule.local.") + let sameHostWithRootUser = makeProfile(id: "five", host: "root@tcapsule.local") + let weakMetadataOnly = makeProfile(id: "four", host: "10.0.0.10", syap: "119", model: "Time Capsule") + + XCTAssertTrue(DeviceProfile.matches(first, sameFullname)) + XCTAssertTrue(DeviceProfile.matches(first, sameHost)) + XCTAssertTrue(DeviceProfile.matches(first, sameHostWithRootUser)) + XCTAssertFalse(DeviceProfile.matches(first, weakMetadataOnly)) + } + + func testRuntimeContextUsesProfileConfigPath() { + let profile = makeProfile(id: "abc", host: "10.0.0.2", configPath: "/tmp/devices/abc/.env") + + XCTAssertEqual(profile.runtimeContext.profileID, "abc") + XCTAssertEqual(profile.runtimeContext.configURL.path, "/tmp/devices/abc/.env") + } + + func testTraitsClassifyNetBSD4NetBSD6AndUnsupportedDevices() { + let netbsd4 = makeProfile(payloadFamily: "netbsd4_samba4") + XCTAssertTrue(netbsd4.traits.isNetBSD4) + XCTAssertFalse(netbsd4.traits.isNetBSD6) + XCTAssertTrue(netbsd4.traits.needsActivationAfterReboot) + XCTAssertTrue(netbsd4.traits.supportsFlashBootHook) + XCTAssertTrue(netbsd4.traits.isSupported) + + let netbsd4ByRelease = makeProfile(osRelease: "4.0") + XCTAssertTrue(netbsd4ByRelease.traits.isNetBSD4) + XCTAssertTrue(netbsd4ByRelease.traits.supportsFlashBootHook) + + let netbsd6 = makeProfile(osRelease: "6.0") + XCTAssertFalse(netbsd6.traits.isNetBSD4) + XCTAssertTrue(netbsd6.traits.isNetBSD6) + XCTAssertFalse(netbsd6.traits.needsActivationAfterReboot) + XCTAssertFalse(netbsd6.traits.supportsFlashBootHook) + XCTAssertTrue(netbsd6.traits.isSupported) + + let unsupported = makeProfile(payloadFamily: "unsupported", deviceGeneration: "unsupported") + XCTAssertFalse(unsupported.traits.isSupported) + } + + private func makeProfile( + id: String = "profile", + displayName: String = "Office Capsule", + host: String = "10.0.0.2", + bonjourName: String? = nil, + bonjourFullname: String? = nil, + syap: String? = nil, + model: String? = nil, + osRelease: String? = nil, + payloadFamily: String? = nil, + deviceGeneration: String? = nil, + configPath: String = "/tmp/profile/.env" + ) -> DeviceProfile { + DeviceProfile( + id: id, + displayName: displayName, + host: host, + bonjourName: bonjourName, + bonjourFullname: bonjourFullname, + hostname: nil, + addresses: [], + syap: syap, + model: model, + osName: nil, + osRelease: osRelease, + arch: nil, + elfEndianness: nil, + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration, + configPath: configPath, + keychainAccount: id, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 20), + lastCheckup: nil, + lastDeploy: nil, + settings: .default, + passwordState: .unknown + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift new file mode 100644 index 0000000..b382eb0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift @@ -0,0 +1,227 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceRegistryStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DeviceRegistryState.allCases, [.idle, .loading, .empty, .loaded, .saving, .failed]) + } + + func testMissingRegistryStartsEmpty() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + + store.load() + + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.devicesDirectoryURL.path)) + } + + func testCorruptRegistryEntersFailedStateWithoutDeletingFile() throws { + let temp = try TemporaryDirectory() + let registryURL = temp.url.appendingPathComponent("devices.json") + try "{ not json".write(to: registryURL, atomically: true, encoding: .utf8) + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + + store.load() + + XCTAssertEqual(store.state, .failed) + XCTAssertNotNil(store.error) + XCTAssertTrue(FileManager.default.fileExists(atPath: registryURL.path)) + XCTAssertEqual(try String(contentsOf: registryURL), "{ not json") + } + + func testCreateUpdateAndDeleteProfile() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + + var profile = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + XCTAssertEqual(store.state, .loaded) + XCTAssertEqual(store.profiles.count, 1) + XCTAssertEqual(profile.configPath, temp.url.appendingPathComponent("Devices/device-one/.env").path) + XCTAssertTrue(FileManager.default.fileExists(atPath: URL(fileURLWithPath: profile.configPath).deletingLastPathComponent().path)) + + profile.displayName = "Renamed Capsule" + profile.settings.debugLogging = true + let updated = try store.updateProfile(profile) + XCTAssertEqual(updated.displayName, "Renamed Capsule") + XCTAssertEqual(store.profiles.first?.settings.debugLogging, true) + + try store.delete(updated) + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + XCTAssertFalse(FileManager.default.fileExists(atPath: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent().path)) + } + + func testDuplicateSaveUpdatesByHostAndBonjourFullnameButNotWeakMetadata() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + + let first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "tcapsule.local.", model: "Time Capsule"), + discoveredDevice: try discovered(record: testDeviceRecord(fullname: "Office._airport._tcp.local.")), + passwordState: .available, + preferredID: "device-one" + ) + let hostDuplicate = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: " TCAPSULE.LOCAL. ", model: "Updated Model"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-two" + ) + XCTAssertEqual(hostDuplicate.id, first.id) + XCTAssertEqual(store.profiles.count, 1) + XCTAssertEqual(store.profiles.first?.model, "Updated Model") + + let fullnameDuplicate = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.9"), + discoveredDevice: try discovered(record: testDeviceRecord( + hostname: "other.local.", + ipv4: ["10.0.0.9"], + fullname: " office._AIRPORT._tcp.local. " + )), + passwordState: .available, + preferredID: "device-three" + ) + XCTAssertEqual(fullnameDuplicate.id, first.id) + XCTAssertEqual(store.profiles.count, 1) + + _ = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.10", syap: "119", model: "Updated Model"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-four" + ) + XCTAssertEqual(store.profiles.count, 2) + } + + func testUpdateProfileDoesNotMergeDuplicateHostIntoAnotherProfile() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + let first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + + var conflictingUpdate = second + conflictingUpdate.host = " root@10.0.0.2. " + + XCTAssertThrowsError(try store.updateProfile(conflictingUpdate)) { error in + XCTAssertEqual( + error as? DeviceRegistryError, + .duplicateProfile(field: "host", value: "10.0.0.2", conflictingProfileID: first.id) + ) + } + XCTAssertEqual(store.profiles.count, 2) + XCTAssertEqual(store.profile(id: first.id)?.host, "10.0.0.2") + XCTAssertEqual(store.profile(id: second.id)?.host, "10.0.0.3") + } + + func testUpdateProfileRejectsDuplicateBonjourFullname() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + let first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: try discovered(record: testDeviceRecord(fullname: "Office._airport._tcp.local.")), + passwordState: .available, + preferredID: "device-one" + ) + var second = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: try discovered(record: testDeviceRecord( + hostname: "den.local.", + ipv4: ["10.0.0.3"], + fullname: "Den._airport._tcp.local." + )), + passwordState: .available, + preferredID: "device-two" + ) + + second.bonjourFullname = " office._AIRPORT._tcp.local. " + + XCTAssertThrowsError(try store.updateProfile(second)) { error in + XCTAssertEqual( + error as? DeviceRegistryError, + .duplicateProfile( + field: "Bonjour fullname", + value: "office._airport._tcp.local.", + conflictingProfileID: first.id + ) + ) + } + XCTAssertEqual(store.profiles.count, 2) + XCTAssertEqual(store.profile(id: second.id)?.bonjourFullname, "Den._airport._tcp.local.") + } + + func testUpdateProfileMissingIDFailsWithoutCreatingProfile() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + var profile = DeviceProfile.make( + id: "missing", + configuredDevice: try testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + applicationSupportURL: temp.url, + date: Date(timeIntervalSince1970: 10) + ) + profile.displayName = "Unsaved" + + XCTAssertThrowsError(try store.updateProfile(profile)) { error in + XCTAssertEqual(error as? DeviceRegistryError, .profileNotFound("missing")) + } + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + } + + func testUpdateProfilePreservesOtherProfilesForLocalEdits() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url, now: { + Date(timeIntervalSince1970: 100) + }) + store.load() + var first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + + first.displayName = "Office" + first.settings.mountWaitSeconds = 45 + let updated = try store.updateProfile(first) + + XCTAssertEqual(updated.displayName, "Office") + XCTAssertEqual(updated.settings.mountWaitSeconds, 45) + XCTAssertEqual(store.profile(id: second.id), second) + XCTAssertEqual(store.profiles.count, 2) + } + + private func discovered(record: JSONValue) throws -> DiscoveredDevice { + let resolved = try record.decode(BonjourResolvedServicePayload.self) + return DiscoveredDevice(record: resolved, index: 0) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift new file mode 100644 index 0000000..1eb9408 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift @@ -0,0 +1,197 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DeviceStatusPolicyTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DeviceDisplayStatus.allCases, [ + .unchecked, + .passwordNeeded, + .passwordInvalid, + .keychainUnavailable, + .checking, + .installing, + .maintaining, + .readyToInstall, + .healthy, + .warning, + .failed, + .activationNeeded, + .removed, + .offline, + .unsupported + ]) + } + + func testDisplayStatusTitlesAreLocalized() { + XCTAssertEqual(DeviceDisplayStatus.allCases.map(\.title), [ + "Unchecked", + "Password Needed", + "Password Invalid", + "Keychain Unavailable", + "Checking", + "Installing", + "Maintenance", + "Ready to Install", + "Healthy", + "Warning", + "Failed", + "Activation Needed", + "Removed", + "Offline", + "Unsupported" + ]) + } + + func testPasswordStateTitlesAreLocalized() { + XCTAssertEqual(DevicePasswordState.allCases.map(\.title), [ + "Unknown", + "Available", + "Missing", + "Invalid", + "Keychain unavailable" + ]) + } + + func testDashboardTabTitlesAreLocalized() { + XCTAssertEqual(DeviceDashboardTab.allCases.map(\.title), [ + "Overview", + "Install / Update", + "Checkup", + "Maintenance", + "Advanced" + ]) + } + + func testPasswordStatesTakePriority() throws { + let profile = try makeProfile() + + XCTAssertEqual(status(profile, .missing), .passwordNeeded) + XCTAssertEqual(status(profile, .unknown), .passwordNeeded) + XCTAssertEqual(status(profile, .invalid), .passwordInvalid) + XCTAssertEqual(status(profile, .keychainUnavailable), .keychainUnavailable) + } + + func testActiveOperationOverridesStoredHealth() throws { + let profile = try makeProfile(lastCheckup: passedCheckup(), lastDeploy: deployed()) + + XCTAssertEqual(status(profile, .available, operation: "doctor"), .checking) + XCTAssertEqual(status(profile, .available, operation: "deploy"), .installing) + XCTAssertEqual(status(profile, .available, operation: "fsck"), .maintaining) + } + + func testHealthStatusFallsBackThroughCheckupAndDeploySnapshots() throws { + XCTAssertEqual(status(try makeProfile(), .available), .unchecked) + XCTAssertEqual(status(try makeProfile(lastCheckup: passedCheckup()), .available), .readyToInstall) + XCTAssertEqual(status(try makeProfile(lastCheckup: passedCheckup(), lastDeploy: deployed()), .available), .healthy) + XCTAssertEqual(status(try makeProfile(lastCheckup: warningCheckup(), lastDeploy: deployed()), .available), .warning) + XCTAssertEqual(status(try makeProfile(lastCheckup: failedCheckup(), lastDeploy: deployed()), .available), .failed) + } + + func testNetBSD4WarningAfterDeployMapsToActivationNeeded() throws { + let profile = try makeProfile( + payloadFamily: "netbsd4_samba4", + lastCheckup: warningCheckup(), + lastDeploy: deployed() + ) + + XCTAssertEqual(status(profile, .available), .activationNeeded) + } + + func testPrimaryActionPolicyUsesStatus() throws { + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(), + passwordState: .missing, + activeOperation: nil + ), .replacePassword) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(), + passwordState: .available, + activeOperation: nil + ), .runCheckup) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(lastCheckup: passedCheckup()), + passwordState: .available, + activeOperation: nil + ), .installSMB) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(lastCheckup: passedCheckup(), lastDeploy: deployed()), + passwordState: .available, + activeOperation: nil + ), .openSMB) + } + + private func status( + _ profile: DeviceProfile, + _ passwordState: DevicePasswordState, + operation: String? = nil + ) -> DeviceDisplayStatus { + DeviceStatusPolicy.status( + for: profile, + passwordState: passwordState, + activeOperation: operation.map { + ActiveOperation(operation: $0, profileID: profile.id, context: profile.runtimeContext) + } + ) + } + + private func makeProfile( + payloadFamily: String = "netbsd6_samba4", + lastCheckup: DeviceCheckupSnapshot? = nil, + lastDeploy: DeviceDeploySnapshot? = nil + ) throws -> DeviceProfile { + var profile = DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(payloadFamily: payloadFamily), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true), + date: Date(timeIntervalSince1970: 1) + ) + profile.lastCheckup = lastCheckup + profile.lastDeploy = lastDeploy + return profile + } + + private func passedCheckup() -> DeviceCheckupSnapshot { + DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 10), + state: .passed, + passCount: 3, + warnCount: 0, + failCount: 0, + summary: "healthy" + ) + } + + private func warningCheckup() -> DeviceCheckupSnapshot { + DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 10), + state: .warning, + passCount: 2, + warnCount: 1, + failCount: 0, + summary: "warning" + ) + } + + private func failedCheckup() -> DeviceCheckupSnapshot { + DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 10), + state: .failed, + passCount: 1, + warnCount: 0, + failCount: 1, + summary: "failed" + ) + } + + private func deployed() -> DeviceDeploySnapshot { + DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 11), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift new file mode 100644 index 0000000..863c375 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift @@ -0,0 +1,227 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DoctorStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DoctorWorkflowState.allCases, [ + .idle, + .running, + .passed, + .warning, + .failed, + .runFailed + ]) + } + + func testInvalidBonjourTimeoutMovesToRunFailedWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DoctorStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "nan" + + store.runDoctor(password: "pw") + + XCTAssertEqual(store.state, .runFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testRunSendsDoctorParamsAndPassedResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks", risk: "remote_read", cancellable: true), + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload( + fatal: false, + checks: [ + check(status: "PASS", message: "smbd is running", domain: "Runtime"), + check(status: "INFO", message: "bonjour visible", domain: "Bonjour") + ] + )) + ]) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "4.5" + store.skipSSH = true + store.skipBonjour = true + store.skipSMB = true + + store.runDoctor(password: "pw") + + XCTAssertEqual(store.state, .running) + try await waitUntilStoreState { store.state == .passed } + XCTAssertEqual(store.currentStage?.stage, "run_checks") + XCTAssertEqual(store.summary?.passCount, 1) + XCTAssertEqual(store.summary?.infoCount, 1) + XCTAssertEqual(runner.calls.first?.operation, "doctor") + XCTAssertEqual(runner.calls.first?.params["bonjour_timeout"], .number(4.5)) + XCTAssertEqual(runner.calls.first?.params["skip_ssh"], .bool(true)) + XCTAssertEqual(runner.calls.first?.params["skip_bonjour"], .bool(true)) + XCTAssertEqual(runner.calls.first?.params["skip_smb"], .bool(true)) + XCTAssertEqual(runner.calls.first?.params["credentials"], .object(["password": .string("pw")])) + } + + func testRejectedRunDoesNotEnterRunning() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DoctorStore(coordinator: coordinator) + + _ = coordinator.run(operation: "deploy", profile: nil) + try await waitUntilStoreState { runner.calls.count == 1 } + let result = store.runDoctor(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.state, .runFailed) + XCTAssertEqual(store.error?.code, "operation_rejected") + XCTAssertEqual(runner.calls.count, 1) + try await waitUntilStoreState { !store.isRunning } + } + + func testWarningResultMovesToWarning() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload( + fatal: false, + checks: [check(status: "WARN", message: "NBNS skipped", domain: "Discovery")] + )) + ]) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + + store.runDoctor(password: "") + + try await waitUntilStoreState { store.state == .warning } + XCTAssertEqual(store.summary?.warnCount, 1) + } + + func testFatalPayloadMovesToFailedAndGroupsFatalFirst() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: false, payload: doctorPayload( + fatal: true, + checks: [ + check(status: "PASS", message: "local tools exist", domain: "Local"), + check(status: "FAIL", message: "smbd is not running", domain: "Runtime"), + check(status: "WARN", message: "bonjour missing", domain: "Bonjour") + ] + )) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + + store.runDoctor(password: "") + + try await waitUntilStoreState { store.state == .failed } + XCTAssertEqual(store.summary?.failCount, 1) + XCTAssertEqual(store.summary?.groups.first?.domain, "Runtime") + } + + func testMissingDomainGroupsAsGeneral() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload( + fatal: false, + checks: [.object([ + "status": .string("PASS"), + "message": .string("config exists"), + "details": .object([:]) + ])] + )) + ]) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + + store.runDoctor(password: "") + + try await waitUntilStoreState { store.state == .passed } + XCTAssertEqual(store.summary?.groups.first?.domain, "General") + } + + func testBackendErrorMovesToRunFailedWithRecovery() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "doctor", + code: "config_error", + message: "missing .env", + recovery: recoveryValue(title: "Configuration error", actions: ["Open Connect."], suggestedOperation: "configure") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + + store.runDoctor(password: "") + + try await waitUntilStoreState { store.state == .runFailed } + XCTAssertEqual(store.error?.code, "config_error") + XCTAssertEqual(store.error?.recovery?.suggestedOperation, "configure") + } + + func testMalformedPayloadMovesToRunFailed() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + + store.runDoctor(password: "") + + try await waitUntilStoreState { store.state == .runFailed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testClearResetsDoctorState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload( + fatal: false, + checks: [check(status: "PASS", message: "ok", domain: "General")] + )) + ]) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + + store.runDoctor(password: "") + try await waitUntilStoreState { store.state == .passed } + store.clear() + + XCTAssertEqual(store.state, .idle) + XCTAssertNil(store.payload) + XCTAssertNil(store.summary) + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + } + + private func doctorPayload(fatal: Bool, checks: [JSONValue]) -> JSONValue { + let pass = checks.filter { $0.stringValue(for: "status") == "PASS" }.count + let warn = checks.filter { $0.stringValue(for: "status") == "WARN" }.count + let fail = checks.filter { $0.stringValue(for: "status") == "FAIL" }.count + let info = checks.filter { $0.stringValue(for: "status") == "INFO" }.count + return .object([ + "schema_version": .number(1), + "fatal": .bool(fatal), + "results": .array(checks), + "counts": .object([ + "PASS": .number(Double(pass)), + "WARN": .number(Double(warn)), + "FAIL": .number(Double(fail)), + "INFO": .number(Double(info)) + ]), + "error": fatal ? .string("doctor failed") : .null, + "summary": .string(fatal ? "doctor found one or more fatal problems." : "doctor checks passed.") + ]) + } + + private func check(status: String, message: String, domain: String) -> JSONValue { + .object([ + "status": .string(status), + "message": .string(message), + "details": .object(["domain": .string(domain)]) + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift new file mode 100644 index 0000000..e1303d6 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class FlashWorkflowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(FlashWorkflowState.allCases, [ + .unavailable, + .disabledInThisBuild, + .eligibleForReadOnlyAnalysis, + .readingBanks, + .savingBackup, + .analyzingBanks, + .planAvailable, + .writeLocked, + .awaitingStrongConfirmation, + .writing, + .readbackValidating, + .writeValidated, + .manualPowerCycleRequired, + .restoreRebooting, + .failed + ]) + } + + func testReleaseDefaultDisablesFlashEvenForNetBSD4() throws { + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + let store = FlashWorkflowStore() + + store.refresh(profile: profile) + + XCTAssertEqual(store.state, .disabledInThisBuild) + XCTAssertTrue(store.eligibilityMessage.contains("disabled")) + } + + func testReadOnlyPolicyAllowsAnalysisButNotWrites() throws { + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + let eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: .readOnly) + + XCTAssertEqual(eligibility.state, .eligibleForReadOnlyAnalysis) + XCTAssertTrue(eligibility.readOnlyAllowed) + XCTAssertFalse(eligibility.writeAllowed) + } + + func testNonNetBSD4DeviceIsUnavailable() throws { + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + + let eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: .writesEnabled) + + XCTAssertEqual(eligibility.state, .unavailable) + XCTAssertFalse(eligibility.readOnlyAllowed) + XCTAssertFalse(eligibility.writeAllowed) + } + + private func makeProfile(payloadFamily: String) throws -> DeviceProfile { + DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(payloadFamily: payloadFamily), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift new file mode 100644 index 0000000..c34ed9b --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -0,0 +1,190 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class HelperLocatorTests: XCTestCase { + func testLocatorUsesExplicitHelperAndSetsAppEnvironment() throws { + let temp = try TemporaryDirectory() + let helper = temp.url.appendingPathComponent("tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let locator = HelperLocator( + environment: [:], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: helper.path) + let environment = locator.helperEnvironment(for: resolution) + + XCTAssertEqual(resolution.executableURL.path, helper.path) + XCTAssertEqual(resolution.mode, .explicit) + XCTAssertNil(resolution.toolsBinURL) + XCTAssertNotNil(environment["TCAPSULE_CONFIG"]) + XCTAssertNotNil(environment["TCAPSULE_STATE_DIR"]) + XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1") + } + + func testLocatorUsesDeviceContextConfigWithoutChangingAppStateDirectory() throws { + let temp = try TemporaryDirectory() + let helper = temp.url.appendingPathComponent("tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + let context = DeviceRuntimeContext( + profileID: "device-one", + configURL: temp.url.appendingPathComponent("Devices/device-one/.env") + ) + let locator = HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default) + + let resolution = try locator.resolve(helperPath: helper.path) + let environment = locator.helperEnvironment(for: resolution, context: context) + + XCTAssertEqual(environment["TCAPSULE_CONFIG"], context.configURL.path) + XCTAssertNotNil(environment["TCAPSULE_STATE_DIR"]) + XCTAssertNotEqual(environment["TCAPSULE_STATE_DIR"], context.configURL.deletingLastPathComponent().path) + XCTAssertTrue(FileManager.default.fileExists(atPath: context.configURL.deletingLastPathComponent().path)) + } + + func testLocatorDiscoversRepoHelperFromSourceRoot() throws { + let temp = try TemporaryDirectory() + let repo = temp.url.appendingPathComponent("Repo", isDirectory: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent(".venv/bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("src/timecapsulesmb", isDirectory: true), withIntermediateDirectories: true) + try "".write(to: repo.appendingPathComponent("pyproject.toml"), atomically: true, encoding: .utf8) + let helper = repo.appendingPathComponent(".venv/bin/tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let locator = HelperLocator( + environment: ["TCAPSULE_SOURCE_ROOT": repo.path], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: nil) + let environment = locator.helperEnvironment(for: resolution) + + XCTAssertEqual(resolution.executableURL.path, helper.path) + XCTAssertEqual(resolution.distributionRootURL?.path, repo.path) + XCTAssertEqual(resolution.mode, .developmentCheckout) + XCTAssertNil(resolution.toolsBinURL) + XCTAssertEqual(environment["TCAPSULE_DISTRIBUTION_ROOT"], repo.path) + } + + func testLocatorPrefersProductionBundleOverDevelopmentHelper() throws { + let temp = try TemporaryDirectory() + let bundle = try makeAppBundle(in: temp.url) + let repo = try makeRepo(in: temp.url) + + let locator = HelperLocator( + environment: ["TCAPSULE_SOURCE_ROOT": repo.path], + currentDirectory: temp.url, + bundle: bundle, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: nil) + + XCTAssertEqual(resolution.mode, .productionBundle) + XCTAssertEqual(resolution.executableURL.path, bundle.bundleURL.appendingPathComponent("Contents/Helpers/tcapsule").path) + XCTAssertEqual(resolution.distributionRootURL?.path, bundle.resourceURL?.appendingPathComponent("Distribution").path) + XCTAssertEqual(resolution.toolsBinURL?.path, bundle.resourceURL?.appendingPathComponent("Tools/bin").path) + } + + func testLocatorPrependsBundledToolsToPath() throws { + let temp = try TemporaryDirectory() + let bundle = try makeAppBundle(in: temp.url) + let locator = HelperLocator( + environment: ["PATH": "/usr/bin"], + currentDirectory: temp.url, + bundle: bundle, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: nil) + let environment = locator.helperEnvironment(for: resolution) + + XCTAssertEqual(environment["PATH"], "\(resolution.toolsBinURL!.path):/usr/bin") + XCTAssertEqual(environment["TCAPSULE_DISTRIBUTION_ROOT"], resolution.distributionRootURL?.path) + } + + func testProductionRuntimeIssuesReportMissingToolsAsWarning() throws { + let temp = try TemporaryDirectory() + let bundle = try makeAppBundle(in: temp.url, createTools: false) + let locator = HelperLocator(environment: [:], currentDirectory: temp.url, bundle: bundle, fileManager: .default) + + let resolution = try locator.resolve(helperPath: nil) + let issues = locator.runtimeIssues(for: resolution) + + XCTAssertTrue(issues.contains(where: { $0.code == .toolsDirectoryMissing && $0.severity == .warning })) + } + + func testLocatorReportsAttemptedPathsWhenMissing() throws { + let temp = try TemporaryDirectory() + let locator = HelperLocator( + environment: ["TCAPSULE_SOURCE_ROOT": temp.url.path], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + XCTAssertThrowsError(try locator.resolve(helperPath: nil)) { error in + guard case HelperLocatorError.notFound(let attempts) = error else { + return XCTFail("unexpected error \(error)") + } + XCTAssertFalse(attempts.isEmpty) + } + } + + private func makeRepo(in directory: URL) throws -> URL { + let repo = directory.appendingPathComponent("Repo", isDirectory: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent(".venv/bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("src/timecapsulesmb", isDirectory: true), withIntermediateDirectories: true) + try "".write(to: repo.appendingPathComponent("pyproject.toml"), atomically: true, encoding: .utf8) + let helper = repo.appendingPathComponent(".venv/bin/tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + return repo + } + + private func makeAppBundle(in directory: URL, createTools: Bool = true) throws -> Bundle { + let app = directory.appendingPathComponent("TimeCapsuleSMB.app", isDirectory: true) + let contents = app.appendingPathComponent("Contents", isDirectory: true) + let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) + let resources = contents.appendingPathComponent("Resources", isDirectory: true) + let helpers = contents.appendingPathComponent("Helpers", isDirectory: true) + try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) + try """ + + + + + CFBundleExecutable + TimeCapsuleSMB + CFBundleIdentifier + test.TimeCapsuleSMB + CFBundlePackageType + APPL + + + """.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) + try "#!/bin/sh\nexit 0\n".write(to: macOS.appendingPathComponent("TimeCapsuleSMB"), atomically: true, encoding: .utf8) + try "#!/bin/sh\nexit 0\n".write(to: helpers.appendingPathComponent("tcapsule"), atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helpers.appendingPathComponent("tcapsule").path) + try FileManager.default.createDirectory(at: resources.appendingPathComponent("Distribution", isDirectory: true), withIntermediateDirectories: true) + if createTools { + try FileManager.default.createDirectory(at: resources.appendingPathComponent("Tools/bin", isDirectory: true), withIntermediateDirectories: true) + } + guard let bundle = Bundle(url: app) else { + throw NSError(domain: "HelperLocatorTests", code: 1) + } + return bundle + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift new file mode 100644 index 0000000..d6e3057 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -0,0 +1,219 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class HelperRunnerTests: XCTestCase { + func testRunnerStreamsEventsFromHelper() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"stage","operation":"paths","stage":"start"}' + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"paths","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.map(\.type), ["stage", "result"]) + XCTAssertEqual(events.last?.ok, true) + } + + func testRunnerWaitsForEventDeliveryBeforeReturning() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"paths","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { event in + try? await Task.sleep(nanoseconds: 50_000_000) + await recorder.append(event) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.map(\.type), ["result"]) + } + + func testRunnerSynthesizesErrorWhenHelperHasNoTerminalEvent() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"type":"log","operation":"doctor","level":"info","message":"working"}' + echo 'stderr detail' >&2 + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "missing_terminal_event") + XCTAssertEqual(events.last?.message, L10n.string("helper.error.missing_terminal_event")) + XCTAssertEqual(events.last?.debug, .object(["stderr": .string("stderr detail\n")])) + } + + func testRunnerDrainsLargeStderrWhileHelperIsRunning() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + i=0 + while [ "$i" -lt 2000 ]; do + printf '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\\n' >&2 + i=$((i + 1)) + done + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"doctor","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr.count, 64 * 1024) + XCTAssertEqual(events.last?.type, "result") + XCTAssertEqual(events.last?.ok, true) + } + + func testRunnerDecodesTruncatedUTF8StderrWithReplacementCharacter() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + printf '\\303\\251' >&2 + """ + ) + let runner = HelperRunner( + locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default), + stderrLimit: 1 + ) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr, "\u{FFFD}") + XCTAssertEqual(events.last?.code, "missing_terminal_event") + } + + func testRunnerReportsMissingHelper() async { + let locator = HelperLocator(environment: [:], currentDirectory: URL(fileURLWithPath: NSTemporaryDirectory()), bundle: .main, fileManager: .default) + let runner = HelperRunner(locator: locator) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: "/missing/tcapsule", operation: "paths", params: [:]) { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "helper_not_found") + } + + func testRunnerCancelsLongRunningHelper() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + while true; do + sleep 1 + done + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let task = Task { + await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + await recorder.append($0) + } + } + try await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + let result = await task.value + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 130) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "cancelled") + XCTAssertEqual(events.last?.message, L10n.string("helper.error.cancelled")) + } + + func testRunnerCancelsBlockedRequestWrite() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + sleep 10 + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + let largePayload = String(repeating: "x", count: 8 * 1024 * 1024) + + let task = Task { + await runner.run(helperPath: helper.path, operation: "doctor", params: ["payload": .string(largePayload)]) { + await recorder.append($0) + } + } + try await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + let result = await task.value + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 130) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "cancelled") + } + + private func makeHelper(in directory: URL, body: String) throws -> URL { + let helper = directory.appendingPathComponent("tcapsule") + try "#!/bin/sh\n\(body)\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + return helper + } +} + +private actor EventRecorder { + private var storage: [BackendEvent] = [] + + var events: [BackendEvent] { + storage + } + + func append(_ event: BackendEvent) { + storage.append(event) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift new file mode 100644 index 0000000..5ff38d8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class HostCompatibilityPolicyTests: XCTestCase { + func testWarnsForKnownProblemVersions() { + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 5))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 6))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 7))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 4, patchVersion: 0))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 4, patchVersion: 12))) + } + + func testDoesNotWarnForAdjacentVersions() { + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 4))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 8))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 6, patchVersion: 7))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 3, patchVersion: 9))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 0))) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift new file mode 100644 index 0000000..88a429d --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift @@ -0,0 +1,612 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class MaintenanceStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(MaintenanceOperationState.allCases, [ + .idle, + .loading, + .listReady, + .planning, + .planReady, + .planStale, + .scanning, + .scanReady, + .scanStale, + .awaitingConfirmation, + .running, + .repairing, + .succeeded, + .repaired, + .failed + ]) + XCTAssertEqual(MaintenanceWorkflow.allCases, [.activate, .uninstall, .fsck, .repairXattrs]) + } + + func testActivationPlanAndAlreadyActiveResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "activate", stage: "build_activation_plan", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationResultPayload(alreadyActive: true)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "pw") + + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "build_activation_plan") + XCTAssertEqual(store.activationPlan?.actions.count, 1) + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + + store.runActivation(password: "pw2") + + try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } + XCTAssertEqual(store.activationResult?.alreadyActive, true) + XCTAssertEqual(runner.calls[1].params["dry_run"], .bool(false)) + XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) + } + + func testRejectedActivationPlanDoesNotEnterPlanning() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = MaintenanceStore(coordinator: coordinator) + + _ = coordinator.run(operation: "doctor", profile: nil) + try await waitUntilStoreState { runner.calls.count == 1 } + let result = store.planActivation(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.activateState, .failed) + XCTAssertEqual(store.error?.code, "operation_rejected") + XCTAssertEqual(runner.calls.count, 1) + try await waitUntilStoreState { !store.isRunning } + } + + func testActivationRequiresPlanAndHandlesConfirmationReplay() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "activate", id: "activate-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "stage", operation: "activate", stage: "run_activation", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationResultPayload(alreadyActive: false)) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + store.runActivation(password: "pw") + XCTAssertEqual(store.activateState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + store.runActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "run_activation") + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("activate-confirm")) + } + + func testActivationBackendErrorAndMalformedPayloadFail() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "activate", + code: "unsupported_device", + message: "NetBSD4 activation is not available.", + recovery: recoveryValue(title: "Activation unavailable", actions: ["Use deploy instead."], suggestedOperation: "deploy") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "") + try await waitUntilStoreState { store.activateState == .failed && !store.isRunning } + XCTAssertEqual(store.error?.code, "unsupported_device") + XCTAssertEqual(store.error?.recovery?.title, "Activation unavailable") + + store.planActivation(password: "") + try await waitUntilStoreState { store.activateState == .failed && store.error?.code == "contract_decode_failed" && !store.isRunning } + } + + func testUninstallPlanStaleRunAndBackendError() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallResultPayload(waited: false, verified: false)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "uninstall", + code: "remote_error", + message: "uninstall failed", + recovery: recoveryValue(title: "Uninstall failed", actions: ["Retry."], suggestedOperation: "uninstall") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.mountWait = "15" + store.noReboot = true + + store.planUninstall(password: "pw") + + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + XCTAssertEqual(store.uninstallPlan?.payloadDirs, ["/Volumes/dk2/.samba4"]) + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["mount_wait"], .number(15)) + + store.noWait = true + XCTAssertEqual(store.uninstallState, .planStale) + store.runUninstall(password: "pw") + XCTAssertEqual(store.error?.code, "plan_stale") + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .succeeded && !store.isRunning } + XCTAssertEqual(store.uninstallResult?.waited, false) + XCTAssertEqual(store.uninstallResult?.verified, false) + XCTAssertEqual(runner.calls[2].params["dry_run"], .bool(false)) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .failed } + XCTAssertEqual(store.error?.code, "remote_error") + XCTAssertEqual(store.error?.recovery?.title, "Uninstall failed") + } + + func testUninstallInvalidMountWaitAndMalformedPlanFail() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.mountWait = "bad" + + store.planUninstall(password: "") + + XCTAssertEqual(store.uninstallState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + + store.mountWait = "30" + store.planUninstall(password: "") + + try await waitUntilStoreState { store.uninstallState == .failed && store.error?.code == "contract_decode_failed" && !store.isRunning } + } + + func testUninstallConfirmationReplayCompletes() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "uninstall", id: "uninstall-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "stage", operation: "uninstall", stage: "remove_payload", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallResultPayload(waited: true, verified: true)) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.uninstallState == .succeeded && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "remove_payload") + XCTAssertEqual(store.uninstallResult?.verified, true) + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("uninstall-confirm")) + } + + func testFsckListPlanStaleAndRunConfirmation() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [fsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "fsck", id: "fsck-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckResultPayload(returncode: 0)) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + store.refreshFsckTargets(password: "pw") + + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + XCTAssertEqual(store.fsckTargets.count, 1) + XCTAssertEqual(store.selectedFsckTarget?.name, "Data") + XCTAssertEqual(runner.calls[0].params["list_volumes"], .bool(true)) + + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + XCTAssertEqual(store.fsckPlan?.device, "/dev/dk2") + XCTAssertEqual(runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[1].params["volume"], .string("/dev/dk2")) + + store.noWait = true + XCTAssertEqual(store.fsckState, .planStale) + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + store.runFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.fsckState == .succeeded } + XCTAssertEqual(store.fsckResult?.returncode, 0) + XCTAssertEqual(runner.calls[4].params["confirmation_id"], .string("fsck-confirm")) + } + + func testFsckEmptyListPlanValidationAndFalseResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [fsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: false, payload: fsckResultPayload(returncode: 1)) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + XCTAssertEqual(store.fsckTargets, []) + + store.planFsck(password: "") + XCTAssertEqual(store.fsckState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .listReady && store.fsckTargets.count == 1 && !store.isRunning } + store.planFsck(password: "") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + store.runFsck(password: "") + try await waitUntilStoreState { store.fsckState == .failed } + XCTAssertEqual(store.error?.code, "operation_failed") + } + + func testFsckFallbackVolumeParamTargetChangeBackendErrorAndMalformedPayloads() async throws { + let targetWithoutName = fsckTargetPayload(name: nil, device: "/dev/dk3", mountpoint: "/Volumes/External") + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [ + targetWithoutName, + fsckTargetPayload(name: "Data") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload(target: targetWithoutName, device: "/dev/dk3", mountpoint: "/Volumes/External")) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "fsck", + code: "validation_failed", + message: "No HFS volume selected.", + recovery: recoveryValue(title: "Select a volume", actions: ["List volumes again."], suggestedOperation: "fsck") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + XCTAssertNil(store.selectedFsckTargetID) + store.selectedFsckTargetID = store.fsckTargets[0].id + + store.planFsck(password: "") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + XCTAssertEqual(runner.calls[1].params["volume"], .string("/dev/dk3")) + + store.selectedFsckTargetID = store.fsckTargets[1].id + XCTAssertEqual(store.fsckState, .planStale) + store.runFsck(password: "") + XCTAssertEqual(store.error?.code, "plan_stale") + + store.planFsck(password: "") + try await waitUntilStoreState { store.fsckState == .failed } + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.recovery?.title, "Select a volume") + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .failed && store.error?.code == "contract_decode_failed" } + } + + func testRepairXattrsScanRepairStaleConfirmationAndBackendError() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "repair-xattrs", stage: "scan_findings", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + confirmationRequired(operation: "repair-xattrs", id: "repair-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 0)) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "repair-xattrs", + code: "validation_failed", + message: "repair-xattrs must run on macOS", + recovery: recoveryValue(title: "repair-xattrs cannot run", actions: ["Run this from macOS."], suggestedOperation: "repair-xattrs") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + store.repairPath = "/Volumes/Data" + + store.scanRepairXattrs() + + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "scan_findings") + XCTAssertTrue(store.canRepairXattrs) + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + + store.repairPath = "/Volumes/Other" + XCTAssertEqual(store.repairState, .scanStale) + store.repairPath = "/Volumes/Data" + store.runRepairXattrs() + XCTAssertEqual(store.repairState, .scanStale) + XCTAssertEqual(store.error?.code, "scan_stale") + XCTAssertEqual(runner.calls.count, 1) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + store.runRepairXattrs() + try await waitUntilStoreState { store.repairState == .awaitingConfirmation && backend.pendingConfirmation != nil } + backend.confirmPending() + try await waitUntilStoreState { store.repairState == .repaired } + XCTAssertEqual(store.repairResult?.repairableCount, 0) + XCTAssertEqual(runner.calls[3].params["confirmation_id"], .string("repair-confirm")) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .failed } + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.recovery?.title, "repair-xattrs cannot run") + } + + func testRepairXattrsMissingPathZeroRepairableAndMalformedPayload() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 0, repairable: 0)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.scanRepairXattrs() + XCTAssertEqual(store.repairState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + + store.repairPath = "/Volumes/Data" + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady } + XCTAssertFalse(store.canRepairXattrs) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .failed && store.error?.code == "contract_decode_failed" } + } + + func testClearResetsMaintenanceState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "") + try await waitUntilStoreState { store.activateState == .planReady } + store.clear() + + XCTAssertEqual(store.activateState, .idle) + XCTAssertEqual(store.uninstallState, .idle) + XCTAssertEqual(store.fsckState, .idle) + XCTAssertEqual(store.repairState, .idle) + XCTAssertNil(store.activationPlan) + XCTAssertNil(store.uninstallPlan) + XCTAssertNil(store.fsckPlan) + XCTAssertNil(store.repairScan) + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + } + + private func confirmationRequired(operation: String, id: String) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: "confirmation_required", + message: "Confirm \(operation).", + details: .object([ + "title": .string("Confirm \(operation)"), + "message": .string("Confirm \(operation)."), + "action_title": .string("Confirm"), + "confirmation_id": .string(id) + ]) + ) + } + + private func activationPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "actions": .array([.object(["type": .string("run_script")])]), + "post_activation_checks": .array([ + .object(["id": .string("runtime_ready"), "description": .string("runtime ready")]) + ]), + "counts": .object(["actions": .number(1)]), + "summary": .string("NetBSD4 activation dry-run plan generated.") + ]) + } + + private func activationResultPayload(alreadyActive: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "already_active": .bool(alreadyActive), + "summary": .string(alreadyActive ? "NetBSD4 payload was already active." : "NetBSD4 activation completed.") + ]) + } + + private func uninstallPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_roots": .array([.string("/Volumes/dk2")]), + "payload_dirs": .array([.string("/Volumes/dk2/.samba4")]), + "remote_actions": .array([.object(["type": .string("remove_path")])]), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "post_uninstall_checks": .array([ + .object(["id": .string("managed_files_absent"), "description": .string("managed files absent")]) + ]), + "counts": .object(["payload_dirs": .number(1)]), + "summary": .string("uninstall dry-run plan generated.") + ]) + } + + private func uninstallResultPayload(waited: Bool, verified: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "summary": .string(verified ? "uninstall completed." : "uninstall completed without post-reboot verification."), + "requires_reboot": .bool(true), + "rebooted": .bool(false), + "reboot_requested": .bool(true), + "waited": .bool(waited), + "verified": .bool(verified) + ]) + } + + private func fsckListPayload(targets: [JSONValue]) -> JSONValue { + .object([ + "schema_version": .number(1), + "targets": .array(targets), + "counts": .object(["targets": .number(Double(targets.count))]), + "summary": .string("found \(targets.count) mounted HFS volume(s).") + ]) + } + + private func fsckTargetPayload( + name: String?, + device: String = "/dev/dk2", + mountpoint: String = "/Volumes/dk2" + ) -> JSONValue { + var payload: [String: JSONValue] = [ + "device": .string(device), + "mountpoint": .string(mountpoint), + "builtin": .bool(true) + ] + if let name { + payload["name"] = .string(name) + } + return .object(payload) + } + + private func fsckPlanPayload( + target: JSONValue? = nil, + device: String = "/dev/dk2", + mountpoint: String = "/Volumes/dk2" + ) -> JSONValue { + .object([ + "schema_version": .number(1), + "target": target ?? fsckTargetPayload(name: "Data"), + "device": .string(device), + "mountpoint": .string(mountpoint), + "reboot_required": .bool(true), + "wait_after_reboot": .bool(false), + "summary": .string("fsck dry-run plan generated.") + ]) + } + + private func fsckResultPayload(returncode: Int) -> JSONValue { + .object([ + "schema_version": .number(1), + "device": .string("/dev/dk2"), + "mountpoint": .string("/Volumes/dk2"), + "returncode": .number(Double(returncode)), + "reboot_requested": .bool(false), + "waited": .bool(false), + "verified": .bool(false), + "summary": .string("fsck completed.") + ]) + } + + private func repairPayload(findings: Int, repairable: Int) -> JSONValue { + .object([ + "schema_version": .number(1), + "returncode": .number(0), + "root": .string("/Volumes/Data"), + "finding_count": .number(Double(findings)), + "repairable_count": .number(Double(repairable)), + "counts": .object([ + "findings": .number(Double(findings)), + "repairable": .number(Double(repairable)) + ]), + "stats": .object([:]), + "report": .string("report"), + "summary": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable."), + "summary_text": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift new file mode 100644 index 0000000..76fbe63 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class OperationTimelineBuilderTests: XCTestCase { + func testBuildsUserFacingTimelineFromStagesResultsAndErrors() { + let events = [ + BackendEvent( + type: "stage", + operation: "deploy", + stage: "upload_payload", + risk: "remote_write", + cancellable: false, + description: "Upload managed Samba payload files." + ), + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deployment." + ), + BackendEvent( + type: "result", + operation: "deploy", + ok: true, + payload: .object(["summary": .string("deployment completed.")]) + ) + ] + + let timeline = OperationTimelineBuilder.timeline(from: events) + + XCTAssertEqual(timeline.map(\.title), ["Uploading", "Needs Confirmation", "Done"]) + XCTAssertEqual(timeline[0].risk, "remote_write") + XCTAssertEqual(timeline[0].cancellable, false) + XCTAssertEqual(timeline[1].state, .warning) + XCTAssertEqual(timeline[2].detail, "deployment completed.") + } + + func testOperationTitlesAreUserFacing() { + XCTAssertEqual(OperationTimelineBuilder.operationTitle("deploy"), "Install / Update") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("doctor"), "Checkup") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("repair-xattrs"), "File Metadata Repair") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("paths"), "App Readiness") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("flash"), "Persistent NetBSD4 Boot Hook") + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift new file mode 100644 index 0000000..0c57055 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift @@ -0,0 +1,20 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class OutputLineParserTests: XCTestCase { + func testParserHandlesSplitMultipleAndUnterminatedLines() { + var parser = OutputLineParser() + + var events: [BackendEvent] = [] + events.append(contentsOf: parser.append(Data(#"{"type":"stage","operation":"paths","stage":"resolve"#.utf8))) + events.append(contentsOf: parser.append(Data(#"_paths"}"#.utf8))) + events.append(contentsOf: parser.append(Data("\nnot-json\n".utf8))) + events.append(contentsOf: parser.append(Data(#"{"type":"result","operation":"paths","ok":true,"payload":{}}"#.utf8))) + events.append(contentsOf: parser.finish()) + + XCTAssertEqual(events.map(\.type), ["stage", "result"]) + XCTAssertEqual(events.first?.stage, "resolve_paths") + XCTAssertEqual(events.last?.ok, true) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift new file mode 100644 index 0000000..6298793 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift @@ -0,0 +1,121 @@ +import Security +import XCTest +@testable import TimeCapsuleSMBApp + +final class PasswordStoreTests: XCTestCase { + func testSaveReadUpdateAndDeletePassword() throws { + let store = InMemoryPasswordStore() + + try store.save("first", for: "device") + XCTAssertEqual(try store.password(for: "device"), "first") + XCTAssertEqual(store.state(for: "device"), .available) + + try store.save("second", for: "device") + XCTAssertEqual(try store.password(for: "device"), "second") + + try store.deletePassword(for: "device") + XCTAssertThrowsError(try store.password(for: "device")) { error in + XCTAssertEqual(error as? PasswordStoreError, .missing) + } + XCTAssertEqual(store.state(for: "device"), .missing) + } + + func testInvalidAndUnavailableStates() throws { + let store = InMemoryPasswordStore(passwords: ["device": "pw"]) + + store.markInvalid(account: "device") + XCTAssertEqual(store.state(for: "device"), .invalid) + + store.readFailure = .read + XCTAssertEqual(store.state(for: "device"), .keychainUnavailable) + XCTAssertThrowsError(try store.password(for: "device")) { error in + guard case PasswordStoreError.unavailable = error else { + return XCTFail("unexpected error \(error)") + } + } + } + + func testSaveAndDeleteFailuresSurfaceUnavailable() { + let store = InMemoryPasswordStore() + store.saveFailure = .save + + XCTAssertThrowsError(try store.save("pw", for: "device")) { error in + guard case PasswordStoreError.unavailable = error else { + return XCTFail("unexpected error \(error)") + } + } + + store.saveFailure = nil + store.deleteFailure = .delete + XCTAssertThrowsError(try store.deletePassword(for: "device")) { error in + guard case PasswordStoreError.unavailable = error else { + return XCTFail("unexpected error \(error)") + } + } + } + + func testKeychainStoreAddsPasswordWithWhenUnlockedThisDeviceOnlyAccessibility() throws { + let keychain = RecordingKeychainClient() + keychain.updateStatus = errSecItemNotFound + let store = KeychainPasswordStore(service: "test.service", keychainClient: keychain) + + try store.save("secret", for: "device") + + XCTAssertEqual(keychain.addedQuery?[kSecAttrService as String] as? String, "test.service") + XCTAssertEqual(keychain.addedQuery?[kSecAttrAccount as String] as? String, "device") + XCTAssertEqual(keychain.addedQuery?[kSecAttrAccessible as String] as? String, kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String) + XCTAssertEqual(keychain.addedQuery?[kSecValueData as String] as? Data, Data("secret".utf8)) + } + + func testKeychainStoreMigratesAccessibilityOnPasswordUpdate() throws { + let keychain = RecordingKeychainClient() + keychain.updateStatus = errSecSuccess + let store = KeychainPasswordStore(service: "test.service", keychainClient: keychain) + + try store.save("updated", for: "device") + + XCTAssertNil(keychain.addedQuery) + XCTAssertEqual(keychain.updatedAttributes?[kSecAttrAccessible as String] as? String, kSecAttrAccessibleWhenUnlockedThisDeviceOnly as String) + XCTAssertEqual(keychain.updatedAttributes?[kSecValueData as String] as? Data, Data("updated".utf8)) + } +} + +private final class RecordingKeychainClient: KeychainClient { + var copyStatus: OSStatus = errSecItemNotFound + var copyResult: CFTypeRef? + var addStatus: OSStatus = errSecSuccess + var updateStatus: OSStatus = errSecItemNotFound + var deleteStatus: OSStatus = errSecSuccess + + private(set) var copiedQuery: [String: Any]? + private(set) var addedQuery: [String: Any]? + private(set) var updatedQuery: [String: Any]? + private(set) var updatedAttributes: [String: Any]? + private(set) var deletedQuery: [String: Any]? + + func copyMatching(_ query: [String: Any], result: inout CFTypeRef?) -> OSStatus { + copiedQuery = query + result = copyResult + return copyStatus + } + + func add(_ query: [String: Any]) -> OSStatus { + addedQuery = query + return addStatus + } + + func update(_ query: [String: Any], attributes: [String: Any]) -> OSStatus { + updatedQuery = query + updatedAttributes = attributes + return updateStatus + } + + func delete(_ query: [String: Any]) -> OSStatus { + deletedQuery = query + return deleteStatus + } + + func message(for status: OSStatus) -> String? { + "status \(status)" + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift new file mode 100644 index 0000000..d20ab20 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class PendingConfirmationTests: XCTestCase { + func testLocalizedStringsLoadFromResourceBundle() { + XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") + XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") + XCTAssertEqual(L10n.string("button.capabilities"), "Capabilities") + XCTAssertEqual(L10n.string("helper.error.cancelled"), "Operation cancelled.") + XCTAssertEqual(L10n.format("event.summary.result", "deploy", "finished"), "deploy: finished") + } + + func testUninstallPlanParamsCarryNoRebootSelection() { + let params = OperationParams.uninstallPlan(noReboot: true, noWait: true, mountWait: 9, password: "pw") + + XCTAssertEqual(params["dry_run"], .bool(true)) + XCTAssertEqual(params["no_reboot"], .bool(true)) + XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertEqual(params["mount_wait"], .number(9)) + XCTAssertEqual(params["credentials"], .object(["password": .string("pw")])) + } + + func testDeployRunParamsCarryOptionsWithoutFrontendConsentFlags() { + let params = OperationParams.deployRun( + noReboot: false, + noWait: true, + nbnsEnabled: true, + debugLogging: true, + mountWait: 45, + password: "" + ) + + XCTAssertEqual(params["dry_run"], .bool(false)) + XCTAssertNil(params["confirm_deploy"]) + XCTAssertNil(params["confirm_reboot"]) + XCTAssertNil(params["confirm_netbsd4_activation"]) + XCTAssertEqual(params["no_reboot"], .bool(false)) + XCTAssertEqual(params["nbns_enabled"], .bool(true)) + XCTAssertEqual(params["debug_logging"], .bool(true)) + XCTAssertEqual(params["mount_wait"], .number(45)) + XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertNil(params["credentials"]) + } + + func testConfigureParamsUseSelectedRecordInsteadOfManualHostWhenProvided() { + let selectedRecord = JSONValue.object([ + "name": .string("TC"), + "hostname": .string("tc.local."), + "ipv4": .array([.string("10.0.0.2")]), + "properties": .object(["syAP": .string("119")]) + ]) + + let params = OperationParams.configure( + host: "root@manual", + selectedRecord: selectedRecord, + password: "pw", + debugLogging: true + ) + + XCTAssertNil(params["host"]) + XCTAssertEqual(params["selected_record"], selectedRecord) + XCTAssertEqual(params["password"], .string("pw")) + XCTAssertEqual(params["debug_logging"], .bool(true)) + } + + func testConfigureParamsDefaultBareManualHostToRootUser() { + let params = OperationParams.configure( + host: " 10.0.0.2 ", + password: "pw", + debugLogging: false + ) + + XCTAssertEqual(params["host"], .string("root@10.0.0.2")) + XCTAssertEqual(params["password"], .string("pw")) + XCTAssertEqual(params["persist_password"], .bool(false)) + } + + func testPendingConfirmationBuildsFromBackendEvent() throws { + let event = BackendEvent( + type: "error", + operation: "uninstall", + code: "confirmation_required", + message: "Confirm uninstall.", + details: .object([ + "title": .string("Confirm uninstall"), + "message": .string("Remove files."), + "action_title": .string("Uninstall"), + "confirmation_id": .string("abc123") + ]) + ) + let originalParams = OperationParams.uninstallRun(noReboot: true, noWait: true, mountWait: 12, password: "pw") + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: originalParams)) + + XCTAssertEqual(confirmation.operation, "uninstall") + XCTAssertEqual(confirmation.title, "Confirm uninstall") + XCTAssertEqual(confirmation.message, "Remove files.") + XCTAssertEqual(confirmation.actionTitle, "Uninstall") + XCTAssertEqual(confirmation.params["confirmation_id"], .string("abc123")) + XCTAssertEqual(confirmation.params["no_reboot"], .bool(true)) + XCTAssertEqual(confirmation.params["mount_wait"], .number(12)) + XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) + XCTAssertEqual(confirmation.params["credentials"], .object(["password": .string("pw")])) + } + + func testMaintenanceRunParamsDoNotCarryFrontendConsentFlags() { + let fsck = OperationParams.fsckRun(volume: "Data", noReboot: true, noWait: true, mountWait: 18, password: "") + let repair = OperationParams.repairXattrsRun(path: "/Volumes/Data") + + XCTAssertNil(fsck["confirm_fsck"]) + XCTAssertEqual(fsck["no_reboot"], .bool(true)) + XCTAssertEqual(fsck["mount_wait"], .number(18)) + XCTAssertEqual(fsck["no_wait"], .bool(true)) + XCTAssertEqual(fsck["volume"], .string("Data")) + + XCTAssertEqual(repair["path"], .string("/Volumes/Data")) + XCTAssertEqual(repair["dry_run"], .bool(false)) + XCTAssertNil(repair["confirm_repair"]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift new file mode 100644 index 0000000..de463bb --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift @@ -0,0 +1,190 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ReadinessStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(ReadinessOperationState.allCases, [.idle, .running, .succeeded, .failed]) + } + + func testCapabilitiesSuccessStoresHelperMetadataAndStage() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "capabilities", stage: "summarize_capabilities", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runCapabilities() + + XCTAssertEqual(store.capabilitiesState, .running) + try await waitUntilStoreState { store.capabilitiesState == .succeeded } + XCTAssertEqual(store.currentStage?.stage, "summarize_capabilities") + XCTAssertEqual(store.capabilities?.helperVersion, "1.2.3") + XCTAssertEqual(runner.calls.first?.operation, "capabilities") + } + + func testPathsSuccessStoresArtifactRows() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "paths", ok: true, payload: pathsPayload()) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runPaths() + + try await waitUntilStoreState { store.pathsState == .succeeded } + XCTAssertEqual(store.paths?.artifacts.count, 1) + XCTAssertEqual(store.paths?.artifacts[0].name, "smbd") + XCTAssertEqual(store.paths?.counts["artifacts"], 1) + } + + func testValidationSuccessStoresPassCounts() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runValidateInstall() + + try await waitUntilStoreState { store.validationState == .succeeded } + XCTAssertEqual(store.validation?.counts["pass"], 1) + XCTAssertNil(store.error) + } + + func testValidationFailureStoresPayloadWithoutTransportError() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: false, payload: validationPayload(ok: false)) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runValidateInstall() + + try await waitUntilStoreState { store.validationState == .failed } + XCTAssertEqual(store.validation?.ok, false) + XCTAssertEqual(store.validation?.counts["fail"], 1) + XCTAssertNil(store.error) + } + + func testBackendErrorFailsOnlyMatchingOperationWithRecovery() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "paths", + code: "validation_failed", + message: "missing distribution root", + recovery: recoveryValue(title: "Deployment validation failed", actions: ["Open Readiness."], suggestedOperation: "validate-install") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runPaths() + + try await waitUntilStoreState { store.pathsState == .failed } + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.recovery?.title, "Deployment validation failed") + XCTAssertEqual(store.capabilitiesState, .idle) + XCTAssertEqual(store.validationState, .idle) + } + + func testMalformedPayloadFailsContract() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runCapabilities() + + try await waitUntilStoreState { store.capabilitiesState == .failed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testClearResetsReadinessState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runCapabilities() + try await waitUntilStoreState { store.capabilitiesState == .succeeded } + store.clear() + + XCTAssertEqual(store.capabilitiesState, .idle) + XCTAssertEqual(store.pathsState, .idle) + XCTAssertEqual(store.validationState, .idle) + XCTAssertNil(store.capabilities) + XCTAssertNil(store.paths) + XCTAssertNil(store.validation) + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + } + + private func capabilitiesPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "api_schema_version": .number(1), + "helper_version": .string("1.2.3"), + "helper_version_code": .number(123), + "operations": .array([.string("discover"), .string("configure")]), + "distribution_root": .string("/repo"), + "artifact_manifest_sha256": .string("abc"), + "confirmation_schema_version": .number(1), + "summary": .string("helper capabilities resolved.") + ]) + } + + private func pathsPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "distribution_root": .string("/repo"), + "config_path": .string("/app/.env"), + "state_dir": .string("/app"), + "package_root": .string("/repo/src/timecapsulesmb"), + "artifact_manifest": .string("/repo/src/timecapsulesmb/assets/artifact-manifest.json"), + "artifacts": .array([ + .object([ + "name": .string("smbd"), + "repo_relative_path": .string("bin/samba4/smbd"), + "absolute_path": .string("/repo/bin/samba4/smbd"), + "sha256": .string("hash"), + "ok": .bool(true), + "message": .string("ok") + ]) + ]), + "counts": .object(["artifacts": .number(1)]), + "summary": .string("resolved app paths with 1 artifact path(s).") + ]) + } + + private func validationPayload(ok: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "ok": .bool(ok), + "checks": .array([ + .object([ + "id": .string(ok ? "python_modules" : "artifact_hashes"), + "ok": .bool(ok), + "message": .string(ok ? "required Python modules import" : "artifact validation failed") + ]) + ]), + "counts": .object([ + "checks": .number(1), + "pass": .number(ok ? 1 : 0), + "fail": .number(ok ? 0 : 1) + ]), + "summary": .string(ok ? "install validation passed." : "install validation failed.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift new file mode 100644 index 0000000..cd802bc --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class RecoveryActionMapperTests: XCTestCase { + func testAuthFailureStartsWithReplacePassword() { + let error = BackendErrorViewModel(operation: "doctor", code: "auth_failed", message: "Password rejected.") + + let actions = RecoveryActionMapper.actions(for: error) + + XCTAssertEqual(actions.first, RecoveryAction(title: "Replace Password", kind: .replacePassword)) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Copy Diagnostics", kind: .copyDiagnostics))) + } + + func testSuggestedOperationMapsToUserFacingAction() throws { + let recovery = try recoveryValue( + title: "Disk issue", + actions: ["Wake the disk by opening it in Finder.", "Retry deploy."], + suggestedOperation: "fsck", + actionIDs: ["open_finder", "install_smb"] + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "Disk did not mount.", + recovery: recovery + ) + + let actions = RecoveryActionMapper.actions(for: error) + + XCTAssertTrue(actions.contains(RecoveryAction(title: "Run Disk Repair", kind: .diskRepair))) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Open Finder", kind: .openFinder))) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Install SMB", kind: .installSMB))) + } + + func testHumanRecoveryTextDoesNotCreateActionButtons() throws { + let recovery = try recoveryValue( + title: "Disk issue", + actions: ["Wake the disk by opening it in Finder.", "Retry deploy."], + suggestedOperation: "unknown" + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "Disk did not mount.", + recovery: recovery + ) + + let actions = RecoveryActionMapper.actions(for: error) + + XCTAssertFalse(actions.contains(where: { $0.kind == .openFinder })) + XCTAssertFalse(actions.contains(where: { $0.kind == .installSMB })) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Retry", kind: .retry))) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift new file mode 100644 index 0000000..16dec9c --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -0,0 +1,330 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class StoreTestRunner: HelperRunning, @unchecked Sendable { + struct Call: Equatable, Sendable { + let helperPath: String? + let operation: String + let params: [String: JSONValue] + let context: DeviceRuntimeContext? + } + + struct Response: Sendable { + let events: [BackendEvent] + let result: HelperRunResult + let delayNanoseconds: UInt64 + + init( + events: [BackendEvent], + result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: UInt64 = 0 + ) { + self.events = events + self.result = result + self.delayNanoseconds = delayNanoseconds + } + } + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.StoreTestRunner") + private var storedResponses: [Response] + private var storedCalls: [Call] = [] + + init(responses: [Response]) { + self.storedResponses = responses + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let response = queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params, context: context)) + if storedResponses.isEmpty { + return Response( + events: [BackendEvent.error(operation: operation, code: "missing_test_response", message: "No test response queued.")], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + } + return storedResponses.removeFirst() + } + + if response.delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: response.delayNanoseconds) + } + if Task.isCancelled { + await onEvent(BackendEvent.error(operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"))) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in response.events { + await onEvent(event) + } + return response.result + } +} + +@MainActor +func waitUntilStoreState( + timeoutNanoseconds: UInt64 = 2_000_000_000, + _ condition: @escaping @MainActor () -> Bool +) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !condition() { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + XCTFail("Timed out waiting for store state change.") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } +} + +func recoveryValue( + title: String, + actions: [String], + suggestedOperation: String = "doctor", + actionIDs: [String] = [] +) -> JSONValue { + return .object([ + "title": .string(title), + "message": .string(title), + "actions": .array(actions.map(JSONValue.string)), + "action_ids": .array(actionIDs.map(JSONValue.string)), + "retryable": .bool(true), + "suggested_operation": .string(suggestedOperation) + ]) +} + +func testDeviceRecord( + name: String = "Office Capsule", + hostname: String = "office-capsule.local.", + ipv4: [String] = ["10.0.0.2"], + syap: String = "119", + model: String = "Time Capsule", + fullname: String = "Office Capsule._airport._tcp.local.", + serviceType: String = "_airport._tcp.local.", + services: [String] = ["_airport._tcp.local."] +) -> JSONValue { + .object([ + "name": .string(name), + "hostname": .string(hostname), + "service_type": .string(serviceType), + "port": .number(5009), + "ipv4": .array(ipv4.map(JSONValue.string)), + "ipv6": .array([]), + "services": .array(services.map(JSONValue.string)), + "properties": .object([ + "syAP": .string(syap), + "model": .string(model) + ]), + "fullname": .string(fullname) + ]) +} + +func testDiscoveredDevice( + id: String = "bonjour:office-capsule._airport._tcp.local", + name: String = "Office Capsule", + host: String = "10.0.0.2", + hostname: String = "office-capsule.local.", + addresses: [String]? = nil, + ipv4: [String]? = nil, + ipv6: [String] = [], + preferredIPv4: String? = "10.0.0.2", + linkLocalOnly: Bool = false, + syap: String? = "119", + model: String? = "Time Capsule", + fullname: String = "Office Capsule._airport._tcp.local.", + selectedRecord: JSONValue? = nil +) -> JSONValue { + let resolvedIPv4 = ipv4 ?? [host] + let resolvedAddresses = addresses ?? (resolvedIPv4 + ipv6) + let record = selectedRecord ?? testDeviceRecord( + name: name, + hostname: hostname, + ipv4: resolvedIPv4, + syap: syap ?? "", + model: model ?? "", + fullname: fullname + ) + return .object([ + "id": .string(id), + "name": .string(name), + "host": .string(host), + "ssh_host": preferredIPv4 == nil ? .null : .string("root@\(host)"), + "hostname": .string(hostname), + "addresses": .array(resolvedAddresses.map(JSONValue.string)), + "ipv4": .array(resolvedIPv4.map(JSONValue.string)), + "ipv6": .array(ipv6.map(JSONValue.string)), + "preferred_ipv4": preferredIPv4.map(JSONValue.string) ?? .null, + "link_local_only": .bool(linkLocalOnly), + "syap": syap.map(JSONValue.string) ?? .null, + "model": model.map(JSONValue.string) ?? .null, + "service_type": .string("_airport._tcp.local."), + "fullname": .string(fullname), + "selected_record": record + ]) +} + +func testDiscoverPayload(records: [JSONValue], devices: [JSONValue]? = nil) -> JSONValue { + let deviceValues: [JSONValue] + if let devices { + deviceValues = devices + } else { + deviceValues = records.map { record -> JSONValue in + let name = record.stringValue(for: "name") ?? "Office Capsule" + let hostname = record.stringValue(for: "hostname") ?? "office-capsule.local." + let fullname = record.stringValue(for: "fullname") ?? "\(name)._airport._tcp.local." + let host: String + if case .object(let object) = record, + case .array(let ipv4Values)? = object["ipv4"], + let first = ipv4Values.compactMap({ value -> String? in + guard case .string(let address) = value else { return nil } + return address.hasPrefix("169.254.") ? nil : address + }).first { + host = first + } else { + host = hostname + } + return testDiscoveredDevice( + id: "bonjour:\(fullname.lowercased())", + name: name, + host: host, + hostname: hostname, + fullname: fullname, + selectedRecord: record + ) + } + } + return .object([ + "schema_version": .number(1), + "instances": .array([]), + "resolved": .array(records), + "devices": .array(deviceValues), + "counts": .object([ + "instances": .number(0), + "resolved": .number(Double(records.count)), + "devices": .number(Double(deviceValues.count)) + ]), + "summary": .string("discovered \(deviceValues.count) Time Capsule device(s).") + ]) +} + +func testConfigurePayload( + host: String = "10.0.0.2", + configPath: String = "/tmp/profile/.env", + syap: String = "119", + model: String = "Time Capsule", + payloadFamily: String = "netbsd6_samba4" +) -> JSONValue { + .object([ + "schema_version": .number(1), + "config_path": .string(configPath), + "host": .string(host), + "configure_id": .string("cfg-1"), + "ssh_authenticated": .bool(true), + "device_syap": .string(syap), + "device_model": .string(model), + "compatibility": .object([ + "os_name": .string("NetBSD"), + "os_release": .string("6.0"), + "arch": .string("powerpc"), + "elf_endianness": .string("big"), + "payload_family": .string(payloadFamily), + "device_generation": .string("tc_gen4"), + "supported": .bool(true), + "syap_candidates": .array([.string(syap)]), + "model_candidates": .array([.string(model)]) + ]), + "device": .object([ + "host": .string(host), + "syap": .string(syap), + "model": .string(model) + ]), + "summary": .string("configuration saved and SSH authentication verified.") + ]) +} + +func testConfiguredDevice( + host: String = "10.0.0.2", + configPath: String = "/tmp/profile/.env", + syap: String = "119", + model: String = "Time Capsule", + payloadFamily: String = "netbsd6_samba4" +) throws -> ConfiguredDeviceState { + ConfiguredDeviceState(payload: try testConfigurePayload( + host: host, + configPath: configPath, + syap: syap, + model: model, + payloadFamily: payloadFamily + ).decode(ConfigurePayload.self)) +} + +func testDoctorPayload(fatal: Bool = false, checks: [JSONValue]) -> JSONValue { + let pass = checks.filter { $0.stringValue(for: "status") == "PASS" }.count + let warn = checks.filter { $0.stringValue(for: "status") == "WARN" }.count + let fail = checks.filter { $0.stringValue(for: "status") == "FAIL" }.count + let info = checks.filter { $0.stringValue(for: "status") == "INFO" }.count + return .object([ + "schema_version": .number(1), + "fatal": .bool(fatal), + "results": .array(checks), + "counts": .object([ + "PASS": .number(Double(pass)), + "WARN": .number(Double(warn)), + "FAIL": .number(Double(fail)), + "INFO": .number(Double(info)) + ]), + "error": fatal ? .string("doctor failed") : .null, + "summary": .string(fatal ? "doctor found one or more fatal problems." : "doctor checks passed.") + ]) +} + +func testDoctorCheck(status: String, message: String, domain: String) -> JSONValue { + .object([ + "status": .string(status), + "message": .string(message), + "details": .object(["domain": .string(domain)]) + ]) +} + +func testDeployPlanPayload(payloadFamily: String = "netbsd6_samba4") -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_root": .string("/Volumes/dk2"), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "payload_family": .string(payloadFamily), + "netbsd4": .bool(false), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "uploads": .array([.object(["description": .string("smbd")])]), + "pre_upload_actions": .array([]), + "post_upload_actions": .array([]), + "activation_actions": .array([]), + "post_deploy_checks": .array([]), + "summary": .string("deployment dry-run plan generated.") + ]) +} + +func testDeployResultPayload(payloadFamily: String = "netbsd6_samba4", verified: Bool = true) -> JSONValue { + .object([ + "schema_version": .number(1), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "netbsd4": .bool(false), + "payload_family": .string(payloadFamily), + "requires_reboot": .bool(true), + "rebooted": .bool(true), + "reboot_requested": .bool(true), + "waited": .bool(true), + "verified": .bool(verified), + "message": .string("Install completed."), + "summary": .string("deployment completed.") + ]) +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift new file mode 100644 index 0000000..9e16dae --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift @@ -0,0 +1,10 @@ +import Foundation + +struct TemporaryDirectory { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } +} diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py new file mode 100755 index 0000000..7d7bf74 --- /dev/null +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import plistlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = PACKAGE_ROOT.parents[1] +APP_NAME = "TimeCapsuleSMB" +PRODUCT_NAME = "TimeCapsuleSMB" + + +def run(cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + env=env, + input=input_text, + text=True, + check=True, + stdout=subprocess.PIPE if input_text is not None else None, + stderr=subprocess.PIPE if input_text is not None else None, + ) + + +def build_swift(configuration: str) -> Path: + run(["swift", "build", "-c", configuration, "--product", PRODUCT_NAME], cwd=PACKAGE_ROOT) + executable = PACKAGE_ROOT / ".build" / configuration / PRODUCT_NAME + if not executable.is_file(): + raise RuntimeError(f"Swift build did not produce {executable}") + return executable + + +def copy_resources(configuration: str, resources_dir: Path) -> None: + build_dir = PACKAGE_ROOT / ".build" / configuration + for resource_bundle in build_dir.glob("*.bundle"): + destination = resources_dir / resource_bundle.name + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(resource_bundle, destination) + + +def write_info_plist(contents_dir: Path) -> None: + info = { + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": APP_NAME, + "CFBundleExecutable": PRODUCT_NAME, + "CFBundleIdentifier": "com.timecapsulesmb.TimeCapsuleSMB", + "CFBundleName": APP_NAME, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "0.1.0", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "13.0", + "NSHighResolutionCapable": True, + } + with (contents_dir / "Info.plist").open("wb") as handle: + plistlib.dump(info, handle) + (contents_dir / "PkgInfo").write_text("APPL????", encoding="utf-8") + + +def write_helper_wrapper(helper_path: Path) -> None: + helper_path.write_text( + """#!/bin/sh +set -eu + +CONTENTS_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +RESOURCES_DIR="$CONTENTS_DIR/Resources" +PYTHON="$RESOURCES_DIR/Python/bin/python" + +if [ -z "${TCAPSULE_STATE_DIR:-}" ]; then + export TCAPSULE_STATE_DIR="$HOME/Library/Application Support/TimeCapsuleSMB" +fi +if [ -z "${TCAPSULE_CONFIG:-}" ]; then + export TCAPSULE_CONFIG="$TCAPSULE_STATE_DIR/.env" +fi +if [ -z "${TCAPSULE_DISTRIBUTION_ROOT:-}" ]; then + export TCAPSULE_DISTRIBUTION_ROOT="$RESOURCES_DIR/Distribution" +fi + +mkdir -p "$TCAPSULE_STATE_DIR" +export PATH="$RESOURCES_DIR/Tools/bin:${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}" +export PYTHONNOUSERSITE=1 + +exec "$PYTHON" -m timecapsulesmb.cli.main "$@" +""", + encoding="utf-8", + ) + helper_path.chmod(0o755) + + +def create_python_runtime(python: str, resources_dir: Path) -> None: + runtime = resources_dir / "Python" + if runtime.exists(): + shutil.rmtree(runtime) + run([python, "-m", "venv", str(runtime)]) + runtime_python = runtime / "bin" / "python" + run([str(runtime_python), "-m", "pip", "install", "-U", "pip"]) + generated_build_lib = REPO_ROOT / "build" / "lib" + build_lib_existed = generated_build_lib.exists() + try: + run([str(runtime_python), "-m", "pip", "install", str(REPO_ROOT)]) + finally: + if not build_lib_existed and generated_build_lib.exists(): + shutil.rmtree(generated_build_lib) + + +def copy_distribution(resources_dir: Path) -> None: + distribution = resources_dir / "Distribution" + if distribution.exists(): + shutil.rmtree(distribution) + distribution.mkdir(parents=True) + shutil.copytree(REPO_ROOT / "bin", distribution / "bin") + + +def copy_tool(name: str, tools_bin: Path) -> bool: + source = shutil.which(name) + if not source: + return False + destination = tools_bin / name + shutil.copy2(source, destination) + destination.chmod(0o755) + return True + + +def copy_tools(resources_dir: Path, require_tools: bool) -> None: + tools_bin = resources_dir / "Tools" / "bin" + tools_bin.mkdir(parents=True, exist_ok=True) + missing = [tool for tool in ("sshpass", "smbclient") if not copy_tool(tool, tools_bin)] + if missing and require_tools: + joined = ", ".join(missing) + raise RuntimeError(f"Missing required host tool(s) for bundling: {joined}") + if missing: + print(f"warning: missing optional bundled tool(s): {', '.join(missing)}", file=sys.stderr) + + +def smoke_request(helper: Path, operation: str, state_dir: Path) -> None: + env = os.environ.copy() + env["TCAPSULE_STATE_DIR"] = str(state_dir) + env["TCAPSULE_CONFIG"] = str(state_dir / ".env") + request = json.dumps({"operation": operation, "params": {}}) + completed = run([str(helper), "api"], input_text=request, env=env) + if '"type":"result"' not in completed.stdout and '"type": "result"' not in completed.stdout: + raise RuntimeError(f"{operation} smoke test did not emit a result event:\n{completed.stdout}\n{completed.stderr}") + if '"ok":false' in completed.stdout or '"ok": false' in completed.stdout: + raise RuntimeError(f"{operation} smoke test failed:\n{completed.stdout}\n{completed.stderr}") + + +def smoke_test(app: Path) -> None: + helper = app / "Contents" / "Helpers" / "tcapsule" + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-package-smoke-") as tmp: + state_dir = Path(tmp) + smoke_request(helper, "capabilities", state_dir) + smoke_request(helper, "validate-install", state_dir) + + +def package_app(args: argparse.Namespace) -> Path: + executable = build_swift(args.configuration) + output_dir = args.output.resolve() + app = output_dir / f"{APP_NAME}.app" + contents = app / "Contents" + macos = contents / "MacOS" + helpers = contents / "Helpers" + resources = contents / "Resources" + + if app.exists(): + shutil.rmtree(app) + macos.mkdir(parents=True) + helpers.mkdir() + resources.mkdir() + + write_info_plist(contents) + shutil.copy2(executable, macos / PRODUCT_NAME) + copy_resources(args.configuration, resources) + write_helper_wrapper(helpers / "tcapsule") + create_python_runtime(args.python, resources) + copy_distribution(resources) + copy_tools(resources, args.require_tools) + + if not args.skip_smoke: + smoke_test(app) + return app + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build a self-contained TimeCapsuleSMB.app bundle.") + parser.add_argument("--output", type=Path, default=PACKAGE_ROOT / "dist", help="Directory that will receive TimeCapsuleSMB.app.") + parser.add_argument("--configuration", choices=("debug", "release"), default="release", help="Swift build configuration.") + parser.add_argument("--python", default=sys.executable, help="Python interpreter used to create the bundled runtime.") + parser.add_argument("--require-tools", action="store_true", help="Fail if sshpass or smbclient cannot be copied into the app bundle.") + parser.add_argument("--skip-smoke", action="store_true", help="Skip bundled helper capabilities and validate-install smoke tests.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + try: + app = package_app(parse_args(argv or sys.argv[1:])) + except subprocess.CalledProcessError as exc: + print(f"command failed with exit code {exc.returncode}: {exc.cmd}", file=sys.stderr) + if exc.stdout: + print(exc.stdout, file=sys.stderr) + if exc.stderr: + print(exc.stderr, file=sys.stderr) + return exc.returncode or 1 + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + print(app) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/timecapsulesmb/app/__init__.py b/src/timecapsulesmb/app/__init__.py new file mode 100644 index 0000000..bd0eaf1 --- /dev/null +++ b/src/timecapsulesmb/app/__init__.py @@ -0,0 +1,2 @@ +"""Structured app backend for GUI integrations.""" + diff --git a/src/timecapsulesmb/app/confirmations.py b/src/timecapsulesmb/app/confirmations.py new file mode 100644 index 0000000..26c9f22 --- /dev/null +++ b/src/timecapsulesmb/app/confirmations.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from typing import Mapping + +from timecapsulesmb.services.app import AppOperationError, jsonable + + +CONFIRMATION_SCHEMA_VERSION = 1 +_LEGACY_CONFIRM_KEYS = frozenset({ + "yes", + "confirm", + "confirm_deploy", + "confirm_reboot", + "confirm_netbsd4_activation", + "confirm_uninstall", + "confirm_fsck", + "confirm_repair", +}) +_CONFIRMATION_ONLY_KEYS = frozenset({ + "confirmation_id", + "confirmation", + *_LEGACY_CONFIRM_KEYS, +}) +_SECRET_PARAM_KEYS = frozenset({"password", "credentials"}) + + +@dataclass(frozen=True) +class ConfirmationRequest: + operation: str + title: str + message: str + action_title: str + risk: str + confirmation_id: str + summary: str + context: Mapping[str, object] + + def to_jsonable(self) -> dict[str, object]: + return { + "schema_version": CONFIRMATION_SCHEMA_VERSION, + "operation": self.operation, + "title": self.title, + "message": self.message, + "action_title": self.action_title, + "risk": self.risk, + "confirmation_id": self.confirmation_id, + "summary": self.summary, + "context": jsonable(dict(self.context)), + } + + +class AppConfirmationRequired(AppOperationError): + def __init__(self, confirmation: ConfirmationRequest) -> None: + super().__init__(confirmation.message, code="confirmation_required") + self.confirmation = confirmation + + +def _safe_params(params: Mapping[str, object]) -> dict[str, object]: + return { + str(key): value + for key, value in params.items() + if str(key) not in _CONFIRMATION_ONLY_KEYS and str(key) not in _SECRET_PARAM_KEYS + } + + +def _confirmation_id(operation: str, params: Mapping[str, object], context: Mapping[str, object]) -> str: + canonical = { + "schema_version": CONFIRMATION_SCHEMA_VERSION, + "operation": operation, + "params": jsonable(_safe_params(params)), + "context": jsonable(dict(context)), + } + payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def build_confirmation( + *, + operation: str, + params: Mapping[str, object], + title: str, + message: str, + action_title: str, + risk: str, + summary: str, + context: Mapping[str, object], +) -> ConfirmationRequest: + return ConfirmationRequest( + operation=operation, + title=title, + message=message, + action_title=action_title, + risk=risk, + confirmation_id=_confirmation_id(operation, params, context), + summary=summary, + context=context, + ) + + +def supplied_confirmation_id(params: Mapping[str, object]) -> str: + direct = params.get("confirmation_id") + if isinstance(direct, str): + return direct.strip() + nested = params.get("confirmation") + if isinstance(nested, Mapping): + nested_id = nested.get("id") or nested.get("confirmation_id") + if isinstance(nested_id, str): + return nested_id.strip() + return "" + + +def has_legacy_confirmation(params: Mapping[str, object], *names: str) -> bool: + from timecapsulesmb.services.app import bool_param + + if "yes" in params and bool_param(dict(params), "yes"): + return True + return bool(names) and all(name in params and bool_param(dict(params), name) for name in names) + + +def require_confirmation( + params: Mapping[str, object], + confirmation: ConfirmationRequest, + *, + legacy_names: tuple[str, ...] = (), +) -> None: + if has_legacy_confirmation(params, *legacy_names): + return + if supplied_confirmation_id(params) == confirmation.confirmation_id: + return + raise AppConfirmationRequired(confirmation) diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py new file mode 100644 index 0000000..a2a9781 --- /dev/null +++ b/src/timecapsulesmb/app/contracts.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.doctor import doctor_status_counts + + +SCHEMA_VERSION = 1 + + +def _with_schema(payload: Mapping[str, object]) -> dict[str, object]: + data = dict(payload) + data.setdefault("schema_version", SCHEMA_VERSION) + return data + + +def capabilities_payload( + *, + helper_version: str, + helper_version_code: int, + operations: list[str], + distribution_root: str, + artifact_manifest_sha256: str | None, +) -> dict[str, object]: + return _with_schema({ + "api_schema_version": SCHEMA_VERSION, + "helper_version": helper_version, + "helper_version_code": helper_version_code, + "operations": operations, + "distribution_root": distribution_root, + "artifact_manifest_sha256": artifact_manifest_sha256, + "confirmation_schema_version": 1, + "summary": "helper capabilities resolved.", + }) + + +def _device_payload(*, host: str | None = None, syap: str | None = None, model: str | None = None) -> dict[str, object]: + return { + "host": host, + "syap": syap, + "model": model, + } + + +def discover_payload(raw: Mapping[str, object]) -> dict[str, object]: + instances = list(raw.get("instances", [])) if isinstance(raw.get("instances"), list) else [] + resolved = list(raw.get("resolved", [])) if isinstance(raw.get("resolved"), list) else [] + devices = list(raw.get("devices", [])) if isinstance(raw.get("devices"), list) else [] + return _with_schema({ + **raw, + "counts": { + "instances": len(instances), + "resolved": len(resolved), + "devices": len(devices), + }, + "summary": f"discovered {len(devices)} Time Capsule device(s).", + }) + + +def paths_payload(raw: Mapping[str, object]) -> dict[str, object]: + artifacts = raw.get("artifacts") + artifact_count = len(artifacts) if isinstance(artifacts, list) else 0 + return _with_schema({ + **raw, + "counts": {"artifacts": artifact_count}, + "summary": f"resolved app paths with {artifact_count} artifact path(s).", + }) + + +def install_validation_payload(*, ok: bool, checks: list[object]) -> dict[str, object]: + checks_payload = jsonable(checks) + checks_list = checks_payload if isinstance(checks_payload, list) else [] + pass_count = sum(1 for check in checks_list if isinstance(check, dict) and check.get("ok") is True) + fail_count = sum(1 for check in checks_list if isinstance(check, dict) and check.get("ok") is False) + return _with_schema({ + "ok": ok, + "checks": checks_list, + "counts": { + "checks": len(checks_list), + "pass": pass_count, + "fail": fail_count, + }, + "summary": "install validation passed." if ok else "install validation failed.", + }) + + +def configure_payload( + *, + config_path: str, + host: str, + configure_id: str, + ssh_authenticated: bool, + device_syap: str | None, + device_model: str | None, + compatibility: object | None, +) -> dict[str, object]: + return _with_schema({ + "config_path": config_path, + "host": host, + "configure_id": configure_id, + "ssh_authenticated": ssh_authenticated, + "device_syap": device_syap, + "device_model": device_model, + "compatibility": jsonable(compatibility), + "device": _device_payload(host=host, syap=device_syap, model=device_model), + "summary": "configuration saved and SSH authentication verified.", + }) + + +def deploy_plan_payload(raw: Mapping[str, object], *, payload_family: str | None, netbsd4: bool) -> dict[str, object]: + requires_reboot = bool(raw.get("reboot_required")) + return _with_schema({ + **raw, + "requires_reboot": requires_reboot, + "payload_family": payload_family, + "netbsd4": netbsd4, + "summary": "deployment dry-run plan generated.", + }) + + +def deploy_result_payload( + *, + payload_dir: str, + rebooted: bool | None = None, + reboot_requested: bool | None = None, + waited: bool | None = None, + verified: bool | None = None, + netbsd4: bool = False, + message: str | None = None, + payload_family: str | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "payload_dir": payload_dir, + "netbsd4": netbsd4, + "payload_family": payload_family, + "requires_reboot": False if netbsd4 else bool(rebooted or reboot_requested), + "summary": "deployment completed.", + } + if rebooted is not None: + payload["rebooted"] = rebooted + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested + if waited is not None: + payload["waited"] = waited + if verified is not None: + payload["verified"] = verified + if message is not None: + payload["message"] = message + payload["summary"] = message + return _with_schema(payload) + + +def activation_plan_payload(raw: object) -> dict[str, object]: + payload = jsonable(raw) + if not isinstance(payload, dict): + payload = {"plan": payload} + actions = payload.get("actions") + action_count = len(actions) if isinstance(actions, list) else 0 + return _with_schema({ + **payload, + "counts": {"actions": action_count}, + "summary": "NetBSD4 activation dry-run plan generated.", + }) + + +def activation_result_payload(*, already_active: bool, message: str | None = None) -> dict[str, object]: + payload: dict[str, object] = { + "already_active": already_active, + "summary": "NetBSD4 payload was already active." if already_active else "NetBSD4 activation completed.", + } + if message is not None: + payload["message"] = message + payload["summary"] = message + return _with_schema(payload) + + +def uninstall_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: + requires_reboot = bool(raw.get("reboot_required")) + payload_dirs = raw.get("payload_dirs") + payload_dir_count = len(payload_dirs) if isinstance(payload_dirs, list) else 0 + return _with_schema({ + **raw, + "requires_reboot": requires_reboot, + "counts": {"payload_dirs": payload_dir_count}, + "summary": "uninstall dry-run plan generated.", + }) + + +def uninstall_result_payload( + *, + rebooted: bool, + verified: bool, + reboot_requested: bool | None = None, + waited: bool | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "rebooted": rebooted, + "verified": verified, + "requires_reboot": bool(rebooted or reboot_requested), + "summary": "uninstall completed." if verified else "uninstall completed without post-reboot verification.", + } + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested + if waited is not None: + payload["waited"] = waited + return _with_schema(payload) + + +def fsck_volume_list_payload(raw: Mapping[str, object]) -> dict[str, object]: + targets = raw.get("targets") + target_count = len(targets) if isinstance(targets, list) else 0 + return _with_schema({ + **raw, + "counts": {"targets": target_count}, + "summary": f"found {target_count} mounted HFS volume(s).", + }) + + +def fsck_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: + return _with_schema({ + **raw, + "summary": "fsck dry-run plan generated.", + }) + + +def fsck_result_payload( + *, + device: str, + mountpoint: str, + returncode: int | None = None, + reboot_requested: bool | None = None, + waited: bool | None = None, + verified: bool | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "device": device, + "mountpoint": mountpoint, + "summary": "fsck completed.", + } + if returncode is not None: + payload["returncode"] = returncode + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested + if waited is not None: + payload["waited"] = waited + if verified is not None: + payload["verified"] = verified + return _with_schema(payload) + + +def repair_xattrs_payload(raw: Mapping[str, object]) -> dict[str, object]: + finding_count = int(raw.get("finding_count") or 0) + repairable_count = int(raw.get("repairable_count") or 0) + legacy_summary = raw.get("summary") + stats = raw.get("stats", legacy_summary if not isinstance(legacy_summary, str) else None) + summary = legacy_summary if isinstance(legacy_summary, str) and legacy_summary.strip() else ( + f"repair-xattrs found {finding_count} issue(s), {repairable_count} repairable." + ) + payload = { + **raw, + "counts": { + "findings": finding_count, + "repairable": repairable_count, + }, + "summary": summary, + "summary_text": summary, + } + if stats is not None: + payload["stats"] = jsonable(stats) + return _with_schema(payload) + + +def doctor_payload( + *, + fatal: bool, + results: list[CheckResult], + error: str | None = None, +) -> dict[str, object]: + result_payload = [jsonable(result) for result in results] + counts = doctor_status_counts(results) + payload: dict[str, object] = { + "fatal": fatal, + "results": result_payload, + "counts": counts, + "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", + } + if error: + payload["error"] = error + return _with_schema(payload) diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py new file mode 100644 index 0000000..9abdb1e --- /dev/null +++ b/src/timecapsulesmb/app/events.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from timecapsulesmb.app.stage_policy import stage_policy + + +SENSITIVE_KEY_PARTS = ("password", "secret", "token", "key") +REDACTED = "" + + +def redact(value: object) -> object: + if isinstance(value, dict): + redacted: dict[str, object] = {} + for key, item in value.items(): + if any(part in str(key).lower() for part in SENSITIVE_KEY_PARTS): + redacted[str(key)] = REDACTED + else: + redacted[str(key)] = redact(item) + return redacted + if isinstance(value, (list, tuple, set)): + return [redact(item) for item in value] + if isinstance(value, Path): + return str(value) + return value + + +@dataclass(frozen=True) +class AppEvent: + type: str + operation: str + fields: dict[str, object] = field(default_factory=dict) + request_id: str | None = None + schema_version: int = 1 + + def to_jsonable(self) -> dict[str, object]: + data = {"schema_version": self.schema_version, "type": self.type, "operation": self.operation} + if self.request_id: + data["request_id"] = self.request_id + data.update(redact(self.fields)) + return data + + def to_json_line(self) -> str: + return json.dumps(self.to_jsonable(), sort_keys=True) + "\n" + + +class EventSink: + def __init__( + self, + emit: Callable[[AppEvent], None], + *, + request_id: str | None = None, + schema_version: int = 1, + ) -> None: + self._emit = emit + self.request_id = request_id or str(uuid.uuid4()) + self.schema_version = schema_version + self._current_stage_by_operation: dict[str, str] = {} + + def with_request_id(self, request_id: str) -> "EventSink": + return EventSink(self._emit, request_id=request_id, schema_version=self.schema_version) + + def emit(self, event: AppEvent) -> None: + if event.request_id is None: + event = AppEvent( + event.type, + event.operation, + event.fields, + request_id=self.request_id, + schema_version=self.schema_version, + ) + self._emit(event) + + def current_stage(self, operation: str) -> str | None: + return self._current_stage_by_operation.get(operation) + + def stage(self, operation: str, stage: str) -> None: + self._current_stage_by_operation[operation] = stage + fields: dict[str, object] = {"stage": stage} + policy = stage_policy(operation, stage) + if policy is not None: + fields.update(policy.to_jsonable()) + self.emit(AppEvent("stage", operation, fields)) + + def log(self, operation: str, message: str, *, level: str = "info") -> None: + self.emit(AppEvent("log", operation, {"level": level, "message": message})) + + def check( + self, + operation: str, + *, + status: str, + message: str, + details: dict[str, object] | None = None, + ) -> None: + self.emit(AppEvent("check", operation, { + "status": status, + "message": message, + "details": details or {}, + })) + + def result(self, operation: str, *, ok: bool, payload: object | None = None) -> None: + self.emit(AppEvent("result", operation, {"ok": ok, "payload": payload if payload is not None else {}})) + + def error( + self, + operation: str, + message: str, + *, + code: str = "operation_failed", + details: object | None = None, + debug: object | None = None, + recovery: object | None = None, + ) -> None: + fields: dict[str, object] = {"code": code, "message": message} + if details is not None: + fields["details"] = details + if debug is not None: + fields["debug"] = debug + if recovery is not None: + fields["recovery"] = recovery + self.emit(AppEvent("error", operation, fields)) diff --git a/src/timecapsulesmb/app/helper.py b/src/timecapsulesmb/app/helper.py new file mode 100644 index 0000000..15178b9 --- /dev/null +++ b/src/timecapsulesmb/app/helper.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import json +import sys +import uuid +from typing import Optional, TextIO + +from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app.recovery import recovery_for +from timecapsulesmb.app.service import run_api_request + + +MAX_REQUEST_CHARS = 1024 * 1024 + + +def _sink_for_stream(stream: TextIO) -> EventSink: + def emit(event: AppEvent) -> None: + stream.write(event.to_json_line()) + stream.flush() + + return EventSink(emit) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Run one structured TimeCapsuleSMB app backend request.") + parser.add_argument( + "--pretty-error", + action="store_true", + help="Also write request parsing errors to stderr for local debugging.", + ) + args = parser.parse_args(argv) + sink = _sink_for_stream(sys.stdout).with_request_id(str(uuid.uuid4())) + + raw = sys.stdin.read(MAX_REQUEST_CHARS + 1) + if len(raw) > MAX_REQUEST_CHARS: + sink.error( + "api", + f"request exceeds maximum size of {MAX_REQUEST_CHARS} characters", + code="invalid_request", + recovery=recovery_for("api", "invalid_request"), + ) + if args.pretty_error: + print("request too large", file=sys.stderr) + return 1 + try: + request = json.loads(raw) + except json.JSONDecodeError as exc: + message = f"invalid JSON request: {exc.msg}" + sink.error( + "api", + message, + code="invalid_request", + debug={"pos": exc.pos}, + recovery=recovery_for("api", "invalid_request"), + ) + if args.pretty_error: + print("invalid JSON request", file=sys.stderr) + return 1 + if not isinstance(request, dict): + sink.error( + "api", + "request must be a JSON object", + code="invalid_request", + recovery=recovery_for("api", "invalid_request"), + ) + return 1 + return run_api_request(request, sink) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py new file mode 100644 index 0000000..8a961c4 --- /dev/null +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from collections.abc import Callable + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.configure import configure_operation +from timecapsulesmb.app.ops.deploy import deploy_operation +from timecapsulesmb.app.ops.doctor import doctor_operation +from timecapsulesmb.app.ops.maintenance import ( + activate_operation, + fsck_operation, + repair_xattrs_operation, + uninstall_operation, +) +from timecapsulesmb.app.ops.readiness import ( + capabilities_operation, + discover_operation, + paths_operation, + validate_install_operation, +) +from timecapsulesmb.services.app import OperationResult + + +OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { + "activate": activate_operation, + "capabilities": capabilities_operation, + "configure": configure_operation, + "deploy": deploy_operation, + "discover": discover_operation, + "doctor": doctor_operation, + "fsck": fsck_operation, + "paths": paths_operation, + "repair-xattrs": repair_xattrs_operation, + "uninstall": uninstall_operation, + "validate-install": validate_install_operation, +} diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py new file mode 100644 index 0000000..d7e999c --- /dev/null +++ b/src/timecapsulesmb/app/ops/configure.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import uuid + +from timecapsulesmb.app.contracts import configure_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.readiness import selected_record_host, selected_record_properties +from timecapsulesmb.core.config import ( + DEFAULTS, + parse_bool, + parse_env_file, +) +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.device.compat import render_compatibility_message +from timecapsulesmb.device.probe import probe_connection_state +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, + jsonable, + require_string_param, + string_param, +) +from timecapsulesmb.services.config_store import EnvFileConfigStore +from timecapsulesmb.services.configure import build_configure_env_values +from timecapsulesmb.services.runtime import ssh_target_link_local_resolution_error, wait_for_tcp_port_state +from timecapsulesmb.transport.ssh import SshConnection + + +def configure_ssh_target(value: str) -> str: + host = value.strip() + if not host or "@" in host: + return host + return f"root@{host}" + + +def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "configure" + sink.stage(operation, "load_existing_config") + app_paths = resolve_app_paths(config_path=config_path(params)) + env_path = app_paths.config_path + existing = parse_env_file(env_path) + configure_id = str(uuid.uuid4()) + ssh_opts = string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + host = configure_ssh_target(string_param(params, "host") or selected_record_host(params) or existing.get("TC_HOST", "")) + password = require_string_param(params, "password") + if not host: + raise AppOperationError("missing required parameter: host", code="validation_failed") + + resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) + if resolution_error is not None: + raise AppOperationError(resolution_error, code="config_error") + + values = build_configure_env_values( + existing, + host=host, + password=password, + ssh_opts=ssh_opts, + configure_id=configure_id, + internal_share_use_disk_root=bool_param( + params, + "internal_share_use_disk_root", + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ), + any_protocol=bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ), + debug_logging=bool_param( + params, + "debug_logging", + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])), + ), + ) + + sink.stage(operation, "ssh_probe") + connection = SshConnection(host, password, ssh_opts) + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not bool_param(params, "enable_ssh", True): + raise AppOperationError("SSH is not reachable and enable_ssh is false.", code="remote_error") + sink.stage(operation, "acp_enable_ssh") + try: + enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc + except ACPError as exc: + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc + + sink.stage(operation, "wait_for_ssh_after_acp") + if not wait_for_ssh_port(host, timeout_seconds=int_param(params, "ssh_wait_timeout", 180)): + raise AppOperationError("SSH did not open after enabling via ACP.", code="remote_error") + sink.stage(operation, "ssh_probe_after_acp") + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_authenticated: + raise AppOperationError( + probe.error or "The provided AirPort SSH target and password did not work.", + code="auth_failed", + ) + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + + selected_props = selected_record_properties(params) + observed_syap = None if compatibility is None else compatibility.exact_syap + observed_model = None if compatibility is None else compatibility.exact_model + if observed_syap is None: + observed_syap = selected_props.get("syAP") or None + + sink.stage(operation, "write_env") + env_path.parent.mkdir(parents=True, exist_ok=True) + omit_keys = frozenset() if bool_param(params, "persist_password") else frozenset({"TC_PASSWORD"}) + EnvFileConfigStore(omit_keys=omit_keys).save(env_path, values) + return OperationResult(True, configure_payload( + config_path=str(env_path), + host=host, + configure_id=configure_id, + ssh_authenticated=True, + device_syap=observed_syap, + device_model=observed_model, + compatibility=jsonable(compatibility) if compatibility is not None else None, + )) + + +def wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + service_name="SSH port", + ) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py new file mode 100644 index 0000000..4b734f0 --- /dev/null +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +from contextlib import ExitStack +from pathlib import Path +import tempfile + +from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, airport_family_display_name_from_identity +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts +from timecapsulesmb.deploy.artifacts import validate_artifacts +from timecapsulesmb.deploy.auth import render_smbpasswd +from timecapsulesmb.deploy.boot_assets import boot_asset_path +from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable +from timecapsulesmb.deploy.executor import ( + flush_remote_filesystem_writes, + remote_request_reboot, + run_remote_actions, + upload_deployment_payload, +) +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_START_SAMBA_SOURCE, + PACKAGED_WATCHDOG_SOURCE, + build_deployment_plan, +) +from timecapsulesmb.deploy.verify import ( + managed_runtime_ready, + render_managed_runtime_verification, + verify_managed_runtime, +) +from timecapsulesmb.device.compat import ( + DeviceCompatibility, + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, + require_compatibility, +) +from timecapsulesmb.device.probe import wait_for_ssh_state_conn +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + build_dry_run_payload_home, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.integrations.acp import ACPError, reboot as acp_reboot +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, +) +from timecapsulesmb.services.credentials import overlay_request_credentials +from timecapsulesmb.services.deploy import ( + DEPLOY_REBOOT_NO_DOWN_MESSAGE, + DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, + no_mast_volumes_message, + no_writable_mast_volumes_message, + payload_verification_error, + render_flash_runtime_config, +) +from timecapsulesmb.services.runtime import ManagedTargetState, load_env_config, resolve_validated_managed_target +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError + + +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 + + +def require_supported_payload(target: ManagedTargetState, *, allow_unsupported: bool) -> DeviceCompatibility: + probe_state = target.probe_state + if probe_state is None: + raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") + compatibility = require_compatibility( + probe_state.compatibility, + fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + if not compatibility.supported and not allow_unsupported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + if not compatibility.payload_family: + raise AppOperationError("No deployable payload is available for this detected device.", code="unsupported_device") + return compatibility + + +def load_config_and_target( + operation: str, + params: dict[str, object], + sink: EventSink, + *, + profile: str, + include_probe: bool, +) -> tuple[AppConfig, ManagedTargetState]: + sink.stage(operation, "load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile=profile, + include_probe=include_probe, + ) + return config, target + + +def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "deploy" + nbns_enabled = bool_param(params, "nbns_enabled", True) + dry_run = bool_param(params, "dry_run") + no_reboot = bool_param(params, "no_reboot") + no_wait = bool_param(params, "no_wait") + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + allow_unsupported = bool_param(params, "allow_unsupported") + debug_logging = bool_param(params, "debug_logging") + + config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=config_path(params)) + + sink.stage(operation, "validate_artifacts") + failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] + if failures: + raise AppOperationError("; ".join(failures), code="validation_failed") + + sink.stage(operation, "check_compatibility") + compatibility = require_supported_payload(target, allow_unsupported=allow_unsupported) + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) + if not dry_run: + confirmation_plan = build_deployment_plan( + connection.host, + build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME), + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + device_name = airport_family_display_name_from_identity( + model=target.probe_state.probe_result.airport_model if target.probe_state else None, + syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, + ) + if is_netbsd4: + title = "Confirm NetBSD4 deployment" + message = f"Deploy and activate the NetBSD4 payload on this {device_name}. Remote services will be changed." + action_title = "Deploy and activate" + risk = "destructive" + summary = "NetBSD4 deployment with service activation" + elif no_reboot: + title = "Confirm deployment" + message = f"Deploy TimeCapsuleSMB to this {device_name} without rebooting it." + action_title = "Deploy" + risk = "remote_write" + summary = "Deployment without reboot" + else: + title = "Confirm deployment and reboot" + message = f"Deploy TimeCapsuleSMB and reboot this {device_name}." + action_title = "Deploy and reboot" + risk = "reboot" + summary = "Deployment with reboot request" + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title=title, + message=message, + action_title=action_title, + risk=risk, + summary=summary, + context={ + "host": connection.host, + "payload_family": payload_family, + "netbsd4": is_netbsd4, + "requires_reboot": bool(confirmation_plan.reboot_required), + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=( + ("confirm_deploy", "confirm_netbsd4_activation") + if is_netbsd4 + else ("confirm_deploy",) if no_reboot else ("confirm_deploy", "confirm_reboot") + ), + ) + if dry_run: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + else: + sink.stage(operation, "read_mast") + mast_discovery = wait_for_mast_volumes_conn( + connection, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + if not mast_discovery.volumes: + raise AppOperationError( + no_mast_volumes_message( + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ), + code="remote_error", + ) + sink.stage(operation, "select_payload_home") + selection = select_payload_home_with_diagnostics_conn( + connection, + mast_discovery.volumes, + MANAGED_PAYLOAD_DIR_NAME, + wait_seconds=mount_wait, + ) + if selection.payload_home is None: + raise AppOperationError( + no_writable_mast_volumes_message(len(mast_discovery.volumes)), + code="remote_error", + ) + payload_home = selection.payload_home + + sink.stage(operation, "build_deployment_plan") + plan = build_deployment_plan( + connection.host, + payload_home, + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + if dry_run: + return OperationResult(True, deploy_plan_payload( + deployment_plan_to_jsonable(plan), + payload_family=payload_family, + netbsd4=is_netbsd4, + )) + + sink.stage(operation, "pre_upload_actions") + run_remote_actions(connection, plan.pre_upload_actions) + sink.stage(operation, "prepare_deployment_files") + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + tmpdir = Path(tmp) + generated_flash_config = tmpdir / "tcapsulesmb.conf" + generated_smbpasswd = tmpdir / "smbpasswd" + generated_username_map = tmpdir / "username.map" + generated_flash_config.write_text(flash_config_text) + smbpasswd_text, username_map_text = render_smbpasswd(connection.password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + upload_sources = { + BINARY_SMBD_SOURCE: plan.smbd_path, + BINARY_MDNS_SOURCE: plan.mdns_path, + BINARY_NBNS_SOURCE: plan.nbns_path, + GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, + GENERATED_USERNAME_MAP_SOURCE: generated_username_map, + GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, + PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), + PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), + PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), + } + sink.stage(operation, "upload_payload") + upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) + + sink.stage(operation, "post_upload_actions") + run_remote_actions(connection, plan.post_upload_actions) + verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) + sink.stage(operation, "flush_payload_upload") + sink.log(operation, "Flushing deployed payload to disk...") + flush_remote_filesystem_writes(connection) + verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) + + if is_netbsd4: + sink.stage(operation, "netbsd4_activation") + run_remote_actions(connection, plan.activation_actions) + verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + netbsd4=True, + reboot_requested=False, + waited=False, + verified=True, + message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + payload_family=payload_family, + )) + + if no_reboot: + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + rebooted=False, + reboot_requested=False, + waited=False, + verified=False, + payload_family=payload_family, + )) + + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + raise_on_request_error=True, + ) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + reboot_requested=True, + waited=False, + verified=False, + payload_family=payload_family, + )) + + request_reboot_and_wait( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + ) + verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + rebooted=True, + reboot_requested=True, + waited=True, + verified=True, + payload_family=payload_family, + )) + + +def verify_payload_upload( + operation: str, + sink: EventSink, + connection: SshConnection, + payload_home, + *, + wait_seconds: int, + post_sync: bool = False, +) -> None: + sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) + sink.log(operation, verification.detail) + if not verification.ok: + raise AppOperationError(payload_verification_error(payload_home, verification), code="remote_error") + + +def verify_runtime( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, +) -> None: + sink.stage(operation, stage) + verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification( + verification, + heading="Waiting for managed runtime to finish starting...", + ): + sink.log(operation, line) + if not managed_runtime_ready(verification): + raise AppOperationError( + f"Managed runtime did not become ready. {verification.detail.strip()}".strip(), + code="remote_error", + ) + + +def request_reboot_and_wait( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + reboot_no_down_message: str, + down_timeout_seconds: int = 60, + up_timeout_seconds: int = 240, +) -> None: + request_reboot(operation, sink, connection, strategy=strategy) + + sink.stage(operation, "wait_for_reboot_down") + sink.log(operation, "Waiting for the device to go down...") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + sink.log(operation, "Waiting for the device to come back up...") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + sink.log(operation, "Device is back online.") + + +def request_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + raise_on_request_error: bool = False, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + request_ssh_reboot( + operation, + sink, + connection, + raise_on_request_error=raise_on_request_error, + ) + else: + request_ssh_reboot( + operation, + sink, + connection, + raise_on_request_error=raise_on_request_error, + ) + + +def request_ssh_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + raise_on_request_error: bool = False, +) -> None: + try: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + if raise_on_request_error: + raise AppOperationError(f"SSH reboot request timed out: {exc}", code="remote_error") from exc + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + if raise_on_request_error: + raise AppOperationError(f"SSH reboot request failed: {exc}", code="remote_error") from exc + sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + return + sink.log(operation, "SSH reboot requested.") diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py new file mode 100644 index 0000000..7bc12d8 --- /dev/null +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from timecapsulesmb.app.contracts import doctor_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC +from timecapsulesmb.services.app import OperationResult, bool_param, config_path, float_param +from timecapsulesmb.services.credentials import overlay_request_credentials +from timecapsulesmb.services.doctor import build_doctor_error +from timecapsulesmb.services.runtime import load_env_config, resolve_env_connection + + +def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "doctor" + bonjour_timeout = float_param(params, "bonjour_timeout", DEFAULT_BROWSE_TIMEOUT_SEC) + sink.stage(operation, "load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + app_paths = resolve_app_paths(config_path=config_path(params)) + connection = None + if not bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + sink.check(operation, status=result.status, message=result.message, details=result.details) + + sink.stage(operation, "run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=bool_param(params, "skip_ssh"), + skip_bonjour=bool_param(params, "skip_bonjour"), + skip_smb=bool_param(params, "skip_smb"), + bonjour_timeout=bonjour_timeout, + on_result=on_result, + debug_fields=debug_fields, + ) + error = build_doctor_error(results, debug_fields) if fatal else None + return OperationResult(not fatal, doctor_payload(fatal=fatal, results=results, error=error)) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py new file mode 100644 index 0000000..5c1d6e5 --- /dev/null +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import argparse +import shlex +import sys +from contextlib import redirect_stderr, redirect_stdout + +from timecapsulesmb.app.contracts import ( + activation_plan_payload, + activation_result_payload, + fsck_plan_payload, + fsck_result_payload, + fsck_volume_list_payload, + repair_xattrs_payload, + uninstall_plan_payload, + uninstall_result_payload, +) +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.deploy import ( + request_reboot, + request_reboot_and_wait, + require_supported_payload, + verify_runtime, +) +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.deploy.dry_run import activation_plan_to_jsonable, uninstall_plan_to_jsonable +from timecapsulesmb.deploy.executor import remote_uninstall_payload, run_remote_actions +from timecapsulesmb.deploy.planner import ( + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + build_netbsd4_activation_plan, + build_uninstall_plan, +) +from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall +from timecapsulesmb.device.compat import is_netbsd4_payload_family +from timecapsulesmb.device.probe import probe_managed_runtime_conn, wait_for_ssh_state_conn +from timecapsulesmb.device.storage import ( + UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, + mounted_mast_volumes_conn, + read_mast_volumes_conn, +) +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, + jsonable, + optional_int_param, + required_path_param, + string_param, +) +from timecapsulesmb.services.credentials import overlay_request_credentials +from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE +from timecapsulesmb.services.maintenance import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + FSCK_REBOOT_NO_DOWN_MESSAGE, + UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + LineLogCapture, + RepairExecutionContext, + build_remote_fsck_script, + format_fsck_plan, + format_fsck_targets, + fsck_plan_to_jsonable, + fsck_target_from_volume, + fsck_target_to_jsonable, + select_fsck_target, +) +from timecapsulesmb.services import repair_xattrs as repair_xattrs_service +from timecapsulesmb.services.runtime import ( + load_env_config, + load_optional_env_config, + resolve_env_connection, + resolve_validated_managed_target, +) +from timecapsulesmb.transport.ssh import SshConnection, run_ssh + + +def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "activate" + dry_run = bool_param(params, "dry_run") + sink.stage(operation, "build_activation_plan") + plan = build_netbsd4_activation_plan() + if dry_run: + return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) + + sink.stage(operation, "load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + confirmation_connection = resolve_env_connection(config, allow_empty_password=True) + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm NetBSD4 activation", + message="Activate the deployed NetBSD4 payload and restart managed services.", + action_title="Activate", + risk="destructive", + summary="NetBSD4 service activation", + context={ + "host": confirmation_connection.host, + "netbsd4": True, + }, + ), + legacy_names=("confirm_netbsd4_activation",), + ) + + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile="activate", + include_probe=True, + ) + compatibility = require_supported_payload(target, allow_unsupported=False) + if not is_netbsd4_payload_family(compatibility.payload_family): + raise AppOperationError( + "activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", + code="unsupported_device", + ) + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, activation_result_payload(already_active=True)) + + sink.stage(operation, "run_activation") + run_remote_actions(connection, plan.actions) + verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, activation_result_payload( + already_active=False, + message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + )) + + +def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "uninstall" + dry_run = bool_param(params, "dry_run") + no_reboot = bool_param(params, "no_reboot") + no_wait = bool_param(params, "no_wait") + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + sink.stage(operation, "load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + if not dry_run: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm uninstall", + message=( + "Remove managed TimeCapsuleSMB files from the device" + + (" and reboot it." if not no_reboot else ".") + ), + action_title="Uninstall", + risk="destructive" if not no_reboot else "remote_write", + summary="Uninstall managed payload" + (" with reboot" if not no_reboot else " without reboot"), + context={ + "host": connection.host, + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=("confirm_uninstall",) if no_reboot else ("confirm_uninstall", "confirm_reboot"), + ) + if dry_run: + volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] + payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] + else: + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_mast_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=mount_wait, + ) + volume_roots = [volume.volume_root for volume in mounted_volumes] + payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] + sink.stage(operation, "build_uninstall_plan") + plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) + if dry_run: + return OperationResult(True, uninstall_plan_payload(uninstall_plan_to_jsonable(plan))) + sink.stage(operation, "uninstall_payload") + remote_uninstall_payload(connection, plan) + if no_reboot: + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=False, + waited=False, + )) + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="acp_then_ssh", + raise_on_request_error=True, + ) + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=True, + waited=False, + )) + request_reboot_and_wait( + operation, + sink, + connection, + strategy="acp_then_ssh", + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + ) + sink.stage(operation, "verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + sink.log(operation, line) + if not verification: + raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") + return OperationResult(True, uninstall_result_payload( + rebooted=True, + verified=True, + reboot_requested=True, + waited=True, + )) + + +def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "fsck" + dry_run = bool_param(params, "dry_run") + list_volumes = bool_param(params, "list_volumes") + no_reboot = bool_param(params, "no_reboot") + no_wait = bool_param(params, "no_wait") + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + if dry_run and list_volumes: + raise AppOperationError("dry_run and list_volumes are mutually exclusive.", code="validation_failed") + if not dry_run and not list_volumes: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm fsck", + message="Run fsck on the selected HFS volume" + (" and reboot the device." if not no_reboot else "."), + action_title="Run fsck", + risk="destructive" if not no_reboot else "remote_write", + summary="Filesystem check and repair", + context={ + "volume": string_param(params, "volume"), + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=("confirm_fsck",), + ) + sink.stage(operation, "load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=mount_wait, + ) + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) + if list_volumes: + sink.stage(operation, "list_fsck_volumes") + sink.log(operation, format_fsck_targets(targets)) + return OperationResult(True, fsck_volume_list_payload({ + "targets": [fsck_target_to_jsonable(target) for target in targets], + })) + + sink.stage(operation, "select_fsck_volume") + try: + target = select_fsck_target( + targets, + string_param(params, "volume") or None, + prompt=False, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + if dry_run: + sink.log(operation, format_fsck_plan(target, reboot=not no_reboot, wait=not no_wait)) + return OperationResult(True, fsck_plan_payload(fsck_plan_to_jsonable( + target, + reboot=not no_reboot, + wait=not no_wait, + ))) + + sink.stage(operation, "run_fsck") + script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + ) + if proc.stdout: + for line in proc.stdout.splitlines(): + sink.log(operation, line) + if no_reboot: + return OperationResult(proc.returncode == 0, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + returncode=proc.returncode, + reboot_requested=False, + waited=False, + verified=False, + )) + if no_wait: + return OperationResult(True, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + reboot_requested=True, + waited=False, + verified=False, + )) + observe_reboot_cycle( + operation, + sink, + connection, + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + return OperationResult(True, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + reboot_requested=True, + waited=True, + verified=True, + )) + + +def observe_reboot_cycle( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + reboot_no_down_message: str, + down_timeout_seconds: int, + up_timeout_seconds: int, +) -> None: + sink.stage(operation, "wait_for_reboot_down") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + sink.stage(operation, "validate_params") + dry_run = bool_param(params, "dry_run") + path = required_path_param(params, "path") + recursive = bool_param(params, "recursive", True) + max_depth = optional_int_param(params, "max_depth") + include_hidden = bool_param(params, "include_hidden") + include_time_machine = bool_param(params, "include_time_machine") + fix_permissions = bool_param(params, "fix_permissions") + verbose = bool_param(params, "verbose") + if not dry_run: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm xattr repair", + message=f"Repair known-safe macOS metadata issues under {path}.", + action_title="Repair xattrs", + risk="local_write", + summary="Repair local mounted-share metadata", + context={"path": str(path)}, + ), + legacy_names=("confirm_repair",), + ) + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + raise AppOperationError( + "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", + code="validation_failed", + ) + config = load_optional_env_config(env_path=config_path(params)) + args = argparse.Namespace( + path=path, + dry_run=dry_run, + yes=not dry_run, + recursive=recursive, + max_depth=max_depth, + include_hidden=include_hidden, + include_time_machine=include_time_machine, + fix_permissions=fix_permissions, + verbose=verbose, + ) + context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) + stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) + stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = repair_xattrs_service.run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + raise AppOperationError(message, code="operation_failed") from exc + finally: + stdout_capture.flush() + stderr_capture.flush() + return OperationResult(result.returncode == 0, repair_xattrs_payload({ + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "stats": jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error, + })) diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py new file mode 100644 index 0000000..caa7a72 --- /dev/null +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import hashlib + +from timecapsulesmb.app.contracts import capabilities_payload, discover_payload, install_validation_payload, paths_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.core.paths import artifact_manifest_resource, resolve_app_paths +from timecapsulesmb.core.release import CLI_VERSION, CLI_VERSION_CODE +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot, + discovered_record_root_host, + discovery_record_to_jsonable, + service_instance_to_jsonable, +) +from timecapsulesmb.discovery.devices import device_candidate_to_jsonable, device_candidates_from_records +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + paths_to_jsonable, + validate_install, +) +from timecapsulesmb.services.app import ( + OperationResult, + config_path, + float_param, +) + + +def selected_record_properties(params: dict[str, object]) -> dict[str, str]: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return {} + properties = selected.get("properties") + if not isinstance(properties, dict): + return {} + return {str(key): str(value) for key, value in properties.items()} + + +def selected_record_host(params: dict[str, object]) -> str: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return "" + record = BonjourResolvedService( + name=str(selected.get("name") or ""), + hostname=str(selected.get("hostname") or ""), + service_type=str(selected.get("service_type") or ""), + port=int(selected.get("port") or 0), + ipv4=tuple(str(ip) for ip in selected.get("ipv4", ()) if ip), + ipv6=tuple(str(ip) for ip in selected.get("ipv6", ()) if ip), + properties=selected_record_properties(params), + fullname=str(selected.get("fullname") or ""), + ) + return discovered_record_root_host(record) or "" + + +def snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: + devices = device_candidates_from_records(snapshot.resolved) + return { + "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], + "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], + "devices": [device_candidate_to_jsonable(device) for device in devices], + } + + +def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "discover" + timeout = float_param(params, "timeout", DEFAULT_BROWSE_TIMEOUT_SEC) + sink.stage(operation, "bonjour_discovery") + snapshot = discover_snapshot(timeout=timeout) + return OperationResult(True, discover_payload(snapshot_payload(snapshot))) + + +def capabilities_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "capabilities" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "summarize_capabilities") + try: + manifest_hash = hashlib.sha256(artifact_manifest_resource().read_bytes()).hexdigest() + except OSError: + manifest_hash = None + return OperationResult(True, capabilities_payload( + helper_version=CLI_VERSION, + helper_version_code=CLI_VERSION_CODE, + operations=[ + "activate", + "capabilities", + "configure", + "deploy", + "discover", + "doctor", + "fsck", + "paths", + "repair-xattrs", + "uninstall", + "validate-install", + ], + distribution_root=str(app_paths.distribution_root), + artifact_manifest_sha256=manifest_hash, + )) + + +def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "paths" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "summarize_artifacts") + return OperationResult(True, paths_payload(paths_to_jsonable(app_paths))) + + +def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "validate-install" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + sink.check( + operation, + status="PASS" if check.ok else "FAIL", + message=check.message, + details=check.details, + ) + return OperationResult(ok, install_validation_payload(ok=ok, checks=install_checks_to_jsonable(checks))) diff --git a/src/timecapsulesmb/app/recovery.py b/src/timecapsulesmb/app/recovery.py new file mode 100644 index 0000000..481f1f5 --- /dev/null +++ b/src/timecapsulesmb/app/recovery.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RecoveryInfo: + title: str + message: str + actions: tuple[str, ...] + retryable: bool + suggested_operation: str | None = None + action_ids: tuple[str, ...] = () + docs_anchor: str | None = None + + def to_jsonable(self) -> dict[str, object]: + payload: dict[str, object] = { + "title": self.title, + "message": self.message, + "actions": list(self.actions), + "action_ids": list(self.action_ids), + "retryable": self.retryable, + "suggested_operation": self.suggested_operation, + } + if self.docs_anchor: + payload["docs_anchor"] = self.docs_anchor + return payload + + +_DEFAULTS: dict[str, RecoveryInfo] = { + "invalid_request": RecoveryInfo( + "Invalid request", + "The helper request was malformed or had invalid parameter types.", + ("Check the request JSON shape.", "Send params as a JSON object."), + retryable=True, + ), + "unknown_operation": RecoveryInfo( + "Unknown operation", + "The helper does not recognize the requested operation.", + ("Use one of the helper operations exposed by this app version.",), + retryable=False, + ), + "validation_failed": RecoveryInfo( + "Request validation failed", + "One or more operation parameters were missing or invalid.", + ("Review the highlighted fields.", "Retry with valid values."), + retryable=True, + ), + "config_error": RecoveryInfo( + "Configuration error", + "The current .env configuration could not be read or used.", + ("Open the configuration step.", "Verify host, password, and SSH options."), + retryable=True, + suggested_operation="configure", + action_ids=("replace_password",), + ), + "auth_failed": RecoveryInfo( + "Authentication failed", + "The Time Capsule rejected the supplied password or SSH credentials.", + ("Re-enter the AirPort admin password.", "Verify that SSH is enabled on the device."), + retryable=True, + suggested_operation="configure", + action_ids=("replace_password",), + ), + "unsupported_device": RecoveryInfo( + "Unsupported device", + "The detected AirPort model or OS does not have a deployable payload in this build.", + ("Check the detected model and OS.", "Use the CLI only if you intentionally pass unsupported-device overrides."), + retryable=False, + ), + "confirmation_required": RecoveryInfo( + "Confirmation required", + "This operation changes the device and needs explicit confirmation.", + ("Review the plan.", "Confirm the operation in the app before retrying."), + retryable=True, + ), + "remote_error": RecoveryInfo( + "Remote operation failed", + "The helper could not complete the requested remote device operation.", + ("Check the operation log.", "Run doctor after the device is reachable."), + retryable=True, + suggested_operation="doctor", + action_ids=("run_checkup",), + ), + "operation_failed": RecoveryInfo( + "Operation failed", + "The helper hit an unexpected failure while running the operation.", + ("Check debug details.", "Retry after fixing the reported cause."), + retryable=True, + ), +} + + +_OPERATION_CODE_RECOVERY: dict[tuple[str, str], RecoveryInfo] = { + ("configure", "auth_failed"): RecoveryInfo( + "AirPort password rejected", + "ACP or SSH authentication failed while configuring the device.", + ("Re-enter the AirPort admin password.", "Confirm the selected device is the intended Time Capsule."), + retryable=True, + suggested_operation="configure", + action_ids=("replace_password",), + ), + ("configure", "unsupported_device"): RecoveryInfo( + "Unsupported Time Capsule", + "The SSH probe succeeded, but the detected hardware or OS cannot use a bundled payload.", + ("Review the detected model and OS.", "Use a supported Gen 4 or Gen 5 Time Capsule."), + retryable=False, + ), + ("deploy", "confirmation_required"): RecoveryInfo( + "Deploy confirmation required", + "Deploy needs confirmation before uploading payload files, rebooting, or activating NetBSD4.", + ("Review the deploy plan.", "Confirm deploy and any required reboot or activation prompt."), + retryable=True, + ), + ("deploy", "validation_failed"): RecoveryInfo( + "Deployment validation failed", + "The bundled payload artifacts or deployment inputs are invalid.", + ("Open Readiness.", "Fix missing artifacts or invalid fields before retrying."), + retryable=True, + suggested_operation="validate-install", + action_ids=("open_diagnostics",), + ), + ("deploy", "unsupported_device"): RecoveryInfo( + "No supported deploy payload", + "The detected device does not match a bundled payload family.", + ("Check the device model and OS.", "Do not deploy from the GUI until a supported payload is available."), + retryable=False, + ), + ("activate", "confirmation_required"): RecoveryInfo( + "Activation confirmation required", + "NetBSD4 activation starts the deployed runtime and must be confirmed.", + ("Review the NetBSD4 activation guidance.", "Confirm activation before retrying."), + retryable=True, + action_ids=("start_smb",), + ), + ("uninstall", "confirmation_required"): RecoveryInfo( + "Uninstall confirmation required", + "Uninstall removes managed files and may reboot the device.", + ("Review the uninstall plan.", "Confirm uninstall and reboot before retrying."), + retryable=True, + action_ids=("uninstall",), + ), + ("fsck", "confirmation_required"): RecoveryInfo( + "fsck confirmation required", + "fsck stops file sharing, unmounts the selected HFS disk, and may reboot the device.", + ("Review the selected volume.", "Confirm fsck before retrying."), + retryable=True, + action_ids=("disk_repair",), + ), + ("fsck", "validation_failed"): RecoveryInfo( + "Volume selection failed", + "The helper could not choose a mounted HFS volume for fsck.", + ("Select a specific HFS volume.", "Refresh mounted volumes and retry."), + retryable=True, + action_ids=("disk_repair",), + ), + ("repair-xattrs", "confirmation_required"): RecoveryInfo( + "Repair confirmation required", + "repair-xattrs needs dry-run mode or explicit confirmation before changing local file metadata.", + ("Run a dry run first.", "Confirm repair before retrying."), + retryable=True, + action_ids=("repair_metadata",), + ), + ("repair-xattrs", "validation_failed"): RecoveryInfo( + "repair-xattrs cannot run", + "repair-xattrs must run on macOS against a valid mounted SMB share path.", + ("Choose a mounted share path.", "Run this from macOS."), + retryable=True, + action_ids=("repair_metadata",), + ), +} + + +_STAGE_RECOVERY: dict[tuple[str, str, str], RecoveryInfo] = { + ("configure", "remote_error", "acp_enable_ssh"): RecoveryInfo( + "ACP SSH enablement failed", + "The helper could not enable SSH through AirPort ACP.", + ("Verify the AirPort admin password.", "Power-cycle the device if AirPort Utility also cannot manage it."), + retryable=True, + suggested_operation="configure", + action_ids=("replace_password",), + ), + ("configure", "remote_error", "wait_for_ssh_after_acp"): RecoveryInfo( + "SSH did not open", + "ACP accepted the request, but the SSH port did not become reachable in time.", + ("Wait for the device to finish rebooting.", "Retry configure with a longer SSH wait timeout."), + retryable=True, + suggested_operation="configure", + ), + ("deploy", "remote_error", "read_mast"): RecoveryInfo( + "No HFS volumes found", + "The device did not report a deployable HFS disk through MaSt.", + ("Wake the disk by opening it in Finder.", "Check the disk is installed and formatted HFS.", "Retry deploy."), + retryable=True, + suggested_operation="deploy", + action_ids=("open_finder", "install_smb"), + ), + ("deploy", "remote_error", "select_payload_home"): RecoveryInfo( + "No writable payload volume", + "MaSt found HFS volumes, but none accepted the managed payload directory.", + ("Wake or remount the disk.", "Check available free space.", "Retry deploy."), + retryable=True, + suggested_operation="deploy", + action_ids=("open_finder", "install_smb"), + ), + ("deploy", "remote_error", "verify_payload_upload"): RecoveryInfo( + "Payload verification failed", + "The uploaded managed payload could not be verified on the HFS disk.", + ("Wake the disk and retry.", "Check the operation log for the failing path."), + retryable=True, + suggested_operation="deploy", + ), + ("deploy", "remote_error", "verify_payload_upload_after_sync"): RecoveryInfo( + "Payload verification failed after sync", + "The managed payload was not stable after flushing disk writes.", + ("Retry deploy.", "Check the disk for write or corruption issues."), + retryable=True, + suggested_operation="deploy", + ), + ("deploy", "remote_error", "wait_for_reboot_down"): RecoveryInfo( + "Reboot did not start", + "The reboot request was sent, but SSH did not go down.", + ("Power-cycle the Time Capsule.", "Retry deploy after it is reachable."), + retryable=True, + suggested_operation="doctor", + ), + ("deploy", "remote_error", "wait_for_reboot_up"): RecoveryInfo( + "Reboot did not finish", + "The device went down but SSH did not return before the timeout.", + ("Wait a few more minutes.", "Power-cycle the device if needed.", "Run doctor once SSH returns."), + retryable=True, + suggested_operation="doctor", + action_ids=("run_checkup",), + ), + ("deploy", "remote_error", "verify_runtime_reboot"): RecoveryInfo( + "Runtime not ready", + "The device rebooted, but the managed Samba runtime did not become healthy.", + ("Run doctor for details.", "Check boot logs from the CLI if doctor still fails."), + retryable=True, + suggested_operation="doctor", + action_ids=("run_checkup",), + ), + ("deploy", "remote_error", "verify_runtime_activation"): RecoveryInfo( + "Activated runtime not ready", + "The NetBSD4 runtime was started but did not become healthy.", + ("Retry activation.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="doctor", + action_ids=("start_smb", "run_checkup"), + ), + ("uninstall", "remote_error", "verify_post_uninstall"): RecoveryInfo( + "Post-uninstall verification failed", + "Managed TimeCapsuleSMB files were still present after reboot.", + ("Retry uninstall.", "Run doctor if the device is reachable."), + retryable=True, + suggested_operation="uninstall", + action_ids=("uninstall",), + ), + ("fsck", "validation_failed", "select_fsck_volume"): RecoveryInfo( + "Volume selection failed", + "The helper could not choose exactly one HFS volume for fsck.", + ("Select the target volume explicitly.", "Refresh mounted volumes and retry."), + retryable=True, + suggested_operation="fsck", + action_ids=("disk_repair",), + ), + ("repair-xattrs", "validation_failed", "platform_check"): RecoveryInfo( + "repair-xattrs requires macOS", + "repair-xattrs can only run on macOS because it uses xattr and chflags on a mounted SMB share.", + ("Run the app on macOS.", "Use dry run or repair from a mounted share path."), + retryable=False, + suggested_operation="repair-xattrs", + action_ids=("repair_metadata",), + ), + ("repair-xattrs", "validation_failed", "validate_params"): RecoveryInfo( + "Invalid repair options", + "One or more repair-xattrs options were invalid.", + ("Review the repair options.", "Retry with valid values."), + retryable=True, + suggested_operation="repair-xattrs", + action_ids=("repair_metadata",), + ), + ("repair-xattrs", "validation_failed", "resolve_scan_root"): RecoveryInfo( + "Path cannot be scanned", + "The selected path is not usable for repair-xattrs.", + ("Choose a mounted SMB share path.", "Confirm the share is accessible in Finder."), + retryable=True, + suggested_operation="repair-xattrs", + action_ids=("repair_metadata",), + ), + ("repair-xattrs", "validation_failed", "scan_findings"): RecoveryInfo( + "Path cannot be scanned", + "repair-xattrs could not read the selected mounted share path.", + ("Choose a mounted SMB share path.", "Confirm the share is accessible in Finder."), + retryable=True, + suggested_operation="repair-xattrs", + action_ids=("repair_metadata",), + ), +} + + +def recovery_for( + operation: str, + code: str, + *, + stage: str | None = None, +) -> dict[str, object]: + if stage: + policy = _STAGE_RECOVERY.get((operation, code, stage)) + if policy is not None: + return policy.to_jsonable() + policy = _OPERATION_CODE_RECOVERY.get((operation, code)) or _DEFAULTS.get(code) or _DEFAULTS["operation_failed"] + return policy.to_jsonable() diff --git a/src/timecapsulesmb/app/requests.py b/src/timecapsulesmb/app/requests.py new file mode 100644 index 0000000..ed84441 --- /dev/null +++ b/src/timecapsulesmb/app/requests.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from timecapsulesmb.services.app import AppOperationError + + +@dataclass(frozen=True) +class ApiRequest: + operation: str + params: dict[str, object] + request_id: str | None = None + + +def parse_api_request(request: Mapping[str, object]) -> ApiRequest: + request_id = request.get("request_id") + operation = str(request.get("operation") or "") + if not operation: + raise AppOperationError("missing required field: operation", code="invalid_request") + + raw_params = request.get("params", {}) + if raw_params is None: + raw_params = {} + if not isinstance(raw_params, dict): + raise AppOperationError("params must be a JSON object", code="invalid_request") + + return ApiRequest( + operation=operation, + params=dict(raw_params), + request_id=str(request_id) if request_id is not None and str(request_id).strip() else None, + ) diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py new file mode 100644 index 0000000..92695ec --- /dev/null +++ b/src/timecapsulesmb/app/service.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import traceback +from collections.abc import Callable + +from timecapsulesmb.app.events import EventSink, redact +from timecapsulesmb.app.ops import OPERATIONS +from timecapsulesmb.app.confirmations import AppConfirmationRequired +from timecapsulesmb.app.requests import parse_api_request +from timecapsulesmb.app.recovery import recovery_for +from timecapsulesmb.core.config import ConfigError +from timecapsulesmb.services.app import AppOperationError, OperationResult +from timecapsulesmb.transport.errors import TransportError + + +def run_api_request(request: dict[str, object], sink: EventSink) -> int: + try: + api_request = parse_api_request(request) + except AppOperationError as exc: + sink.error( + "api", + str(exc), + code=exc.code, + recovery=recovery_for("api", "invalid_request"), + ) + return 1 + + if api_request.request_id: + sink = sink.with_request_id(api_request.request_id) + + operation = api_request.operation + params = api_request.params + handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) + if handler is None: + sink.error( + operation, + f"unknown operation: {operation}", + code="unknown_operation", + debug={"known_operations": sorted(OPERATIONS)}, + recovery=recovery_for(operation, "unknown_operation"), + ) + return 1 + try: + result = handler(params, sink) + except AppConfirmationRequired as exc: + sink.error( + operation, + str(exc), + code=exc.code, + details=exc.confirmation.to_jsonable(), + recovery=recovery_for(operation, exc.code, stage=sink.current_stage(operation)), + ) + return 1 + except AppOperationError as exc: + recovery = exc.recovery or recovery_for(operation, exc.code, stage=sink.current_stage(operation)) + sink.error( + operation, + str(exc), + code=exc.code, + debug=redact(exc.debug) if exc.debug is not None else None, + recovery=recovery, + ) + return 1 + except ConfigError as exc: + sink.error( + operation, + str(exc), + code="config_error", + recovery=recovery_for(operation, "config_error", stage=sink.current_stage(operation)), + ) + return 1 + except TransportError as exc: + sink.error( + operation, + str(exc), + code="remote_error", + recovery=recovery_for(operation, "remote_error", stage=sink.current_stage(operation)), + ) + return 1 + except (SystemExit, KeyboardInterrupt): + raise + except Exception as exc: + sink.error( + operation, + f"{type(exc).__name__}: {exc}", + code="operation_failed", + debug={"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))}, + recovery=recovery_for(operation, "operation_failed", stage=sink.current_stage(operation)), + ) + return 1 + sink.result(operation, ok=result.ok, payload=result.payload) + return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py new file mode 100644 index 0000000..3ea3c87 --- /dev/null +++ b/src/timecapsulesmb/app/stage_policy.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +LOCAL_READ = "local_read" +LOCAL_WRITE = "local_write" +REMOTE_READ = "remote_read" +REMOTE_WRITE = "remote_write" +DESTRUCTIVE = "destructive" +REBOOT = "reboot" + +RISK_VALUES = frozenset({ + LOCAL_READ, + LOCAL_WRITE, + REMOTE_READ, + REMOTE_WRITE, + DESTRUCTIVE, + REBOOT, +}) + + +@dataclass(frozen=True) +class StagePolicy: + risk: str + cancellable: bool + description: str + + def to_jsonable(self) -> dict[str, object]: + return { + "risk": self.risk, + "cancellable": self.cancellable, + "description": self.description, + } + + +_POLICIES: dict[tuple[str, str], StagePolicy] = { + ("capabilities", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve helper configuration and distribution paths."), + ("capabilities", "summarize_capabilities"): StagePolicy(LOCAL_READ, True, "Summarize helper API capabilities."), + ("discover", "bonjour_discovery"): StagePolicy(LOCAL_READ, True, "Browse for AirPort Bonjour services."), + ("paths", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve configuration, state, and distribution paths."), + ("paths", "summarize_artifacts"): StagePolicy(LOCAL_READ, True, "Summarize bundled artifact paths."), + ("validate-install", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve app installation paths."), + ("validate-install", "validate_install"): StagePolicy(LOCAL_READ, True, "Validate local helper and artifact prerequisites."), + ("configure", "load_existing_config"): StagePolicy(LOCAL_READ, True, "Read the existing .env configuration."), + ("configure", "ssh_probe"): StagePolicy(REMOTE_READ, True, "Probe SSH reachability and device compatibility."), + ("configure", "acp_enable_ssh"): StagePolicy(REMOTE_WRITE, False, "Request SSH enablement through AirPort ACP."), + ("configure", "wait_for_ssh_after_acp"): StagePolicy(REMOTE_READ, True, "Wait for SSH to open after ACP enablement."), + ("configure", "ssh_probe_after_acp"): StagePolicy(REMOTE_READ, True, "Probe SSH again after ACP enablement."), + ("configure", "write_env"): StagePolicy(LOCAL_WRITE, False, "Write the app .env configuration."), + ("deploy", "load_config"): StagePolicy(LOCAL_READ, True, "Read deployment configuration."), + ("deploy", "resolve_managed_target"): StagePolicy(REMOTE_READ, True, "Resolve and probe the managed Time Capsule target."), + ("deploy", "validate_artifacts"): StagePolicy(LOCAL_READ, True, "Validate bundled payload artifacts."), + ("deploy", "check_compatibility"): StagePolicy(REMOTE_READ, True, "Check detected device compatibility."), + ("deploy", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("deploy", "select_payload_home"): StagePolicy(REMOTE_READ, True, "Select a writable HFS payload location."), + ("deploy", "build_deployment_plan"): StagePolicy(LOCAL_READ, True, "Build the deployment action plan."), + ("deploy", "pre_upload_actions"): StagePolicy(REMOTE_WRITE, False, "Prepare remote directories and stop conflicting processes."), + ("deploy", "prepare_deployment_files"): StagePolicy(LOCAL_WRITE, True, "Generate temporary deployment config files."), + ("deploy", "upload_payload"): StagePolicy(REMOTE_WRITE, False, "Upload managed Samba payload files."), + ("deploy", "post_upload_actions"): StagePolicy(REMOTE_WRITE, False, "Install flash hooks and payload permissions."), + ("deploy", "verify_payload_upload"): StagePolicy(REMOTE_READ, True, "Verify uploaded payload files."), + ("deploy", "flush_payload_upload"): StagePolicy(REMOTE_WRITE, False, "Flush remote filesystem writes."), + ("deploy", "verify_payload_upload_after_sync"): StagePolicy(REMOTE_READ, True, "Verify uploaded payload files after sync."), + ("deploy", "netbsd4_activation"): StagePolicy(REMOTE_WRITE, False, "Start the deployed NetBSD4 runtime."), + ("deploy", "verify_runtime_activation"): StagePolicy(REMOTE_READ, True, "Wait for the activated runtime to become ready."), + ("deploy", "reboot"): StagePolicy(REBOOT, False, "Request a device reboot."), + ("deploy", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after reboot request."), + ("deploy", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after reboot."), + ("deploy", "verify_runtime_reboot"): StagePolicy(REMOTE_READ, True, "Wait for the managed runtime after reboot."), + ("doctor", "load_config"): StagePolicy(LOCAL_READ, True, "Read diagnostic configuration."), + ("doctor", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("doctor", "run_checks"): StagePolicy(REMOTE_READ, True, "Run local and remote diagnostic checks."), + ("activate", "load_config"): StagePolicy(LOCAL_READ, True, "Read activation configuration."), + ("activate", "resolve_managed_target"): StagePolicy(REMOTE_READ, True, "Resolve and probe the NetBSD4 target."), + ("activate", "build_activation_plan"): StagePolicy(LOCAL_READ, True, "Build the NetBSD4 activation action plan."), + ("activate", "probe_runtime"): StagePolicy(REMOTE_READ, True, "Check whether the NetBSD4 runtime is already ready."), + ("activate", "run_activation"): StagePolicy(REMOTE_WRITE, False, "Run NetBSD4 activation commands."), + ("activate", "verify_runtime_activation"): StagePolicy(REMOTE_READ, True, "Wait for the activated runtime to become ready."), + ("uninstall", "load_config"): StagePolicy(LOCAL_READ, True, "Read uninstall configuration."), + ("uninstall", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("uninstall", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("uninstall", "mount_mast_volumes"): StagePolicy(REMOTE_WRITE, False, "Mount HFS volumes before uninstall."), + ("uninstall", "build_uninstall_plan"): StagePolicy(LOCAL_READ, True, "Build the uninstall action plan."), + ("uninstall", "uninstall_payload"): StagePolicy(DESTRUCTIVE, False, "Remove managed payload files and flash hooks."), + ("uninstall", "reboot"): StagePolicy(REBOOT, False, "Request a device reboot."), + ("uninstall", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after reboot request."), + ("uninstall", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after reboot."), + ("uninstall", "verify_post_uninstall"): StagePolicy(REMOTE_READ, True, "Verify managed files are absent after reboot."), + ("fsck", "load_config"): StagePolicy(LOCAL_READ, True, "Read fsck configuration."), + ("fsck", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("fsck", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("fsck", "mount_hfs_volumes"): StagePolicy(REMOTE_WRITE, False, "Mount HFS volumes before fsck."), + ("fsck", "select_fsck_volume"): StagePolicy(REMOTE_READ, True, "Select the HFS volume to repair."), + ("fsck", "run_fsck"): StagePolicy(DESTRUCTIVE, False, "Unmount the selected disk and run fsck_hfs."), + ("fsck", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after fsck reboot."), + ("fsck", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after fsck reboot."), + ("repair-xattrs", "platform_check"): StagePolicy(LOCAL_READ, True, "Verify repair-xattrs is running on macOS."), + ("repair-xattrs", "validate_params"): StagePolicy(LOCAL_READ, True, "Validate repair-xattrs request parameters."), + ("repair-xattrs", "resolve_scan_root"): StagePolicy(LOCAL_READ, True, "Resolve the mounted SMB share scan root."), + ("repair-xattrs", "scan_findings"): StagePolicy(LOCAL_READ, True, "Scan local mounted SMB files for xattr problems."), + ("repair-xattrs", "report_findings"): StagePolicy(LOCAL_READ, True, "Render xattr findings and repair candidates."), + ("repair-xattrs", "confirm_repair"): StagePolicy(LOCAL_READ, True, "Confirm local metadata repairs."), + ("repair-xattrs", "repair_findings"): StagePolicy(DESTRUCTIVE, False, "Repair local file metadata on the mounted SMB share."), +} + + +def stage_policy(operation: str, stage: str) -> StagePolicy | None: + return _POLICIES.get((operation, stage)) diff --git a/src/timecapsulesmb/checks/doctor.py b/src/timecapsulesmb/checks/doctor.py index 5720404..30098fd 100644 --- a/src/timecapsulesmb/checks/doctor.py +++ b/src/timecapsulesmb/checks/doctor.py @@ -32,6 +32,7 @@ from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.config import AppConfig from timecapsulesmb.device.probe import ProbedDeviceState, RemoteInterfaceProbeResult +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.transport.ssh import SshConnection @@ -45,6 +46,7 @@ def run_doctor_checks( skip_ssh: bool = False, skip_bonjour: bool = False, skip_smb: bool = False, + bonjour_timeout: float = DEFAULT_BROWSE_TIMEOUT_SEC, on_result: Optional[Callable[[CheckResult], None]] = None, debug_fields: dict[str, object] | None = None, ) -> tuple[list[CheckResult], bool]: @@ -52,6 +54,7 @@ def run_doctor_checks( skip_ssh=skip_ssh, skip_bonjour=skip_bonjour, skip_smb=skip_smb, + bonjour_timeout=bonjour_timeout, ) inputs = DoctorInputs( config=config, diff --git a/src/timecapsulesmb/checks/doctor_state.py b/src/timecapsulesmb/checks/doctor_state.py index fd74f1f..bcf786c 100644 --- a/src/timecapsulesmb/checks/doctor_state.py +++ b/src/timecapsulesmb/checks/doctor_state.py @@ -27,6 +27,7 @@ class DoctorOptions: skip_ssh: bool skip_bonjour: bool skip_smb: bool + bonjour_timeout: float @dataclass(frozen=True) diff --git a/src/timecapsulesmb/checks/doctor_steps.py b/src/timecapsulesmb/checks/doctor_steps.py index 577b4bb..132cfea 100644 --- a/src/timecapsulesmb/checks/doctor_steps.py +++ b/src/timecapsulesmb/checks/doctor_steps.py @@ -267,6 +267,7 @@ def _add_bonjour_results( *, proxied_ssh: bool, skip_bonjour: bool, + bonjour_timeout: float, add_result: Callable[[CheckResult], None], ) -> DoctorBonjourResult: bonjour_instance: str | None = None @@ -301,6 +302,7 @@ def _add_bonjour_results( zeroconf_debug=None, ) smb_snapshot, discovery_error, bonjour_zeroconf_debug = discover_smb_services_detailed( + timeout=bonjour_timeout, include_related=True, target_ip=bonjour_expected.target_ip, ) @@ -777,6 +779,7 @@ def _doctor_check_bonjour(inputs: DoctorInputs, target: DoctorTarget, naming: Ru naming.identity, proxied_ssh=target.proxied_ssh, skip_bonjour=inputs.options.skip_bonjour, + bonjour_timeout=inputs.options.bonjour_timeout, add_result=sink.add, ) diff --git a/src/timecapsulesmb/cli/activate.py b/src/timecapsulesmb/cli/activate.py index 738e853..3521285 100644 --- a/src/timecapsulesmb/cli/activate.py +++ b/src/timecapsulesmb/cli/activate.py @@ -8,11 +8,12 @@ from timecapsulesmb.cli.runtime import ( add_config_argument, load_env_config, + print_json, require_netbsd4_device_compatibility, ) from timecapsulesmb.core.config import airport_exact_display_name_from_identity from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.deploy.dry_run import format_activation_plan +from timecapsulesmb.deploy.dry_run import activation_plan_to_jsonable, format_activation_plan from timecapsulesmb.deploy.executor import run_remote_actions from timecapsulesmb.deploy.planner import build_netbsd4_activation_plan from timecapsulesmb.device.probe import probe_managed_runtime_conn @@ -37,8 +38,12 @@ def main(argv: Optional[list[str]] = None) -> int: add_config_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before restarting the deployed Samba services") parser.add_argument("--dry-run", action="store_true", help="Print activation actions without making changes") + parser.add_argument("--json", action="store_true", help="Output the dry-run activation plan as JSON") args = parser.parse_args(argv) + if args.json and not args.dry_run: + parser.error("--json currently requires --dry-run") + ensure_install_id() config = load_env_config(env_path=args.config) telemetry = TelemetryClient.from_config(config) @@ -51,6 +56,7 @@ def main(argv: Optional[list[str]] = None) -> int: require_netbsd4_device_compatibility( command_context, command_name="activate", + json_output=args.json, unsupported_message="activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", ) @@ -60,7 +66,10 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.update_fields(activation_action_count=len(plan.actions)) if args.dry_run: - print(format_activation_plan(plan, device_name=device_name)) + if args.json: + print_json(activation_plan_to_jsonable(plan)) + else: + print(format_activation_plan(plan, device_name=device_name)) command_context.succeed() return 0 diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index ea381c1..e3f1b21 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -26,12 +26,13 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import wait_for_tcp_port_state from timecapsulesmb.cli.runtime import ( + add_bonjour_timeout_argument, add_config_argument, confirm as confirm_prompt, ssh_target_link_local_resolution_error, ) from timecapsulesmb.core.errors import missing_dependency_message, missing_required_python_module -from timecapsulesmb.core.net import extract_host, is_link_local_ipv4 +from timecapsulesmb.core.net import extract_host from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.device.compat import DeviceCompatibility, render_compatibility_message @@ -44,11 +45,10 @@ from timecapsulesmb.discovery.bonjour import ( BonjourResolvedService, AIRPORT_SERVICE, - DEFAULT_BROWSE_TIMEOUT_SEC, discover_resolved_records, discovered_record_root_host, - record_has_service, ) +from timecapsulesmb.discovery.devices import DiscoveredDeviceCandidate, device_candidates_from_records from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import SshConnection from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh @@ -80,16 +80,15 @@ def confirm(prompt_text: str, default_no: bool = False) -> bool: return confirm_prompt(prompt_text, default=not default_no, eof_default=False) -def list_devices(records) -> None: +def list_devices(candidates: list[DiscoveredDeviceCandidate]) -> None: print("Found devices:") - for i, record in enumerate(records, start=1): - root_host = discovered_record_root_host(record) - pref = root_host.removeprefix("root@") if root_host else record.hostname or "-" - ipv4 = ",".join(record.ipv4) if record.ipv4 else "-" - print(f" {i}. {record.name} | host: {pref} | IPv4: {ipv4}") + for i, candidate in enumerate(candidates, start=1): + pref = candidate.host or "-" + ipv4 = ",".join(candidate.ipv4) if candidate.ipv4 else "-" + print(f" {i}. {candidate.name} | host: {pref} | IPv4: {ipv4}") -def choose_device(records): +def choose_device(candidates: list[DiscoveredDeviceCandidate]) -> DiscoveredDeviceCandidate | None: while True: try: raw = input("Select a device by number (q to skip discovery): ").strip() @@ -102,39 +101,40 @@ def choose_device(records): print("Please enter a valid number.") continue idx = int(raw) - if not (1 <= idx <= len(records)): + if not (1 <= idx <= len(candidates)): print("Out of range.") continue - return records[idx - 1] + return candidates[idx - 1] -def discover_default_record(existing: dict[str, str]) -> Optional[BonjourResolvedService]: +def discover_default_record(existing: dict[str, str], *, timeout: float) -> Optional[BonjourResolvedService]: print("Attempting to discover Time Capsule/Airport Extreme devices on the local network via mDNS...", flush=True) - records = discover_resolved_records(AIRPORT_SERVICE, timeout=DEFAULT_BROWSE_TIMEOUT_SEC) - if not records: + records = discover_resolved_records(AIRPORT_SERVICE, timeout=timeout) + candidates = device_candidates_from_records(records, airport_only=False) + if not candidates: print("No Time Capsule/Airport Extreme devices discovered. Falling back to manual SSH target entry.\n", flush=True) return None - list_devices(records) - selected = choose_device(records) + list_devices(candidates) + selected = choose_device(candidates) if selected is None: existing_target = valid_existing_config_value(existing, "TC_HOST", "Device SSH target") or DEFAULTS["TC_HOST"] print(f"Discovery skipped. Falling back to {existing_target}.\n", flush=True) return None - chosen_host = discovered_record_root_host(selected) + chosen_host = selected.ssh_host selected_host = ( chosen_host.removeprefix("root@") if chosen_host else selected.hostname or "manual SSH target required" ) print(f"Selected: {selected.name} ({selected_host})\n", flush=True) - if chosen_host is None and any(is_link_local_ipv4(ip) for ip in selected.ipv4): + if chosen_host is None and selected.link_local_only: print( "Selected device only advertised 169.254.x.x link-local IPv4. " "Enter the device's LAN IP or LAN-resolving hostname manually.\n", flush=True, ) - return selected + return selected.selected_record def exception_summary(exc: BaseException) -> str: @@ -297,6 +297,8 @@ def main(argv: Optional[list[str]] = None) -> int: add_config_argument(parser) parser.add_argument("--internal-share-use-disk-root", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--any-protocol", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) + add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) ensure_install_id() @@ -326,7 +328,7 @@ def main(argv: Optional[list[str]] = None) -> int: args=args, configure_id=configure_id, ) as command_context: - command_context.update_fields(configure_id=configure_id) + command_context.update_fields(configure_id=configure_id, bonjour_timeout=args.bonjour_timeout) command_context.set_stage("dependency_check") missing_module = missing_required_python_module(REQUIRED_PYTHON_MODULES) if missing_module is not None: @@ -357,9 +359,15 @@ def main(argv: Optional[list[str]] = None) -> int: values["TC_ANY_PROTOCOL"] = ( "true" if args.any_protocol or existing_any_protocol else "false" ) + existing_debug_logging = parse_bool( + existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + ) + values["TC_DEBUG_LOGGING"] = ( + "true" if args.debug_logging or existing_debug_logging else "false" + ) command_context.set_stage("bonjour_discovery") try: - discovered_record = discover_default_record(existing) + discovered_record = discover_default_record(existing, timeout=args.bonjour_timeout) except Exception as exc: error_text = exception_summary(exc) print(f"Warning: mDNS discovery failed: {error_text}") diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index 1e52a65..86dc322 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -7,24 +7,21 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import request_deploy_reboot_and_wait, verify_managed_runtime_flow +from timecapsulesmb.cli.flows import request_deploy_reboot, request_deploy_reboot_and_wait, verify_managed_runtime_flow from timecapsulesmb.cli.runtime import ( + add_mount_wait_argument, + add_no_wait_argument, add_config_argument, load_env_config, print_json, require_supported_device_compatibility, ) from timecapsulesmb.core.config import ( - DEFAULTS, MANAGED_PAYLOAD_DIR_NAME, - AppConfig, airport_family_display_name_from_identity, - parse_bool, - shell_quote, ) from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP, NETBSD4_REBOOT_GUIDANCE from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts from timecapsulesmb.deploy.artifacts import validate_artifacts @@ -35,9 +32,6 @@ BINARY_MDNS_SOURCE, BINARY_NBNS_SOURCE, BINARY_SMBD_SOURCE, - DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - DEFAULT_ATA_IDLE_SECONDS, - DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, GENERATED_FLASH_CONFIG_SOURCE, GENERATED_SMBPASSWD_SOURCE, GENERATED_USERNAME_MAP_SOURCE, @@ -55,66 +49,21 @@ from timecapsulesmb.device.storage import ( MAST_DISCOVERY_ATTEMPTS, MAST_DISCOVERY_DELAY_SECONDS, - PayloadHome, - PayloadVerificationResult, build_dry_run_payload_home, verify_payload_home_conn, ) +from timecapsulesmb.services.deploy import ( + DEPLOY_REBOOT_NO_DOWN_MESSAGE as REBOOT_NO_DOWN_MESSAGE, + no_mast_volumes_message, + no_writable_mast_volumes_message, + payload_verification_error, + render_flash_runtime_config, +) from timecapsulesmb.device.probe import read_interface_ipv4_addrs_conn from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.cli.util import color_green, color_red -REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." -) - - -def _no_mast_volumes_message(*, attempts: int, delay_seconds: int) -> str: - return ( - f"No deployable HFS disk was found after {attempts} MaSt queries " - f"spaced {delay_seconds} seconds apart." - ) - - -def _no_writable_mast_volumes_message(volume_count: int) -> str: - return f"MaSt found {volume_count} deployable HFS volume(s), but deploy could not write to any of them." - - -def _render_flash_config_assignment(key: str, value: str | int) -> str: - if isinstance(value, int): - return f"{key}={value}" - return f"{key}={shell_quote(value)}" - - -def render_flash_runtime_config( - config: AppConfig, - payload_home: PayloadHome, - *, - nbns_enabled: bool, - debug_logging: bool, - ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, - diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, -) -> str: - internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) - any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) - - values: list[tuple[str, str | int]] = [ - ("TC_CONFIG_VERSION", 2), - ("TC_DEPLOY_RELEASE_TAG", RELEASE_TAG), - ("TC_DEPLOY_CLI_VERSION_CODE", CLI_VERSION_CODE), - ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if parse_bool(internal_root_default) else 0), - ("ANY_PROTOCOL", 1 if parse_bool(any_protocol_default) else 0), - ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), - ("ATA_IDLE_SECONDS", ata_idle_seconds), - ("NBNS_ENABLED", 1 if nbns_enabled else 0), - ("SMBD_DEBUG_LOGGING", 1 if debug_logging else 0), - ("MDNS_DEBUG_LOGGING", 1 if debug_logging else 0), - ] - return "\n".join(_render_flash_config_assignment(key, value) for key, value in values) + "\n" - - def _target_family_display_name(target) -> str: probe = target.probe_state.probe_result if target.probe_state is not None else None return airport_family_display_name_from_identity( @@ -123,36 +72,17 @@ def _target_family_display_name(target) -> str: ) -def _payload_verification_error(payload_home: PayloadHome, result: PayloadVerificationResult) -> str: - return f"managed payload verification failed at {payload_home.payload_dir}: {result.detail}" - - -def _non_negative_int(value: str) -> int: - try: - parsed = int(value) - except ValueError as e: - raise argparse.ArgumentTypeError("must be an integer") from e - if parsed < 0: - raise argparse.ArgumentTypeError("must be 0 or greater") - return parsed - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Deploy the checked-in Samba 4 payload to an AirPort storage device.") add_config_argument(parser) + add_mount_wait_argument(parser) + add_no_wait_argument(parser) parser.add_argument("--no-reboot", action="store_true", help="Do not reboot after deployment") parser.add_argument("--yes", action="store_true", help="Do not prompt before reboot") parser.add_argument("--dry-run", action="store_true", help="Print actions without making changes") parser.add_argument("--json", action="store_true", help="Output the dry-run deployment plan as JSON") parser.add_argument("--allow-unsupported", action="store_true", help="Proceed even if the detected device is not currently supported") parser.add_argument("--no-nbns", action="store_true", help="Disable the bundled NBNS responder on the next boot") - parser.add_argument( - "--mount-wait", - type=_non_negative_int, - default=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - metavar="SECONDS", - help=f"Seconds for deployment-time diskd.useVolume mount guards to wait before their manual fallback (default: {DEFAULT_APPLE_MOUNT_WAIT_SECONDS})", - ) parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args(argv) @@ -212,7 +142,7 @@ def main(argv: Optional[list[str]] = None) -> int: mast_volumes = mast_discovery.volumes if not mast_volumes: raise SystemExit( - _no_mast_volumes_message( + no_mast_volumes_message( attempts=MAST_DISCOVERY_ATTEMPTS, delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, ) @@ -224,7 +154,7 @@ def main(argv: Optional[list[str]] = None) -> int: wait_seconds=apple_mount_wait_seconds, ) if selection.payload_home is None: - raise SystemExit(_no_writable_mast_volumes_message(len(mast_volumes))) + raise SystemExit(no_writable_mast_volumes_message(len(mast_volumes))) payload_home = selection.payload_home command_context.set_stage("build_deployment_plan") plan = build_deployment_plan( @@ -318,7 +248,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.add_debug_fields(payload_upload_verification=payload_verification.detail) if not payload_verification.ok: - raise SystemExit(_payload_verification_error(payload_home, payload_verification)) + raise SystemExit(payload_verification_error(payload_home, payload_verification)) command_context.set_stage("flush_payload_upload") if not args.json: @@ -336,7 +266,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.add_debug_fields(payload_post_sync_verification=payload_verification.detail) if not payload_verification.ok: - raise SystemExit(_payload_verification_error(payload_home, payload_verification)) + raise SystemExit(payload_verification_error(payload_home, payload_verification)) print(f"Deployed Samba payload to {plan.payload_dir}") print("Updated /mnt/Flash boot files.") @@ -379,6 +309,13 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.cancel_with_error("Cancelled by user at reboot confirmation prompt.") return 0 + if args.no_wait: + request_deploy_reboot(connection, command_context, raise_on_request_error=True) + print("Reboot requested; not waiting for the device to go down or come back.") + print(color_green("Deploy Finished.")) + command_context.succeed() + return 0 + if not request_deploy_reboot_and_wait( connection, command_context, diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index 09bb686..2208fc0 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -8,9 +8,10 @@ from timecapsulesmb.checks.doctor import run_doctor_checks from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json +from timecapsulesmb.cli.runtime import add_bonjour_timeout_argument, add_config_argument, load_env_config, print_json from timecapsulesmb.cli.util import color_green, color_red from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.doctor import doctor_status_counts from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.core.paths import resolve_app_paths @@ -250,6 +251,7 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--skip-bonjour", action="store_true", help="Skip Bonjour browse/resolve checks") parser.add_argument("--skip-smb", action="store_true", help="Skip authenticated SMB listing check") parser.add_argument("--json", action="store_true", help="Output doctor results as JSON") + add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) ensure_install_id() @@ -261,6 +263,7 @@ def main(argv: Optional[list[str]] = None) -> int: skip_ssh=args.skip_ssh, skip_bonjour=args.skip_bonjour, skip_smb=args.skip_smb, + bonjour_timeout=args.bonjour_timeout, json_output=args.json, ) if not args.skip_ssh and config.has_value("TC_HOST"): @@ -279,11 +282,12 @@ def main(argv: Optional[list[str]] = None) -> int: skip_ssh=args.skip_ssh, skip_bonjour=args.skip_bonjour, skip_smb=args.skip_smb, + bonjour_timeout=args.bonjour_timeout, on_result=None if args.json else print_result, debug_fields=doctor_debug, ) command_context.add_debug_fields(**doctor_debug) - status_counts = {status: sum(1 for result in results if result.status == status) for status in ("PASS", "WARN", "FAIL", "INFO")} + status_counts = doctor_status_counts(results) command_context.update_fields( fatal=fatal, check_count=len(results), diff --git a/src/timecapsulesmb/cli/flash.py b/src/timecapsulesmb/cli/flash.py index bf2b843..df83605 100644 --- a/src/timecapsulesmb/cli/flash.py +++ b/src/timecapsulesmb/cli/flash.py @@ -19,6 +19,7 @@ from timecapsulesmb.cli.runtime import ( LogCallback, add_config_argument, + add_no_wait_argument, emit_progress, load_env_config, prefixed_logger, @@ -549,6 +550,7 @@ def _build_parser() -> argparse.ArgumentParser: mode_group.add_argument("--download-only", action="store_true", help="Download and validate Apple firmware without writing") parser.add_argument("--yes", action="store_true", help="Do not prompt before --patch or --restore writes") parser.add_argument("--reboot", action="store_true", help="Reboot after a validated --restore write") + add_no_wait_argument(parser) parser.add_argument("--poweroff", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--json", action="store_true", help="Output the flash analysis and plan as JSON") parser.add_argument("--backup-dir", type=Path, default=None, help="Directory where this run's firmware backup should be saved") @@ -580,6 +582,8 @@ def _parse_args(argv: Optional[list[str]]) -> tuple[argparse.Namespace, str]: parser.error("flash --patch cannot use --reboot; power cycle manually after the validated write") if args.reboot and operation != "restore": parser.error("--reboot is only valid with --restore") + if args.no_wait and not (operation == "restore" and args.reboot): + parser.error("--no-wait is only valid with --restore --reboot") if args.poweroff: parser.error("--poweroff is not supported; power cycle manually after a validated patch write") if args.json and operation in WRITE_OPERATIONS: @@ -972,7 +976,11 @@ def _finish_write( command_context.succeed() return 0 - request_ssh_reboot(target.connection, command_context, log=log) + request_ssh_reboot(target.connection, command_context, log=log, raise_on_request_error=args.no_wait) + if args.no_wait: + print("Reboot requested; not waiting for the device to go down or come back.", flush=True) + command_context.succeed() + return 0 if not observe_reboot_cycle( target.connection, command_context, diff --git a/src/timecapsulesmb/cli/flows.py b/src/timecapsulesmb/cli/flows.py index 12dce9b..b089f66 100644 --- a/src/timecapsulesmb/cli/flows.py +++ b/src/timecapsulesmb/cli/flows.py @@ -83,9 +83,7 @@ def request_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_acp_then_ssh(connection, command_context) + request_reboot(connection, command_context) return observe_reboot_cycle( connection, @@ -96,6 +94,17 @@ def request_reboot_and_wait( ) +def request_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + raise_on_request_error: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_acp_then_ssh(connection, command_context, raise_on_request_error=raise_on_request_error) + + def request_deploy_reboot_and_wait( connection: SshConnection, command_context: CommandContext, @@ -104,9 +113,7 @@ def request_deploy_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_via_ssh_shutdown(connection, command_context) + request_deploy_reboot(connection, command_context) return observe_reboot_cycle( connection, @@ -117,23 +124,53 @@ def request_deploy_reboot_and_wait( ) +def request_deploy_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + raise_on_request_error: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_via_ssh_shutdown( + connection, + command_context, + raise_on_request_error=raise_on_request_error, + ) + + def request_ssh_reboot( connection: SshConnection, command_context: CommandContext, *, log: LogCallback = None, + raise_on_request_error: bool = False, ) -> None: command_context.set_stage("reboot") command_context.update_fields(reboot_was_attempted=True) command_context.add_debug_fields(reboot_request_strategy="ssh") - _request_reboot_via_ssh(connection, command_context, log=log) + _request_reboot_via_ssh( + connection, + command_context, + log=log, + raise_on_request_error=raise_on_request_error, + ) -def _request_reboot_acp_then_ssh(connection: SshConnection, command_context: CommandContext) -> None: +def _request_reboot_acp_then_ssh( + connection: SshConnection, + command_context: CommandContext, + *, + raise_on_request_error: bool = False, +) -> None: command_context.add_debug_fields(reboot_request_strategy="acp_then_ssh") if _request_reboot_via_acp(connection, command_context): return - _request_reboot_via_ssh(connection, command_context) + _request_reboot_via_ssh( + connection, + command_context, + raise_on_request_error=raise_on_request_error, + ) def _request_reboot_via_acp(connection: SshConnection, command_context: CommandContext) -> bool: @@ -162,6 +199,7 @@ def _request_reboot_via_ssh_shutdown( command_context: CommandContext, *, log: LogCallback = None, + raise_on_request_error: bool = False, ) -> None: command_context.add_debug_fields(reboot_request_strategy="ssh_shutdown_then_reboot") _request_reboot_via_ssh( @@ -170,6 +208,7 @@ def _request_reboot_via_ssh_shutdown( log=log, request_reboot=remote_request_reboot, progress_message=SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, + raise_on_request_error=raise_on_request_error, ) @@ -180,6 +219,7 @@ def _request_reboot_via_ssh( log: LogCallback = None, request_reboot: Callable[[SshConnection], None] | None = None, progress_message: str = SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, + raise_on_request_error: bool = False, ) -> None: command_context.add_debug_fields(ssh_reboot_attempted=True) emit_progress(log, progress_message) @@ -193,6 +233,8 @@ def _request_reboot_via_ssh( ssh_reboot_timed_out=True, ssh_reboot_error=system_exit_message(exc), ) + if raise_on_request_error: + raise print("SSH reboot request timed out; checking whether the device is rebooting...") return except SshError as exc: @@ -200,6 +242,8 @@ def _request_reboot_via_ssh( ssh_reboot_succeeded=False, ssh_reboot_error=system_exit_message(exc), ) + if raise_on_request_error: + raise print("SSH reboot request failed; checking whether the device is rebooting anyway...") return diff --git a/src/timecapsulesmb/cli/fsck.py b/src/timecapsulesmb/cli/fsck.py index 78cffad..524fcac 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -2,110 +2,42 @@ import argparse import shlex -from dataclasses import dataclass from typing import Optional from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import observe_reboot_cycle -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config -from timecapsulesmb.deploy.executor import DETACHED_SHUTDOWN_REBOOT_COMMAND -from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS -from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog +from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.device.storage import MaStVolume +from timecapsulesmb.services.maintenance import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + FSCK_REBOOT_NO_DOWN_MESSAGE, + build_remote_fsck_script, + format_fsck_plan, + format_fsck_targets, + fsck_target_from_volume, + select_fsck_target, +) from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import run_ssh -FSCK_REBOOT_NO_DOWN_MESSAGE = "fsck requested reboot from the device, but SSH did not go down." -FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 -NO_MOUNTED_HFS_VOLUMES_MESSAGE = "no mounted HFS volumes found" -MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE = "multiple mounted HFS volumes found; specify --volume to select one" - - -@dataclass(frozen=True) -class FsckTarget: - device: str - mountpoint: str - name: str - builtin: bool - - -def _target_from_volume(volume: MaStVolume) -> FsckTarget: - return FsckTarget( - device=volume.device_path, - mountpoint=volume.volume_root, - name=volume.name, - builtin=volume.builtin, - ) - - -def _normalize_volume_selector(selector: str) -> str: - selector = selector.strip() - if selector.startswith("/dev/"): - return selector.removeprefix("/dev/") - return selector - - -def select_fsck_target(targets: tuple[FsckTarget, ...], selector: str | None, *, prompt: bool = True) -> FsckTarget: - if not targets: - raise RuntimeError(NO_MOUNTED_HFS_VOLUMES_MESSAGE) - if selector: - selected_device = _normalize_volume_selector(selector) - for target in targets: - if target.device == selector or target.device.removeprefix("/dev/") == selected_device: - return target - raise RuntimeError(f"HFS volume not found: {selector}") - if len(targets) == 1: - return targets[0] - if not prompt: - raise RuntimeError(MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE) - - print("Mounted HFS volumes:") - for index, target in enumerate(targets, start=1): - kind = "internal" if target.builtin else "external" - print(f" {index}. {target.device} on {target.mountpoint} ({target.name}, {kind})") - while True: - answer = input("Select a volume to fsck by number: ").strip() - if answer.isdigit(): - index = int(answer) - if 1 <= index <= len(targets): - return targets[index - 1] - print("Please enter a valid volume number.") - - -def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: - lines = [ - render_direct_pkill9_watchdog(), - render_direct_pkill9_by_ucomm("smbd"), - render_direct_pkill9_by_ucomm("afpserver"), - render_direct_pkill9_by_ucomm("wcifsnd"), - render_direct_pkill9_by_ucomm("wcifsfs"), - "sleep 2", - f"/sbin/umount -f {shlex.quote(mountpoint)} >/dev/null 2>&1 || true", - f"echo '--- fsck_hfs {device} ---'", - f"/sbin/fsck_hfs -fy {shlex.quote(device)} 2>&1 || true", - ] - if reboot: - lines.extend( - [ - "echo '--- reboot ---'", - DETACHED_SHUTDOWN_REBOOT_COMMAND, - ] - ) - return "\n".join(lines) - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Run fsck_hfs on a mounted HFS volume and reboot by default.") add_config_argument(parser) + add_mount_wait_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before running fsck") + parser.add_argument("--dry-run", action="store_true", help="Print the selected fsck target and actions without making changes") + parser.add_argument("--list-volumes", action="store_true", help="List mounted HFS volumes that can be selected for fsck") parser.add_argument("--no-reboot", action="store_true", help="Run fsck only; do not reboot afterward") - parser.add_argument("--no-wait", action="store_true", help="Do not wait for SSH to go down and come back after reboot") + add_no_wait_argument(parser) parser.add_argument("--volume", help="HFS volume device to repair, for example dk2 or /dev/dk2") args = parser.parse_args(argv) - print("Running fsck...") + if args.dry_run and args.list_volumes: + parser.error("--dry-run and --list-volumes are mutually exclusive") + + if not args.dry_run and not args.list_volumes: + print("Running fsck...") ensure_install_id() config = load_env_config(env_path=args.config) @@ -124,15 +56,22 @@ def main(argv: Optional[list[str]] = None) -> int: mounted_volumes = command_context.mount_mast_volumes( connection, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=args.mount_wait, mount_stage="mount_hfs_volumes", ) + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) + if args.list_volumes: + command_context.set_stage("list_fsck_volumes") + print(format_fsck_targets(targets)) + command_context.succeed() + return 0 + command_context.set_stage("select_fsck_volume") try: target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), + targets, args.volume, - prompt=not args.yes, + prompt=not args.yes and not args.dry_run, ) except RuntimeError as exc: raise SystemExit(str(exc)) from exc @@ -140,6 +79,11 @@ def main(argv: Optional[list[str]] = None) -> int: print(f"Target host: {connection.host}") print(f"Mounted HFS volume: {target.device} on {target.mountpoint}") + if args.dry_run: + print(format_fsck_plan(target, reboot=not args.no_reboot, wait=not args.no_wait)) + command_context.succeed() + return 0 + if not args.yes: command_context.set_stage("confirm_fsck") device_name = command_context.optional_airport_display_name(timeout_seconds=0.1) @@ -170,6 +114,7 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.update_fields(reboot_was_attempted=True) if args.no_wait: + print("Reboot requested; not waiting for the device to go down or come back.") command_context.succeed() return 0 diff --git a/src/timecapsulesmb/cli/main.py b/src/timecapsulesmb/cli/main.py index 0dc61e2..0062d5a 100644 --- a/src/timecapsulesmb/cli/main.py +++ b/src/timecapsulesmb/cli/main.py @@ -5,11 +5,13 @@ from typing import Optional from . import activate, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, repair_xattrs, uninstall, validate_install +from timecapsulesmb.app import helper as app_helper from timecapsulesmb.core.paths import DistributionRootError from .version_check import check_client_version, render_version_block_message COMMANDS = { + "api": app_helper.main, "bootstrap": bootstrap.main, "activate": activate.main, "configure": configure.main, @@ -36,7 +38,7 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) - if "-h" not in args.args and "--help" not in args.args: + if args.command != "api" and "-h" not in args.args and "--help" not in args.args: try: version_check = check_client_version() if version_check.should_block: diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index bb00930..403a341 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -2,12 +2,17 @@ import argparse import sys +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Callable, Optional +from timecapsulesmb.app.contracts import repair_xattrs_payload +from timecapsulesmb.app.events import EventSink from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.runtime import add_config_argument, confirm as confirm_prompt, load_optional_env_config from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.repair_xattrs import ( ACTION_CLEAR_ARCH_FLAG, @@ -41,18 +46,38 @@ xattr_status, xattrs_readable, ) +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.maintenance import LineLogCapture, RepairExecutionContext from timecapsulesmb.telemetry import TelemetryClient -def print_candidates(candidates: list[RepairCandidate], *, dry_run: bool) -> None: +@dataclass(frozen=True) +class RepairRunResult: + returncode: int + root: Path + findings: list[RepairFinding] + candidates: list[RepairCandidate] + summary: RepairSummary + report: str | None = None + + +def render_candidate_lines(candidates: list[RepairCandidate], *, dry_run: bool) -> list[str]: verb = "Would repair" if dry_run else "Repairable" + lines: list[str] = [] for candidate in candidates: actions = ", ".join(candidate.actions) or "none" flags = f", flags: {candidate.flags}" if candidate.flags else "" - print(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + lines.append(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + return lines -def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: +def print_candidates(candidates: list[RepairCandidate], *, dry_run: bool) -> None: + for line in render_candidate_lines(candidates, dry_run=dry_run): + print(line) + + +def render_diagnostic_lines(findings: list[RepairFinding], *, verbose: bool) -> list[str]: + lines: list[str] = [] for finding in findings: if finding.repairable: continue @@ -62,30 +87,61 @@ def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: detail += f" flags={finding.flags}" if finding.xattr_error: detail += f" xattr_error={finding.xattr_error}" - print(f"WARN {detail}") + lines.append(f"WARN {detail}") + return lines -def print_summary(summary: RepairSummary, *, dry_run: bool) -> None: - print("") - print("Summary:") - print(f" scanned paths: {summary.scanned}") - print(f" scanned files: {summary.scanned_files}") - print(f" scanned directories: {summary.scanned_dirs}") - print(f" skipped: {summary.skipped}") - print(f" unreadable xattrs: {summary.unreadable}") - print(f" not repairable: {summary.not_repairable}") - print(f" repairable: {summary.repairable}") - print(f" permission repairs: {summary.permission_repairable}") +def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: + for line in render_diagnostic_lines(findings, verbose=verbose): + print(line) + + +def render_summary_lines(summary: RepairSummary, *, dry_run: bool) -> list[str]: + lines = [ + "", + "Summary:", + f" scanned paths: {summary.scanned}", + f" scanned files: {summary.scanned_files}", + f" scanned directories: {summary.scanned_dirs}", + f" skipped: {summary.skipped}", + f" unreadable xattrs: {summary.unreadable}", + f" not repairable: {summary.not_repairable}", + f" repairable: {summary.repairable}", + f" permission repairs: {summary.permission_repairable}", + ] if not dry_run: - print(f" repaired: {summary.repaired}") - print(f" failed: {summary.failed}") + lines.extend([ + f" repaired: {summary.repaired}", + f" failed: {summary.failed}", + ]) + return lines + + +def print_summary(summary: RepairSummary, *, dry_run: bool) -> None: + for line in render_summary_lines(summary, dry_run=dry_run): + print(line) def confirm(prompt_text: str) -> bool: return confirm_prompt(prompt_text, default=False, eof_default=False, interrupt_default=False) -def run_repair(args: argparse.Namespace, command_context: CommandContext, config: AppConfig) -> int: +def _emit_lines(emit: Callable[[str], None], lines: list[str]) -> None: + for line in lines: + emit(line) + + +def run_repair_structured( + args: argparse.Namespace, + command_context: CommandContext, + config: AppConfig, + *, + emit_log: Callable[[str], None] | None = None, +) -> RepairRunResult: + def emit(message: str) -> None: + if emit_log is not None: + emit_log(message) + command_context.set_stage("resolve_scan_root") command_context.update_fields( dry_run=args.dry_run, @@ -117,7 +173,7 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config summary = RepairSummary() command_context.update_fields(repair_root=str(root)) command_context.set_stage("scan_findings") - print(f"Scanning {root}") + emit(f"Scanning {root}") try: findings = find_findings( root, @@ -146,61 +202,108 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config ) if not findings: - print("No repairable files found.") - print_summary(summary, dry_run=True) + emit("No repairable files found.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) command_context.succeed() - return 0 + return RepairRunResult(0, root, findings, candidates, summary) command_context.set_stage("report_findings") - print_diagnostics(findings, verbose=args.verbose) + _emit_lines(emit, render_diagnostic_lines(findings, verbose=args.verbose)) if candidates: - print_candidates(candidates, dry_run=args.dry_run) + _emit_lines(emit, render_candidate_lines(candidates, dry_run=args.dry_run)) if args.dry_run: - print_summary(summary, dry_run=True) - print("No changes made.") - command_context.fail_with_error(build_repair_report(findings)) - return 0 + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + emit("No changes made.") + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) if not candidates: - print("No known-safe repairs are available for the detected issues.") - print_summary(summary, dry_run=True) - command_context.fail_with_error(build_repair_report(findings)) - return 1 + emit("No known-safe repairs are available for the detected issues.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) command_context.set_stage("confirm_repair") if not args.yes and not confirm(f"Repair {len(candidates)} paths with known-safe fixes?"): - print("No changes made.") - print_summary(summary, dry_run=True) - command_context.fail_with_error(build_repair_report(findings)) - return 0 + emit("No changes made.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) command_context.set_stage("repair_findings") failed_findings: list[RepairFinding] = [] for finding, candidate in zip(repairs, candidates): - print(f"Repairing: {candidate.path}") + emit(f"Repairing: {candidate.path}") if repair_candidate(candidate): summary.repaired += 1 if ACTION_CLEAR_ARCH_FLAG in candidate.actions: - print(f"PASS xattr now readable: {candidate.path}") + emit(f"PASS xattr now readable: {candidate.path}") if ACTION_FIX_PERMISSIONS in candidate.actions: - print(f"PASS permissions repaired: {candidate.path}") + emit(f"PASS permissions repaired: {candidate.path}") else: summary.failed += 1 failed_findings.append(finding) if ACTION_CLEAR_ARCH_FLAG in candidate.actions: - print(f"FAIL repair did not make xattr readable: {candidate.path}") + emit(f"FAIL repair did not make xattr readable: {candidate.path}") else: - print(f"FAIL repair did not fix detected issue: {candidate.path}") + emit(f"FAIL repair did not fix detected issue: {candidate.path}") unresolved = unresolved_findings_after_success(findings) + failed_findings command_context.update_fields(repaired_count=summary.repaired, repair_failed_count=summary.failed) - print_summary(summary, dry_run=False) + _emit_lines(emit, render_summary_lines(summary, dry_run=False)) if unresolved: - command_context.fail_with_error(build_repair_report(findings, failed=unresolved)) - return 1 + report = build_repair_report(findings, failed=unresolved) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) command_context.succeed() - return 0 + return RepairRunResult(0, root, findings, candidates, summary) + + +def run_repair(args: argparse.Namespace, command_context: CommandContext, config: AppConfig) -> int: + return run_repair_structured(args, command_context, config, emit_log=print).returncode + + +def _repair_result_payload(result: RepairRunResult, context: RepairExecutionContext | CommandContext) -> dict[str, object]: + return repair_xattrs_payload({ + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "stats": jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error if isinstance(context, RepairExecutionContext) else None, + }) + + +def run_repair_json(args: argparse.Namespace, config: AppConfig, sink: EventSink) -> int: + operation = "repair-xattrs" + context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) + stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) + stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + sink.error(operation, message, code="operation_failed") + sink.result(operation, ok=False, payload={"error": message}) + return 1 + finally: + stdout_capture.flush() + stderr_capture.flush() + payload = _repair_result_payload(result, context) + sink.result(operation, ok=result.returncode == 0, payload=payload) + return result.returncode def main(argv: Optional[list[str]] = None) -> int: @@ -216,15 +319,30 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--include-time-machine", action="store_true", help="Include Time Machine and bundle-like paths normally skipped") parser.add_argument("--fix-permissions", action="store_true", help="Also repair missing write permissions on scanned files/directories") parser.add_argument("--verbose", action="store_true", help="Print detailed diagnostics for detected issues") + parser.add_argument("--json", action="store_true", help="Emit app-event NDJSON instead of human-readable output") args = parser.parse_args(argv) if args.dry_run and args.yes: parser.error("--dry-run and --yes are mutually exclusive") + if args.json and not args.dry_run and not args.yes: + parser.error("--json repair requires --yes when not using --dry-run") if args.max_depth is not None and args.max_depth < 0: parser.error("--max-depth must be non-negative") ensure_install_id() config = load_optional_env_config(env_path=args.config) + if args.json: + sink = EventSink(lambda event: print(event.to_json_line(), end="")) + operation = "repair-xattrs" + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + message = "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share." + sink.error(operation, message, code="validation_failed") + sink.result(operation, ok=False, payload={"error": message}) + return 1 + sink.stage(operation, "validate_params") + return run_repair_json(args, config, sink) + telemetry = TelemetryClient.from_config(config) with CommandContext(telemetry, "repair-xattrs", "repair_xattrs_started", "repair_xattrs_finished", config=config, args=args) as command_context: command_context.set_stage("platform_check") diff --git a/src/timecapsulesmb/cli/runtime.py b/src/timecapsulesmb/cli/runtime.py index be49d26..de1fab3 100644 --- a/src/timecapsulesmb/cli/runtime.py +++ b/src/timecapsulesmb/cli/runtime.py @@ -2,6 +2,7 @@ import argparse import json +import math from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional @@ -28,6 +29,9 @@ probe_remote_interface_conn, read_interface_ipv4_addrs_conn, ) +from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC +from timecapsulesmb.services import runtime as service_runtime from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy @@ -54,6 +58,50 @@ def add_config_argument(parser: argparse.ArgumentParser) -> None: ) +def non_negative_int_arg(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if parsed < 0: + raise argparse.ArgumentTypeError("must be 0 or greater") + return parsed + + +def non_negative_float_arg(value: str) -> float: + try: + parsed = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a number") from exc + if not math.isfinite(parsed) or parsed < 0: + raise argparse.ArgumentTypeError("must be 0 or greater") + return parsed + + +def add_mount_wait_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--mount-wait", + type=non_negative_int_arg, + default=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + metavar="SECONDS", + help=f"Seconds for diskd.useVolume mount guards to wait before their manual fallback (default: {DEFAULT_APPLE_MOUNT_WAIT_SECONDS})", + ) + + +def add_no_wait_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--no-wait", action="store_true", help="Do not wait for the device to go down and come back after reboot") + + +def add_bonjour_timeout_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--bonjour-timeout", + type=non_negative_float_arg, + default=DEFAULT_BROWSE_TIMEOUT_SEC, + metavar="SECONDS", + help=f"Bonjour browse time in seconds (default: {DEFAULT_BROWSE_TIMEOUT_SEC:g})", + ) + + def config_path_from_args(args: argparse.Namespace) -> Path | None: return getattr(args, "config", None) @@ -128,8 +176,7 @@ def load_config_from_args( def load_env_config(*, env_path: Path | None = None, defaults: dict[str, str] | None = None) -> AppConfig: - resolved_path = resolve_app_paths(config_path=env_path).config_path - return load_app_config(resolved_path, defaults=defaults) + return service_runtime.load_env_config(env_path=env_path, defaults=defaults, resolve_paths=resolve_app_paths) def load_optional_env_config( @@ -137,16 +184,7 @@ def load_optional_env_config( env_path: Path | None = None, defaults: dict[str, str] | None = None, ) -> AppConfig: - try: - resolved_path = resolve_app_paths(config_path=env_path).config_path - except Exception: - return AppConfig.missing(path=env_path or Path.cwd() / ".env") - if not resolved_path.exists(): - return AppConfig.missing(path=resolved_path) - try: - return load_app_config(resolved_path, defaults=defaults) - except OSError: - return AppConfig.missing(path=resolved_path) + return service_runtime.load_optional_env_config(env_path=env_path, defaults=defaults, resolve_paths=resolve_app_paths) def resolve_ssh_credentials( @@ -154,12 +192,7 @@ def resolve_ssh_credentials( *, allow_empty_password: bool = False, ) -> tuple[str, str]: - host = config.require("TC_HOST") - password = config.get("TC_PASSWORD") - if not password and not allow_empty_password: - import getpass - password = getpass.getpass("Device root password: ") - return host, password + return service_runtime.resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) def resolve_env_connection( @@ -168,10 +201,11 @@ def resolve_env_connection( required_keys: tuple[str, ...] = (), allow_empty_password: bool = False, ) -> SshConnection: - for key in required_keys: - config.require(key) - host, password = resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) - return SshConnection(host=host, password=password, ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + return service_runtime.resolve_env_connection( + config, + required_keys=required_keys, + allow_empty_password=allow_empty_password, + ) def inspect_managed_connection( @@ -180,9 +214,7 @@ def inspect_managed_connection( *, include_probe: bool = False, ) -> ManagedTargetState: - interface_probe = probe_remote_interface_conn(connection, iface) - probe_state = probe_connection_state(connection) if include_probe else None - return ManagedTargetState(connection=connection, interface_probe=interface_probe, probe_state=probe_state) + return service_runtime.inspect_managed_connection(connection, iface, include_probe=include_probe) def ssh_target_link_local_resolution_error( @@ -191,20 +223,7 @@ def ssh_target_link_local_resolution_error( *, field_name: str = "Device SSH target", ) -> str | None: - if ssh_opts_use_proxy(ssh_opts): - return None - host = extract_host(target).strip() - if not host or ipv4_literal(host) is not None: - return None - link_local_ips = tuple(ip for ip in resolve_host_ipv4s(host) if is_link_local_ipv4(ip)) - if not link_local_ips: - return None - noun = "address" if len(link_local_ips) == 1 else "addresses" - return ( - f"{field_name} host {host} resolves to 169.254.x.x link-local IPv4 {noun} " - f"{', '.join(link_local_ips)}. Use the device's LAN IP or a hostname that resolves " - "to its LAN IP; 169.254.x.x is only suitable for temporary SSH recovery." - ) + return service_runtime.ssh_target_link_local_resolution_error(target, ssh_opts, field_name=field_name) def resolve_validated_managed_target( @@ -214,27 +233,16 @@ def resolve_validated_managed_target( profile: str, include_probe: bool = False, ) -> ManagedTargetState: - require_valid_app_config(config, profile=profile, command_name=command_name) - resolution_error = ssh_target_link_local_resolution_error( - config.require("TC_HOST"), - config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), - field_name="TC_HOST", + return service_runtime.resolve_validated_managed_target( + config, + command_name=command_name, + profile=profile, + include_probe=include_probe, ) - if resolution_error is not None: - raise ConfigError(resolution_error) - connection = resolve_env_connection(config) - if profile == "flash": - return ManagedTargetState(connection=connection, interface_probe=None, probe_state=None) - probe_state = probe_connection_state(connection) if include_probe else None - return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) def require_connection_compatibility(connection: SshConnection) -> DeviceCompatibility: - state = probe_connection_state(connection) - return require_compatibility( - state.compatibility, - fallback_error=state.probe_result.error or "Failed to determine remote device OS compatibility.", - ) + return service_runtime.require_connection_compatibility(connection) def require_supported_device_compatibility( diff --git a/src/timecapsulesmb/cli/set_ssh.py b/src/timecapsulesmb/cli/set_ssh.py index f6ba21b..dd95086 100644 --- a/src/timecapsulesmb/cli/set_ssh.py +++ b/src/timecapsulesmb/cli/set_ssh.py @@ -1,11 +1,12 @@ from __future__ import annotations import argparse +from enum import Enum from typing import Optional from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import wait_for_device_up, wait_for_tcp_port_state -from timecapsulesmb.cli.runtime import LogCallback, add_config_argument, confirm, emit_progress, load_env_config +from timecapsulesmb.cli.runtime import LogCallback, add_config_argument, add_no_wait_argument, confirm, emit_progress, load_env_config from timecapsulesmb.cli.util import color_red from timecapsulesmb.core.config import ConfigError from timecapsulesmb.core.net import extract_host @@ -28,6 +29,22 @@ def _looks_like_ssh_auth_failure(output: str) -> bool: return "permission denied" in lowered or "please try again" in lowered +class SetSshAction(Enum): + ENABLE = "enable_ssh" + ENABLE_NOOP = "enable_noop" + DISABLE = "disable_ssh" + DISABLE_NOOP = "disable_noop" + PROMPT_DISABLE = "prompt_disable_ssh" + + +def select_set_ssh_action(*, explicit_enable: bool, explicit_disable: bool, ssh_open: bool) -> SetSshAction: + if explicit_enable: + return SetSshAction.ENABLE_NOOP if ssh_open else SetSshAction.ENABLE + if explicit_disable: + return SetSshAction.DISABLE if ssh_open else SetSshAction.DISABLE_NOOP + return SetSshAction.PROMPT_DISABLE if ssh_open else SetSshAction.ENABLE + + def disable_ssh_over_ssh( connection: SshConnection, *, @@ -67,32 +84,62 @@ def disable_ssh_over_ssh( def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Use the configured device target from .env to enable SSH via ACP or disable SSH over SSH.") add_config_argument(parser) + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument("--enable", action="store_true", help="Enable SSH via ACP if it is not already reachable") + mode_group.add_argument("--disable", action="store_true", help="Disable SSH over SSH if it is currently reachable") + mode_group.add_argument("--status", action="store_true", help="Report whether SSH is reachable without changing device state") + parser.add_argument("--yes", action="store_true", help="Skip the legacy prompt when SSH is already enabled") + add_no_wait_argument(parser) args = parser.parse_args(argv) + if args.status and args.no_wait: + parser.error("--no-wait is not valid with --status") + ensure_install_id() config = load_env_config(env_path=args.config, defaults={}) telemetry = TelemetryClient.from_config(config) with CommandContext(telemetry, "set-ssh", "set_ssh_started", "set_ssh_finished", config=config, args=args) as command_context: command_context.set_stage("load_config") try: - command_context.require_valid_config(profile="set_ssh") + command_context.require_valid_config(profile="set_ssh_status" if args.status else "set_ssh") except ConfigError as exc: message = str(exc) or f"Missing {config.path} settings. Run '.venv/bin/tcapsule configure' first." command_context.update_fields(set_ssh_action="missing_config") print(message) command_context.fail_with_error(message) return 1 - connection = command_context.resolve_env_connection() - acp_host = extract_host(connection.host) - password = connection.password + connection = None if args.status else command_context.resolve_env_connection() + target_host = config.require("TC_HOST") if args.status else connection.host + acp_host = extract_host(target_host) + password = "" if connection is None else connection.password - print(f"Using configured target from {config.path}: {connection.host}") + print(f"Using configured target from {config.path}: {target_host}") print(f"Probing SSH on {acp_host}:22 ...") command_context.set_stage("probe_ssh") ssh_open = tcp_open(acp_host, 22) command_context.update_fields(ssh_initially_reachable=ssh_open) - if not ssh_open: - command_context.update_fields(set_ssh_action="enable_ssh") + + if args.status: + command_context.update_fields(set_ssh_action="status", ssh_final_reachable=ssh_open) + print("SSH enabled." if ssh_open else "SSH disabled.") + command_context.succeed() + return 0 + + assert connection is not None + action = select_set_ssh_action( + explicit_enable=args.enable, + explicit_disable=args.disable, + ssh_open=ssh_open, + ) + + if action is SetSshAction.ENABLE_NOOP: + command_context.update_fields(set_ssh_action=action.value, ssh_final_reachable=True) + print("SSH already enabled.") + command_context.succeed() + return 0 + + if action is SetSshAction.ENABLE: + command_context.update_fields(set_ssh_action=action.value) print("SSH not reachable. Attempting to enable via ACP...") try: command_context.set_stage("enable_ssh") @@ -105,77 +152,99 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.fail_with_error(message) return 1 + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH enable requested; not waiting for SSH to open.") + command_context.succeed() + return 0 + command_context.set_stage("wait_for_ssh_enabled") if not wait_for_tcp_port_state(acp_host, 22, expected_state=True, service_name="SSH port"): command_context.update_fields(ssh_final_reachable=False) command_context.fail_with_error("SSH did not open after enabling via ACP.") return 1 command_context.update_fields(ssh_final_reachable=True) - else: + + print("SSH is configured. You can connect as 'root' using the AirPort admin password.") + command_context.succeed() + return 0 + + if action is SetSshAction.DISABLE_NOOP: + command_context.update_fields(set_ssh_action=action.value, ssh_final_reachable=False) + print("SSH already disabled.") + command_context.succeed() + return 0 + + if action is SetSshAction.PROMPT_DISABLE: command_context.set_stage("prompt_disable_ssh") - should_disable = confirm( - "SSH already enabled. Disable?", - default=False, - eof_default=False, - interrupt_default=False, - ) - if not should_disable: + if not args.yes: + confirmed = confirm( + "SSH already enabled. Disable?", + default=False, + eof_default=False, + interrupt_default=False, + ) + else: + confirmed = True + if not confirmed: command_context.update_fields(set_ssh_action="leave_enabled", ssh_final_reachable=True) print("Leaving SSH enabled.") + command_context.succeed() + return 0 + action = SetSshAction.DISABLE - if should_disable: - command_context.update_fields(set_ssh_action="disable_ssh") - try: - command_context.set_stage("disable_ssh") - disable_ssh_over_ssh(connection, reboot_device=True, log=print) - except Exception as e: - error_text = str(e) - message = f"Failed to disable SSH over SSH: {error_text}" - print(color_red("Failed to disable SSH over SSH:")) - print(error_text) - command_context.fail_with_error(message) - return 1 - - print("Device is starting reboot now, waiting for it to shut down...") - command_context.set_stage("wait_for_ssh_down") - if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): - message = "SSH did not close after disable/reboot request; disable could not be verified." - command_context.update_fields( - ssh_final_reachable=True, - ssh_disable_persisted=False, - ssh_reboot_observed_down=False, - ) - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - print("Device is down now, verifying persistence after reboot...") - command_context.update_fields(ssh_reboot_observed_down=True) - command_context.set_stage("wait_for_device_up") - if not wait_for_device_up(acp_host): - message = "Device went down after disable request but did not come back within timeout." - command_context.update_fields(device_recovered=False) - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - command_context.update_fields(device_recovered=True) - print("Device successfully rebooted. Checking if SSH is still disabled...") - command_context.set_stage("verify_ssh_disabled") - if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, timeout_seconds=30, service_name="SSH port"): - command_context.update_fields(ssh_final_reachable=True, ssh_disable_persisted=False) - message = "SSH reopened after reboot. Disable did not persist." - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - else: - command_context.update_fields(ssh_final_reachable=False, ssh_disable_persisted=True) - print("SSH disabled (remains closed after reboot). Enable SSH again if this was not intended.") - command_context.succeed() - return 0 - - print("SSH is configured. You can connect as 'root' using the AirPort admin password.") + command_context.update_fields(set_ssh_action=action.value) + try: + command_context.set_stage("disable_ssh") + disable_ssh_over_ssh(connection, reboot_device=True, log=print) + except Exception as e: + error_text = str(e) + message = f"Failed to disable SSH over SSH: {error_text}" + print(color_red("Failed to disable SSH over SSH:")) + print(error_text) + command_context.fail_with_error(message) + return 1 + + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH disable requested; not waiting for reboot or verifying SSH stays closed.") + command_context.succeed() + return 0 + + print("Device is starting reboot now, waiting for it to shut down...") + command_context.set_stage("wait_for_ssh_down") + if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): + message = "SSH did not close after disable/reboot request; disable could not be verified." + command_context.update_fields( + ssh_final_reachable=True, + ssh_disable_persisted=False, + ssh_reboot_observed_down=False, + ) + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + print("Device is down now, verifying persistence after reboot...") + command_context.update_fields(ssh_reboot_observed_down=True) + command_context.set_stage("wait_for_device_up") + if not wait_for_device_up(acp_host): + message = "Device went down after disable request but did not come back within timeout." + command_context.update_fields(device_recovered=False) + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + command_context.update_fields(device_recovered=True) + print("Device successfully rebooted. Checking if SSH is still disabled...") + command_context.set_stage("verify_ssh_disabled") + if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, timeout_seconds=30, service_name="SSH port"): + command_context.update_fields(ssh_final_reachable=True, ssh_disable_persisted=False) + message = "SSH reopened after reboot. Disable did not persist." + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + command_context.update_fields(ssh_final_reachable=False, ssh_disable_persisted=True) + print("SSH disabled (remains closed after reboot). Enable SSH again if this was not intended.") command_context.succeed() return 0 - return 1 diff --git a/src/timecapsulesmb/cli/uninstall.py b/src/timecapsulesmb/cli/uninstall.py index 771d5cc..01ca476 100644 --- a/src/timecapsulesmb/cli/uninstall.py +++ b/src/timecapsulesmb/cli/uninstall.py @@ -4,27 +4,24 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import request_reboot_and_wait -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json +from timecapsulesmb.cli.flows import request_reboot, request_reboot_and_wait +from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config, print_json from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME from timecapsulesmb.deploy.dry_run import format_uninstall_plan, uninstall_plan_to_jsonable from timecapsulesmb.deploy.executor import remote_uninstall_payload -from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS, build_uninstall_plan +from timecapsulesmb.deploy.planner import build_uninstall_plan from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall from timecapsulesmb.device.storage import UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.maintenance import UNINSTALL_REBOOT_NO_DOWN_MESSAGE as REBOOT_NO_DOWN_MESSAGE from timecapsulesmb.telemetry import TelemetryClient -REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." -) - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Remove the managed TimeCapsuleSMB payload from the configured device.") add_config_argument(parser) + add_mount_wait_argument(parser) + add_no_wait_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before reboot") parser.add_argument("--no-reboot", action="store_true", help="Remove files but do not reboot the device") parser.add_argument("--dry-run", action="store_true", help="Print actions without making changes") @@ -59,7 +56,7 @@ def main(argv: Optional[list[str]] = None) -> int: else: mounted_volumes = command_context.mount_mast_volumes( connection, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=args.mount_wait, ) volume_roots = [volume.volume_root for volume in mounted_volumes] payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] @@ -105,6 +102,13 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 + if args.no_wait: + request_reboot(connection, command_context, raise_on_request_error=True) + print("Reboot requested; not waiting for the device to go down or come back.") + print("Post-uninstall verification skipped.") + command_context.succeed() + return 0 + if not request_reboot_and_wait( connection, command_context, diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 1d23bda..ec4941f 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -75,6 +75,7 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_SSH_OPTS": "-o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa -o KexAlgorithms=+diffie-hellman-group14-sha1 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", "TC_INTERNAL_SHARE_USE_DISK_ROOT": "false", "TC_ANY_PROTOCOL": "false", + "TC_DEBUG_LOGGING": "false", } ENV_FILE_KEYS = [ @@ -83,8 +84,22 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_SSH_OPTS", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", "TC_CONFIGURE_ID", ] +ENV_FILE_OMIT_KEYS = frozenset({ + # Runtime-derived/deprecated naming keys may still exist in older .env + # files, but new configure writes should not keep them alive. + "TC_AIRPORT_SYAP", + "TC_MDNS_DEVICE_MODEL", + "TC_MDNS_HOST_LABEL", + "TC_MDNS_INSTANCE_NAME", + "TC_NETBIOS_NAME", + "TC_NET_IFACE", + "NET_IPV4_HINT", + "TC_SAMBA_USER", + "TC_PAYLOAD_DIR_NAME", +}) CONFIG_HEADER = """# Local user/device configuration for TimeCapsuleSMB. # Generated by tcapsule configure @@ -496,6 +511,7 @@ def validate_mdns_device_model_matches_syap(syap: str, device_model: str) -> Opt "TC_MDNS_DEVICE_MODEL": validate_mdns_device_model, "TC_INTERNAL_SHARE_USE_DISK_ROOT": validate_bool, "TC_ANY_PROTOCOL": validate_bool, + "TC_DEBUG_LOGGING": validate_bool, } @@ -511,11 +527,13 @@ class ConfigProfile: CONFIGURE_VALIDATED_KEYS = ( "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", ) MANAGED_VALIDATED_KEYS = ( "TC_HOST", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", ) MANAGED_REQUIRED_FILE_KEYS = ( "TC_HOST", @@ -557,6 +575,10 @@ class ConfigProfile: required_file_values=("TC_HOST", "TC_PASSWORD"), validated_keys=("TC_HOST",), ), + "set_ssh_status": ConfigProfile( + required_file_values=("TC_HOST",), + validated_keys=("TC_HOST",), + ), "flash": ConfigProfile( required_file_values=FLASH_REQUIRED_FILE_KEYS, validated_keys=FLASH_VALIDATED_KEYS, @@ -640,9 +662,19 @@ def render_env_text(values: dict[str, str]) -> str: for key in ENV_FILE_KEYS: rendered_value = values.get(key, DEFAULTS.get(key, "")) lines.append(f"{key}={shell_quote(rendered_value)}") + extra_keys = sorted(key for key in values if key not in ENV_FILE_KEYS and key not in ENV_FILE_OMIT_KEYS) + if extra_keys: + lines.append("") + lines.append("# Preserved custom settings") + for key in extra_keys: + lines.append(f"{key}={shell_quote(values[key])}") lines.append("") return "\n".join(lines) +def preserved_env_file_values(values: dict[str, str]) -> dict[str, str]: + return {key: value for key, value in values.items() if key not in ENV_FILE_OMIT_KEYS} + + def write_env_file(path: Path, values: dict[str, str]) -> None: path.write_text(render_env_text(values)) diff --git a/src/timecapsulesmb/deploy/dry_run.py b/src/timecapsulesmb/deploy/dry_run.py index 5689c38..e91c4bf 100644 --- a/src/timecapsulesmb/deploy/dry_run.py +++ b/src/timecapsulesmb/deploy/dry_run.py @@ -88,6 +88,12 @@ def deployment_plan_to_jsonable(plan: DeploymentPlan) -> dict[str, object]: return data +def activation_plan_to_jsonable(plan: ActivationPlan) -> dict[str, object]: + data = asdict(plan) + data["actions"] = remote_actions_to_jsonable(plan.actions) + return data + + def format_activation_plan(plan: ActivationPlan, *, device_name: str = "AirPort storage device") -> str: lines: list[str] = [] lines.append("Dry run: NetBSD4 activation plan") diff --git a/src/timecapsulesmb/discovery/devices.py b/src/timecapsulesmb/discovery/devices.py new file mode 100644 index 0000000..967f802 --- /dev/null +++ b/src/timecapsulesmb/discovery/devices.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass +from typing import Iterable + +from timecapsulesmb.core.net import is_link_local_ipv4 +from timecapsulesmb.discovery.bonjour import ( + AIRPORT_SERVICE, + BonjourResolvedService, + discovered_record_root_host, + discovery_record_to_jsonable, + record_has_service, +) + + +@dataclass(frozen=True) +class DiscoveredDeviceCandidate: + id: str + name: str + host: str + ssh_host: str | None + hostname: str + addresses: tuple[str, ...] + ipv4: tuple[str, ...] + ipv6: tuple[str, ...] + preferred_ipv4: str | None + link_local_only: bool + syap: str | None + model: str | None + service_type: str + fullname: str + selected_record: BonjourResolvedService + + +def device_candidates_from_records( + records: Iterable[BonjourResolvedService], + *, + airport_only: bool = True, +) -> list[DiscoveredDeviceCandidate]: + materialized = list(records) + source_records = [record for record in materialized if record_has_service(record, AIRPORT_SERVICE)] + if not airport_only and not source_records: + source_records = materialized + candidates = [ + _candidate_from_record(record, index) + for index, record in enumerate(source_records) + ] + by_key: dict[str, DiscoveredDeviceCandidate] = {} + for candidate in candidates: + key = _dedupe_key(candidate) + existing = by_key.get(key) + if existing is None or _candidate_score(candidate) > _candidate_score(existing): + by_key[key] = candidate + return sorted(by_key.values(), key=lambda candidate: (candidate.name.casefold(), candidate.host.casefold(), candidate.id)) + + +def device_candidate_to_jsonable(candidate: DiscoveredDeviceCandidate) -> dict[str, object]: + return { + "id": candidate.id, + "name": candidate.name, + "host": candidate.host, + "ssh_host": candidate.ssh_host, + "hostname": candidate.hostname, + "addresses": list(candidate.addresses), + "ipv4": list(candidate.ipv4), + "ipv6": list(candidate.ipv6), + "preferred_ipv4": candidate.preferred_ipv4, + "link_local_only": candidate.link_local_only, + "syap": candidate.syap, + "model": candidate.model, + "service_type": candidate.service_type, + "fullname": candidate.fullname, + "selected_record": discovery_record_to_jsonable(candidate.selected_record), + } + + +def _candidate_from_record(record: BonjourResolvedService, index: int) -> DiscoveredDeviceCandidate: + preferred_ipv4 = _first_non_link_local_ipv4(record.ipv4) + ssh_host = discovered_record_root_host(record) + host = _host_from_ssh_host(ssh_host) or record.hostname or _first_value(record.ipv6) or "" + name = record.name or record.hostname or host or "AirPort Device" + fullname = record.fullname or "" + return DiscoveredDeviceCandidate( + id=_candidate_id(record, host=host, index=index), + name=name, + host=host, + ssh_host=ssh_host, + hostname=record.hostname or "", + addresses=tuple([*record.ipv4, *record.ipv6]), + ipv4=tuple(record.ipv4), + ipv6=tuple(record.ipv6), + preferred_ipv4=preferred_ipv4, + link_local_only=bool(record.ipv4) and preferred_ipv4 is None, + syap=_non_empty(record.properties.get("syAP") or record.properties.get("syap")), + model=_non_empty(record.properties.get("model") or record.properties.get("am")), + service_type=record.service_type or "", + fullname=fullname, + selected_record=record, + ) + + +def _candidate_score(candidate: DiscoveredDeviceCandidate) -> tuple[int, int, int, int]: + return ( + 1 if candidate.preferred_ipv4 else 0, + 1 if candidate.ssh_host else 0, + 1 if candidate.syap else 0, + len(candidate.addresses), + ) + + +def _candidate_id(record: BonjourResolvedService, *, host: str, index: int) -> str: + for prefix, value in ( + ("bonjour", record.fullname), + ("hostname", record.hostname), + ("host", host), + ("name", record.name), + ): + normalized = _normalize(value) + if normalized: + return f"{prefix}:{normalized}" + return f"discovered:{index}" + + +def _dedupe_key(candidate: DiscoveredDeviceCandidate) -> str: + for prefix, value in ( + ("bonjour", candidate.fullname), + ("hostname", candidate.hostname), + ("host", candidate.host), + ("name", candidate.name), + ): + normalized = _normalize(value) + if normalized: + return f"{prefix}:{normalized}" + return candidate.id + + +def _first_non_link_local_ipv4(values: Iterable[str]) -> str | None: + for value in values: + if not value or is_link_local_ipv4(value): + continue + try: + if ipaddress.ip_address(value).version == 4: + return value + except ValueError: + continue + return None + + +def _host_from_ssh_host(value: str | None) -> str: + if not value: + return "" + return value.removeprefix("root@") + + +def _first_value(values: Iterable[str]) -> str: + for value in values: + if value: + return value + return "" + + +def _normalize(value: str | None) -> str: + return (value or "").strip().rstrip(".").casefold() + + +def _non_empty(value: str | None) -> str | None: + stripped = (value or "").strip() + return stripped or None diff --git a/src/timecapsulesmb/services/__init__.py b/src/timecapsulesmb/services/__init__.py new file mode 100644 index 0000000..da30ee9 --- /dev/null +++ b/src/timecapsulesmb/services/__init__.py @@ -0,0 +1,2 @@ +"""Non-interactive service helpers shared by CLI and app adapters.""" + diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py new file mode 100644 index 0000000..6ed5b10 --- /dev/null +++ b/src/timecapsulesmb/services/app.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, is_dataclass +from enum import Enum +import math +from pathlib import Path + + +class AppOperationError(RuntimeError): + def __init__( + self, + message: str, + *, + code: str = "operation_failed", + debug: object | None = None, + recovery: object | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.debug = debug + self.recovery = recovery + + +@dataclass(frozen=True) +class OperationResult: + ok: bool + payload: object | None = None + + +def jsonable(value: object) -> object: + if is_dataclass(value): + return jsonable(asdict(value)) + if isinstance(value, Enum): + return jsonable(value.value) + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(key): jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [jsonable(item) for item in value] + return value + + +def config_path(params: dict[str, object]) -> Path | None: + value = params.get("config") + if value in (None, ""): + return None + return Path(str(value)) + + +def bool_param(params: dict[str, object], name: str, default: bool = False) -> bool: + value = params.get(name, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y"}: + return True + if normalized in {"0", "false", "no", "n"}: + return False + raise AppOperationError(f"{name} must be a boolean", code="validation_failed") + + +def confirm_param(params: dict[str, object], name: str) -> bool: + if name in params: + return bool_param(params, name) + return bool_param(params, "yes") + + +def int_param(params: dict[str, object], name: str, default: int) -> int: + value = params.get(name, default) + if isinstance(value, bool): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") + if isinstance(value, float): + if not math.isfinite(value) or not value.is_integer(): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") + parsed = int(value) + else: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") + return parsed + + +def _parse_optional_int_value(value: object, name: str) -> int: + if isinstance(value, bool): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") + if isinstance(value, float): + if not math.isfinite(value) or not value.is_integer(): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") + parsed = int(value) + else: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") + return parsed + + +def optional_int_param(params: dict[str, object], name: str) -> int | None: + value = params.get(name) + if value in (None, ""): + return None + return _parse_optional_int_value(value, name) + + +def float_param(params: dict[str, object], name: str, default: float) -> float: + value = params.get(name, default) + if isinstance(value, bool): + raise AppOperationError(f"{name} must be a number", code="validation_failed") + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be a number", code="validation_failed") from exc + if not math.isfinite(parsed): + raise AppOperationError(f"{name} must be finite", code="validation_failed") + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater", code="validation_failed") + return parsed + + +def string_param(params: dict[str, object], name: str, default: str = "") -> str: + value = params.get(name, default) + return "" if value is None else str(value) + + +def require_string_param(params: dict[str, object], name: str) -> str: + value = string_param(params, name).strip() + if not value: + raise AppOperationError(f"missing required parameter: {name}", code="validation_failed") + return value + + +def required_path_param(params: dict[str, object], name: str) -> Path: + value = params.get(name) + if value is None: + raise AppOperationError(f"missing required parameter: {name}", code="validation_failed") + if isinstance(value, Path): + path_text = str(value).strip() + elif isinstance(value, str): + path_text = value.strip() + else: + raise AppOperationError(f"{name} must be a path string", code="validation_failed") + if not path_text: + raise AppOperationError(f"missing required parameter: {name}", code="validation_failed") + return Path(path_text) diff --git a/src/timecapsulesmb/services/config_store.py b/src/timecapsulesmb/services/config_store.py new file mode 100644 index 0000000..8aaaebc --- /dev/null +++ b/src/timecapsulesmb/services/config_store.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Protocol + +from timecapsulesmb.core.config import AppConfig, load_app_config, write_env_file + + +class ConfigStore(Protocol): + def load(self, path: Path, *, defaults: dict[str, str] | None = None) -> AppConfig: + ... + + def save(self, path: Path, values: Mapping[str, str]) -> None: + ... + + +@dataclass(frozen=True) +class EnvFileConfigStore: + omit_keys: frozenset[str] = frozenset() + + def load(self, path: Path, *, defaults: dict[str, str] | None = None) -> AppConfig: + return load_app_config(path, defaults=defaults) + + def save(self, path: Path, values: Mapping[str, str]) -> None: + filtered = { + key: value + for key, value in values.items() + if key not in self.omit_keys + } + write_env_file(path, filtered) diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py new file mode 100644 index 0000000..db65261 --- /dev/null +++ b/src/timecapsulesmb/services/configure.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from timecapsulesmb.core.config import DEFAULTS, parse_bool, preserved_env_file_values + + +def build_configure_env_values( + existing: dict[str, str], + *, + host: str, + password: str, + ssh_opts: str, + configure_id: str, + internal_share_use_disk_root: bool | None = None, + any_protocol: bool | None = None, + debug_logging: bool | None = None, +) -> dict[str, str]: + values = preserved_env_file_values(existing) + values.update({ + "TC_HOST": host, + "TC_PASSWORD": password, + "TC_SSH_OPTS": ssh_opts, + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if ( + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])) + if internal_share_use_disk_root is None + else internal_share_use_disk_root + ) else "false", + "TC_ANY_PROTOCOL": "true" if ( + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])) + if any_protocol is None + else any_protocol + ) else "false", + "TC_DEBUG_LOGGING": "true" if ( + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])) + if debug_logging is None + else debug_logging + ) else "false", + "TC_CONFIGURE_ID": configure_id, + }) + return values diff --git a/src/timecapsulesmb/services/credentials.py b/src/timecapsulesmb/services/credentials.py new file mode 100644 index 0000000..d1c214c --- /dev/null +++ b/src/timecapsulesmb/services/credentials.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Mapping + +from timecapsulesmb.core.config import AppConfig + + +def request_password(params: Mapping[str, object]) -> str: + value = params.get("password") + if isinstance(value, str) and value: + return value + credentials = params.get("credentials") + if isinstance(credentials, Mapping): + nested = credentials.get("password") + if isinstance(nested, str) and nested: + return nested + return "" + + +def overlay_request_credentials(config: AppConfig, params: Mapping[str, object]) -> AppConfig: + password = request_password(params) + if not password: + return config + values = dict(config.values) + values["TC_PASSWORD"] = password + return AppConfig.from_values( + values, + path=config.path, + exists=config.exists, + file_values=config.file_values, + ) diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py new file mode 100644 index 0000000..129b3ec --- /dev/null +++ b/src/timecapsulesmb/services/deploy.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from timecapsulesmb.core.config import DEFAULTS, AppConfig, parse_bool, shell_quote +from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG +from timecapsulesmb.deploy.planner import DEFAULT_ATA_IDLE_SECONDS, DEFAULT_DISKD_USE_VOLUME_ATTEMPTS +from timecapsulesmb.device.storage import PayloadHome, PayloadVerificationResult + + +DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." +DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." +) + + +def no_mast_volumes_message(*, attempts: int, delay_seconds: int) -> str: + return ( + f"No deployable HFS disk was found after {attempts} MaSt queries " + f"spaced {delay_seconds} seconds apart." + ) + + +def no_writable_mast_volumes_message(volume_count: int) -> str: + return f"MaSt found {volume_count} deployable HFS volume(s), but deploy could not write to any of them." + + +def payload_verification_error(payload_home: PayloadHome, result: PayloadVerificationResult) -> str: + return f"managed payload verification failed at {payload_home.payload_dir}: {result.detail}" + + +def _render_flash_config_assignment(key: str, value: str | int) -> str: + if isinstance(value, int): + return f"{key}={value}" + return f"{key}={shell_quote(value)}" + + +def render_flash_runtime_config( + config: AppConfig, + payload_home: PayloadHome, + *, + nbns_enabled: bool, + debug_logging: bool, + ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, + diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, +) -> str: + internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) + any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) + configured_debug_logging = config.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + effective_debug_logging = debug_logging or parse_bool(configured_debug_logging) + + values: list[tuple[str, str | int]] = [ + ("TC_CONFIG_VERSION", 2), + ("TC_DEPLOY_RELEASE_TAG", RELEASE_TAG), + ("TC_DEPLOY_CLI_VERSION_CODE", CLI_VERSION_CODE), + ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if parse_bool(internal_root_default) else 0), + ("ANY_PROTOCOL", 1 if parse_bool(any_protocol_default) else 0), + ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), + ("ATA_IDLE_SECONDS", ata_idle_seconds), + ("NBNS_ENABLED", 1 if nbns_enabled else 0), + ("SMBD_DEBUG_LOGGING", 1 if effective_debug_logging else 0), + ("MDNS_DEBUG_LOGGING", 1 if effective_debug_logging else 0), + ] + return "\n".join(_render_flash_config_assignment(key, value) for key, value in values) + "\n" diff --git a/src/timecapsulesmb/services/doctor.py b/src/timecapsulesmb/services/doctor.py new file mode 100644 index 0000000..992db56 --- /dev/null +++ b/src/timecapsulesmb/services/doctor.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping + +from timecapsulesmb.checks.models import CheckResult + + +BONJOUR_INSTANCE_FAILURE_PREFIX = "no discovered _smb._tcp instance matched" + + +def doctor_status_counts(results: list[CheckResult]) -> dict[str, int]: + return { + status: sum(1 for result in results if result.status == status) + for status in ("PASS", "WARN", "FAIL", "INFO") + } + + +def _mapping_value(value: object, key: str) -> object | None: + if isinstance(value, Mapping): + return value.get(key) + return None + + +def _as_int(value: object) -> int | None: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _as_sequence(value: object) -> list[object]: + if isinstance(value, list): + return list(value) + if isinstance(value, tuple): + return list(value) + return [] + + +def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | None: + for result in results: + if result.status != "FAIL" or BONJOUR_INSTANCE_FAILURE_PREFIX not in result.message: + continue + match = re.search( + r"expected (?:device |configured )?instance (?P['\"])(?P.*?)(?P=quote)", + result.message, + ) + if match: + return match.group("name") + return None + + +def _debug_bonjour_expected_instance(debug_fields: Mapping[str, object]) -> str | None: + expected = _mapping_value(debug_fields, "bonjour_expected") + value = _mapping_value(expected, "instance_name") + return value if isinstance(value, str) and value else None + + +def _bonjour_failure_uses_instance_match(results: list[CheckResult]) -> bool: + return any(result.status == "FAIL" and BONJOUR_INSTANCE_FAILURE_PREFIX in result.message for result in results) + + +def _native_dns_sd_smb_names(debug_fields: Mapping[str, object]) -> list[str]: + native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") + names: list[str] = [] + for browse in _as_sequence(_mapping_value(native_dns_sd, "browses")): + browse_type = str(_mapping_value(browse, "service_type") or "") + for event in _as_sequence(_mapping_value(browse, "events")): + event_type = str(_mapping_value(event, "service_type") or browse_type) + if not event_type.rstrip(".").startswith("_smb._tcp"): + continue + if str(_mapping_value(event, "action") or "").lower() != "add": + continue + name = _mapping_value(event, "name") + if isinstance(name, str) and name and name not in names: + names.append(name) + return names + + +def build_discovery_context(results: list[CheckResult], debug_fields: Mapping[str, object]) -> list[str]: + if not _bonjour_failure_uses_instance_match(results): + return [] + + zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") + zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) + if zeroconf_instance_count != 0: + return [] + + native_smb_names = _native_dns_sd_smb_names(debug_fields) + expected_instance = _debug_bonjour_expected_instance(debug_fields) or _expected_bonjour_instance_from_results(results) + native_saw_expected = expected_instance is not None and expected_instance in native_smb_names + if not native_saw_expected: + return [] + + return [ + "INFO Python zeroconf discovered 0 Bonjour instances during doctor", + f"INFO native dns-sd discovered expected _smb._tcp instance {expected_instance!r}", + ( + "INFO likely doctor false negative: native macOS mDNS saw the expected service " + "but Python zeroconf did not receive browse events" + ), + ] + + +def _last_regex_group(pattern: str, text: str) -> str | None: + matches = list(re.finditer(pattern, text)) + if not matches: + return None + match = matches[-1] + return match.group(1) if match.groups() else match.group(0) + + +def _extract_generated_service_types(mdns_log: str) -> list[str]: + service_types: list[str] = [] + for match in re.finditer(r"serving service: type=([^ ]+)", mdns_log): + service_type = match.group(1) + if service_type not in service_types: + service_types.append(service_type) + return service_types + + +def build_mdns_boot_context(debug_fields: Mapping[str, object]) -> list[str]: + rc_log = _mapping_value(debug_fields, "remote_rc_local_log_tail") + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + rc_text = rc_log if isinstance(rc_log, str) else "" + mdns_text = mdns_log if isinstance(mdns_log, str) else "" + combined = f"{rc_text}\n{mdns_text}" + if not combined.strip(): + return [] + + lines: list[str] = [] + capture_failed = any( + marker in combined + for marker in ( + "mDNS snapshot capture exited with failure", + "mDNS snapshot capture ended without status", + "mDNS snapshot capture timed out", + "mDNS snapshot capture did not produce trusted Apple snapshot", + "warning: could not identify local Apple mDNS records", + ) + ) + fallback_generated = ( + "generating AirPort fallback" in combined + or "airport snapshot: wrote" in combined + or "mDNS AirPort snapshot generated" in combined + ) + generated_fallback = "mdns advertiser will fall back to generated records" in combined + + if capture_failed and fallback_generated: + lines.append("INFO trusted Apple mDNS snapshot capture failed; AirPort fallback snapshot was generated") + elif capture_failed and generated_fallback: + lines.append( + "INFO trusted Apple mDNS snapshot capture failed; mdns-advertiser fell back to generated records" + ) + elif capture_failed: + lines.append("INFO trusted Apple mDNS snapshot capture failed") + + snapshot_load = _last_regex_group(r"snapshot load: loaded ([^\n]+)", mdns_text) + if snapshot_load: + lines.append(f"INFO mDNS snapshot load: loaded {snapshot_load}") + + source = _last_regex_group(r"serving summary: source=([^\s]+)", mdns_text) + service_types = _extract_generated_service_types(mdns_text) + if source and service_types: + lines.append( + f"INFO mdns-advertiser source={source}; generated services include {', '.join(service_types)}" + ) + elif source: + lines.append(f"INFO mdns-advertiser source={source}") + + takeover = _last_regex_group(r"mDNS takeover established after ([^\n]+)", mdns_text) + if takeover: + lines.append(f"INFO mDNS takeover established after {takeover}") + + return lines + + +def build_doctor_error(results: list[CheckResult], debug_fields: Mapping[str, object] | None = None) -> str | None: + debug_fields = debug_fields or {} + fail_lines = [f"{result.status} {result.message}" for result in results if result.status == "FAIL"] + warn_lines = [f"{result.status} {result.message}" for result in results if result.status == "WARN"] + info_lines = [ + f"{result.status} {result.message}" + for result in results + if result.status == "INFO" and result.message.startswith("discovered _smb._tcp candidates:") + ] + discovery_lines = build_discovery_context(results, debug_fields) + mdns_boot_lines = build_mdns_boot_context(debug_fields) + lines: list[str] = [] + if fail_lines: + lines.append("Doctor failures:") + lines.extend(fail_lines) + if warn_lines: + if lines: + lines.append("") + lines.append("Doctor warnings:") + lines.extend(warn_lines) + if info_lines: + if lines: + lines.append("") + lines.append("Doctor context:") + lines.extend(info_lines) + if discovery_lines: + if lines: + lines.append("") + lines.append("Discovery context:") + lines.extend(discovery_lines) + if mdns_boot_lines: + if lines: + lines.append("") + lines.append("mDNS boot context:") + lines.extend(mdns_boot_lines) + return "\n".join(lines) if lines else None diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py new file mode 100644 index 0000000..93dcb5e --- /dev/null +++ b/src/timecapsulesmb/services/maintenance.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from dataclasses import dataclass +import shlex +from typing import Callable + +from timecapsulesmb.deploy.executor import DETACHED_SHUTDOWN_REBOOT_COMMAND +from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog +from timecapsulesmb.device.storage import MaStVolume + + +FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 +UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." +) +FSCK_REBOOT_NO_DOWN_MESSAGE = "fsck requested reboot from the device, but SSH did not go down." + +NO_MOUNTED_HFS_VOLUMES_MESSAGE = "no mounted HFS volumes found" +MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE = "multiple mounted HFS volumes found; specify --volume to select one" + + +@dataclass(frozen=True) +class FsckTarget: + device: str + mountpoint: str + name: str + builtin: bool + + +def fsck_target_from_volume(volume: MaStVolume) -> FsckTarget: + return FsckTarget( + device=volume.device_path, + mountpoint=volume.volume_root, + name=volume.name, + builtin=volume.builtin, + ) + + +def normalize_volume_selector(selector: str) -> str: + selector = selector.strip() + if selector.startswith("/dev/"): + return selector.removeprefix("/dev/") + return selector + + +def select_fsck_target(targets: tuple[FsckTarget, ...], selector: str | None, *, prompt: bool = True) -> FsckTarget: + if not targets: + raise RuntimeError(NO_MOUNTED_HFS_VOLUMES_MESSAGE) + if selector: + selected_device = normalize_volume_selector(selector) + for target in targets: + if target.device == selector or target.device.removeprefix("/dev/") == selected_device: + return target + raise RuntimeError(f"HFS volume not found: {selector}") + if len(targets) == 1: + return targets[0] + if not prompt: + raise RuntimeError(MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE) + + print(format_fsck_targets(targets)) + while True: + answer = input("Select a volume to fsck by number: ").strip() + if answer.isdigit(): + index = int(answer) + if 1 <= index <= len(targets): + return targets[index - 1] + print("Please enter a valid volume number.") + + +def fsck_target_to_jsonable(target: FsckTarget) -> dict[str, object]: + return { + "device": target.device, + "mountpoint": target.mountpoint, + "name": target.name, + "builtin": target.builtin, + } + + +def format_fsck_targets(targets: tuple[FsckTarget, ...]) -> str: + lines = ["Mounted HFS volumes:"] + if not targets: + lines.append(" none") + return "\n".join(lines) + for index, target in enumerate(targets, start=1): + kind = "internal" if target.builtin else "external" + lines.append(f" {index}. {target.device} on {target.mountpoint} ({target.name}, {kind})") + return "\n".join(lines) + + +def fsck_plan_to_jsonable(target: FsckTarget, *, reboot: bool, wait: bool) -> dict[str, object]: + return { + "target": fsck_target_to_jsonable(target), + "device": target.device, + "mountpoint": target.mountpoint, + "reboot_required": reboot, + "wait_after_reboot": bool(reboot and wait), + } + + +def format_fsck_plan(target: FsckTarget, *, reboot: bool, wait: bool) -> str: + lines = [ + "Dry run: fsck plan", + "", + "Target:", + f" device: {target.device}", + f" mountpoint: {target.mountpoint}", + f" name: {target.name}", + f" type: {'internal' if target.builtin else 'external'}", + "", + "Actions:", + " stop managed file sharing processes", + f" unmount: {target.mountpoint}", + f" run: /sbin/fsck_hfs -fy {target.device}", + "", + "Reboot:", + f" {'yes' if reboot else 'no'}", + ] + if reboot: + lines.append(f" follow-up: {'wait for SSH down, then SSH up' if wait else 'do not wait'}") + return "\n".join(lines) + + +def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: + lines = [ + render_direct_pkill9_watchdog(), + render_direct_pkill9_by_ucomm("smbd"), + render_direct_pkill9_by_ucomm("afpserver"), + render_direct_pkill9_by_ucomm("wcifsnd"), + render_direct_pkill9_by_ucomm("wcifsfs"), + "sleep 2", + f"/sbin/umount -f {shlex.quote(mountpoint)} >/dev/null 2>&1 || true", + f"echo '--- fsck_hfs {device} ---'", + f"/sbin/fsck_hfs -fy {shlex.quote(device)} 2>&1 || true", + ] + if reboot: + lines.extend([ + "echo '--- reboot ---'", + DETACHED_SHUTDOWN_REBOOT_COMMAND, + ]) + return "\n".join(lines) + + +class RepairExecutionContext: + def __init__(self, stage_callback: Callable[[str], None]) -> None: + self._stage_callback = stage_callback + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self._stage_callback(stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +class LineLogCapture: + def __init__(self, emit_line: Callable[[str], None]) -> None: + self._emit_line = emit_line + self._buffer = "" + + def write(self, text: str) -> int: + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def _emit(self, line: str) -> None: + message = line.rstrip("\r") + if message: + self._emit_line(message) diff --git a/src/timecapsulesmb/services/repair_xattrs.py b/src/timecapsulesmb/services/repair_xattrs.py new file mode 100644 index 0000000..6a9b336 --- /dev/null +++ b/src/timecapsulesmb/services/repair_xattrs.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.repair_xattrs import ( + ACTION_CLEAR_ARCH_FLAG, + ACTION_FIX_PERMISSIONS, + RepairCandidate, + RepairFinding, + RepairSummary, + actionable_findings, + build_repair_report, + default_share_path_from_config, + find_findings, + finding_to_candidate, + mounted_smb_shares, + path_exists, + repair_candidate, + unresolved_findings_after_success, + validate_repair_root_under_volumes, +) + + +@dataclass(frozen=True) +class RepairRunResult: + returncode: int + root: Path + findings: list[RepairFinding] + candidates: list[RepairCandidate] + summary: RepairSummary + report: str | None = None + + +def render_candidate_lines(candidates: list[RepairCandidate], *, dry_run: bool) -> list[str]: + verb = "Would repair" if dry_run else "Repairable" + lines: list[str] = [] + for candidate in candidates: + actions = ", ".join(candidate.actions) or "none" + flags = f", flags: {candidate.flags}" if candidate.flags else "" + lines.append(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + return lines + + +def render_diagnostic_lines(findings: list[RepairFinding], *, verbose: bool) -> list[str]: + lines: list[str] = [] + for finding in findings: + if finding.repairable: + continue + if finding.xattr_error or verbose: + detail = f"{finding.kind}: {finding.path} ({finding.path_type})" + if finding.flags: + detail += f" flags={finding.flags}" + if finding.xattr_error: + detail += f" xattr_error={finding.xattr_error}" + lines.append(f"WARN {detail}") + return lines + + +def render_summary_lines(summary: RepairSummary, *, dry_run: bool) -> list[str]: + lines = [ + "", + "Summary:", + f" scanned paths: {summary.scanned}", + f" scanned files: {summary.scanned_files}", + f" scanned directories: {summary.scanned_dirs}", + f" skipped: {summary.skipped}", + f" unreadable xattrs: {summary.unreadable}", + f" not repairable: {summary.not_repairable}", + f" repairable: {summary.repairable}", + f" permission repairs: {summary.permission_repairable}", + ] + if not dry_run: + lines.extend([ + f" repaired: {summary.repaired}", + f" failed: {summary.failed}", + ]) + return lines + + +def _emit_lines(emit: Callable[[str], None], lines: list[str]) -> None: + for line in lines: + emit(line) + + +def run_repair_structured( + args: argparse.Namespace, + command_context, + config: AppConfig, + *, + emit_log: Callable[[str], None] | None = None, + confirm: Callable[[str], bool] | None = None, +) -> RepairRunResult: + def emit(message: str) -> None: + if emit_log is not None: + emit_log(message) + + command_context.set_stage("resolve_scan_root") + command_context.update_fields( + dry_run=args.dry_run, + recursive=args.recursive, + max_depth=args.max_depth, + include_hidden=args.include_hidden, + include_time_machine=args.include_time_machine, + fix_permissions=args.fix_permissions, + explicit_path=args.path is not None, + ) + if args.path is None: + try: + root = default_share_path_from_config( + config, + shares=mounted_smb_shares(), + path_exists_func=path_exists, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + else: + root = args.path + if root is None: + raise SystemExit("Could not determine mounted share path. Pass --path explicitly.") + try: + root = validate_repair_root_under_volumes(root) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + summary = RepairSummary() + command_context.update_fields(repair_root=str(root)) + command_context.set_stage("scan_findings") + emit(f"Scanning {root}") + try: + findings = find_findings( + root, + recursive=args.recursive, + max_depth=args.max_depth, + include_hidden=args.include_hidden, + include_time_machine=args.include_time_machine, + include_directories=True, + include_root_directory=True, + fix_permissions=args.fix_permissions, + summary=summary, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + repairs = actionable_findings(findings) + candidates = [finding_to_candidate(finding) for finding in repairs] + command_context.update_fields( + scanned_paths=summary.scanned, + scanned_files=summary.scanned_files, + scanned_dirs=summary.scanned_dirs, + skipped_paths=summary.skipped, + unreadable_xattrs=summary.unreadable, + finding_count=len(findings), + repairable_count=len(candidates), + permission_repairable=summary.permission_repairable, + ) + + if not findings: + emit("No repairable files found.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + command_context.succeed() + return RepairRunResult(0, root, findings, candidates, summary) + + command_context.set_stage("report_findings") + _emit_lines(emit, render_diagnostic_lines(findings, verbose=args.verbose)) + if candidates: + _emit_lines(emit, render_candidate_lines(candidates, dry_run=args.dry_run)) + + if args.dry_run: + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + emit("No changes made.") + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) + + if not candidates: + emit("No known-safe repairs are available for the detected issues.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) + + command_context.set_stage("confirm_repair") + if not args.yes and not (confirm is not None and confirm(f"Repair {len(candidates)} paths with known-safe fixes?")): + emit("No changes made.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) + + command_context.set_stage("repair_findings") + failed_findings: list[RepairFinding] = [] + for finding, candidate in zip(repairs, candidates): + emit(f"Repairing: {candidate.path}") + if repair_candidate(candidate): + summary.repaired += 1 + if ACTION_CLEAR_ARCH_FLAG in candidate.actions: + emit(f"PASS xattr now readable: {candidate.path}") + if ACTION_FIX_PERMISSIONS in candidate.actions: + emit(f"PASS permissions repaired: {candidate.path}") + else: + summary.failed += 1 + failed_findings.append(finding) + if ACTION_CLEAR_ARCH_FLAG in candidate.actions: + emit(f"FAIL repair did not make xattr readable: {candidate.path}") + else: + emit(f"FAIL repair did not fix detected issue: {candidate.path}") + + unresolved = unresolved_findings_after_success(findings) + failed_findings + command_context.update_fields(repaired_count=summary.repaired, repair_failed_count=summary.failed) + _emit_lines(emit, render_summary_lines(summary, dry_run=False)) + if unresolved: + report = build_repair_report(findings, failed=unresolved) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) + command_context.succeed() + return RepairRunResult(0, root, findings, candidates, summary) diff --git a/src/timecapsulesmb/services/runtime.py b/src/timecapsulesmb/services/runtime.py new file mode 100644 index 0000000..0e72a52 --- /dev/null +++ b/src/timecapsulesmb/services/runtime.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import time +from typing import Callable + +from timecapsulesmb.core.config import DEFAULTS, AppConfig, ConfigError, load_app_config, require_valid_app_config +from timecapsulesmb.core.net import extract_host, ipv4_literal, is_link_local_ipv4, resolve_host_ipv4s +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.device.compat import DeviceCompatibility, require_compatibility +from timecapsulesmb.device.probe import ( + ProbedDeviceState, + RemoteInterfaceProbeResult, + probe_connection_state, + probe_remote_interface_conn, +) +from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy +from timecapsulesmb.transport.local import tcp_open + + +@dataclass(frozen=True) +class ManagedTargetState: + connection: SshConnection + interface_probe: RemoteInterfaceProbeResult | None + probe_state: ProbedDeviceState | None + + +def load_env_config( + *, + env_path: Path | None = None, + defaults: dict[str, str] | None = None, + resolve_paths=resolve_app_paths, +) -> AppConfig: + resolved_path = resolve_paths(config_path=env_path).config_path + return load_app_config(resolved_path, defaults=defaults) + + +def load_optional_env_config( + *, + env_path: Path | None = None, + defaults: dict[str, str] | None = None, + resolve_paths=resolve_app_paths, +) -> AppConfig: + try: + resolved_path = resolve_paths(config_path=env_path).config_path + except Exception: + return AppConfig.missing(path=env_path or Path.cwd() / ".env") + if not resolved_path.exists(): + return AppConfig.missing(path=resolved_path) + try: + return load_app_config(resolved_path, defaults=defaults) + except OSError: + return AppConfig.missing(path=resolved_path) + + +def resolve_ssh_credentials( + config: AppConfig, + *, + allow_empty_password: bool = False, +) -> tuple[str, str]: + host = config.require("TC_HOST") + password = config.get("TC_PASSWORD") + if not password and not allow_empty_password: + import getpass + password = getpass.getpass("Device root password: ") + return host, password + + +def resolve_env_connection( + config: AppConfig, + *, + required_keys: tuple[str, ...] = (), + allow_empty_password: bool = False, +) -> SshConnection: + for key in required_keys: + config.require(key) + host, password = resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) + return SshConnection(host=host, password=password, ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + + +def inspect_managed_connection( + connection: SshConnection, + iface: str, + *, + include_probe: bool = False, +) -> ManagedTargetState: + interface_probe = probe_remote_interface_conn(connection, iface) + probe_state = probe_connection_state(connection) if include_probe else None + return ManagedTargetState(connection=connection, interface_probe=interface_probe, probe_state=probe_state) + + +def ssh_target_link_local_resolution_error( + target: str, + ssh_opts: str, + *, + field_name: str = "Device SSH target", +) -> str | None: + if ssh_opts_use_proxy(ssh_opts): + return None + host = extract_host(target).strip() + if not host or ipv4_literal(host) is not None: + return None + link_local_ips = tuple(ip for ip in resolve_host_ipv4s(host) if is_link_local_ipv4(ip)) + if not link_local_ips: + return None + noun = "address" if len(link_local_ips) == 1 else "addresses" + return ( + f"{field_name} host {host} resolves to 169.254.x.x link-local IPv4 {noun} " + f"{', '.join(link_local_ips)}. Use the device's LAN IP or a hostname that resolves " + "to its LAN IP; 169.254.x.x is only suitable for temporary SSH recovery." + ) + + +def resolve_validated_managed_target( + config: AppConfig, + *, + command_name: str, + profile: str, + include_probe: bool = False, +) -> ManagedTargetState: + require_valid_app_config(config, profile=profile, command_name=command_name) + resolution_error = ssh_target_link_local_resolution_error( + config.require("TC_HOST"), + config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), + field_name="TC_HOST", + ) + if resolution_error is not None: + raise ConfigError(resolution_error) + connection = resolve_env_connection(config) + if profile == "flash": + return ManagedTargetState(connection=connection, interface_probe=None, probe_state=None) + probe_state = probe_connection_state(connection) if include_probe else None + return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) + + +def require_connection_compatibility(connection: SshConnection) -> DeviceCompatibility: + state = probe_connection_state(connection) + return require_compatibility( + state.compatibility, + fallback_error=state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + + +def wait_for_tcp_port_state( + host: str, + port: int, + *, + expected_state: bool, + timeout_seconds: int = 120, + interval_seconds: int = 5, + log: Callable[[str], None] | None = None, + service_name: str | None = None, +) -> bool: + label = service_name or f"TCP port {port}" + expected_state_string = "open" if expected_state else "closed" + if log is not None: + log(f"Waiting for {label} to be {expected_state_string}...") + deadline = time.time() + timeout_seconds + while True: + is_open = tcp_open(host, port) + if is_open == expected_state: + if log is not None: + log(f"{label} is {expected_state_string}.") + return True + if time.time() >= deadline: + break + time.sleep(interval_seconds) + if log is not None: + log(f"{label} did not become {expected_state_string} within {timeout_seconds}s.") + return False diff --git a/tests/test_app_api.py b/tests/test_app_api.py new file mode 100644 index 0000000..2418f5a --- /dev/null +++ b/tests/test_app_api.py @@ -0,0 +1,1531 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb import repair_xattrs as repair_xattrs_domain +from timecapsulesmb.app import contracts, helper, service +from timecapsulesmb.cli import main as cli_main +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, ConfigError, parse_env_file +from timecapsulesmb.device.compat import DeviceCompatibility +from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState +from timecapsulesmb.device.storage import MaStVolume, build_dry_run_payload_home +from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance +from timecapsulesmb.integrations.acp import ACPAuthError +from timecapsulesmb.services.app import AppOperationError, jsonable +from timecapsulesmb.transport.errors import SshCommandTimeout, SshError, TransportError +from timecapsulesmb.transport.ssh import SshConnection + + +class SampleMode(Enum): + FAST = "fast" + + +@dataclass(frozen=True) +class SamplePayload: + mode: SampleMode + + +class CollectingSink: + def __init__(self) -> None: + self.events: list[dict[str, object]] = [] + self.sink = EventSink(lambda event: self.events.append(event.to_jsonable())) + + def events_of_type(self, event_type: str) -> list[dict[str, object]]: + return [event for event in self.events if event["type"] == event_type] + + +def supported_compatibility(payload_family: str = "netbsd6_samba4") -> DeviceCompatibility: + return DeviceCompatibility( + os_name="NetBSD", + os_release="6.0", + arch="earmv4", + elf_endianness="little", + payload_family=payload_family, + device_generation="gen5", + supported=True, + reason_code="supported_netbsd6", + syap_candidates=("119",), + model_candidates=("TimeCapsule8,119",), + ) + + +def unsupported_compatibility() -> DeviceCompatibility: + return DeviceCompatibility( + os_name="NetBSD", + os_release="3.0", + arch="i386", + elf_endianness="little", + payload_family=None, + device_generation=None, + supported=False, + reason_code="unsupported_os", + syap_candidates=(), + model_candidates=(), + ) + + +def probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=True, + error=None, + os_name="NetBSD", + os_release="6.0", + arch="earmv4", + elf_endianness="little", + airport_model="TimeCapsule8,119", + airport_syap="119", + ), + compatibility=supported_compatibility(), + ) + + +def netbsd4_probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=True, + error=None, + os_name="NetBSD", + os_release="4.0", + arch="powerpc", + elf_endianness="big", + airport_model="TimeCapsule6,116", + airport_syap="116", + ), + compatibility=supported_compatibility("netbsd4be_samba4"), + ) + + +def unreachable_probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=False, + ssh_authenticated=False, + error="connection refused", + os_name="", + os_release="", + arch="", + elf_endianness="", + ), + compatibility=None, + ) + + +class AppApiTests(unittest.TestCase): + def assert_single_terminal_event(self, collector: CollectingSink, event_type: str) -> dict[str, object]: + terminals = collector.events_of_type("result") + collector.events_of_type("error") + self.assertEqual([event["type"] for event in terminals], [event_type]) + return terminals[0] + + def test_event_redacts_sensitive_fields(self) -> None: + event = AppEvent("result", "configure", { + "ok": True, + "payload": { + "password": "secret", + "nested": { + "TC_PASSWORD": "secret", + "api_key": "secret", + "ssh_private_key": "secret", + }, + }, + }) + + data = event.to_jsonable() + + self.assertEqual(data["payload"]["password"], "") + self.assertEqual(data["payload"]["nested"]["TC_PASSWORD"], "") + self.assertEqual(data["payload"]["nested"]["api_key"], "") + self.assertEqual(data["payload"]["nested"]["ssh_private_key"], "") + + def test_result_event_preserves_falsey_payloads(self) -> None: + collector = CollectingSink() + + collector.sink.result("paths", ok=True, payload=[]) + + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"], []) + self.assertEqual(result["schema_version"], 1) + self.assertTrue(result["request_id"]) + + def test_jsonable_serializes_enum_values_inside_dataclasses(self) -> None: + self.assertEqual(jsonable(SamplePayload(SampleMode.FAST)), {"mode": "fast"}) + + def test_stage_events_include_policy_metadata(self) -> None: + collector = CollectingSink() + + collector.sink.stage("paths", "resolve_paths") + collector.sink.stage("deploy", "upload_payload") + collector.sink.stage("uninstall", "uninstall_payload") + collector.sink.stage("deploy", "reboot") + + stages = collector.events_of_type("stage") + self.assertEqual(stages[0]["risk"], "local_read") + self.assertTrue(stages[0]["cancellable"]) + self.assertEqual(stages[1]["risk"], "remote_write") + self.assertEqual(stages[2]["risk"], "destructive") + self.assertEqual(stages[3]["risk"], "reboot") + self.assertIn("description", stages[3]) + + def test_contract_builders_keep_stable_representative_shapes(self) -> None: + deploy_plan = contracts.deploy_plan_payload( + {"host": "root@10.0.0.2", "reboot_required": True}, + payload_family="netbsd6_samba4", + netbsd4=False, + ) + self.assertEqual(deploy_plan, { + "host": "root@10.0.0.2", + "reboot_required": True, + "requires_reboot": True, + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "summary": "deployment dry-run plan generated.", + "schema_version": 1, + }) + + doctor = contracts.doctor_payload( + fatal=True, + results=[ + CheckResult("PASS", "ok"), + CheckResult("WARN", "slow"), + CheckResult("FAIL", "bad"), + ], + error="Doctor failures:\nFAIL bad", + ) + self.assertEqual(doctor["counts"], {"PASS": 1, "WARN": 1, "FAIL": 1, "INFO": 0}) + self.assertEqual(doctor["summary"], "doctor found one or more fatal problems.") + self.assertEqual(doctor["schema_version"], 1) + + repair = contracts.repair_xattrs_payload({ + "returncode": 0, + "root": "/Volumes/Data", + "finding_count": 2, + "repairable_count": 1, + "stats": {"scanned": 3}, + }) + self.assertEqual(repair["summary"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["stats"], {"scanned": 3}) + + def test_repair_xattrs_payload_preserves_legacy_summary_stats_as_stats(self) -> None: + repair = contracts.repair_xattrs_payload({ + "finding_count": 2, + "repairable_count": 1, + "summary": {"scanned": 3}, + }) + + self.assertEqual(repair["summary"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["stats"], {"scanned": 3}) + + def test_request_id_propagates_to_every_event(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"request_id": "req-123", "operation": "paths", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + self.assertTrue(collector.events) + self.assertEqual({event["request_id"] for event in collector.events}, {"req-123"}) + self.assert_single_terminal_event(collector, "result") + + def test_capabilities_returns_helper_contract_details(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "capabilities", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["api_schema_version"], 1) + self.assertIn("deploy", payload["operations"]) + self.assertIn("capabilities", payload["operations"]) + self.assertIn("helper_version", payload) + self.assertIn("artifact_manifest_sha256", payload) + + def test_missing_params_defaults_to_empty_object(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "paths"}, collector.sink) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertEqual(result["operation"], "paths") + + def test_missing_operation_emits_invalid_request_error(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["operation"], "api") + self.assertEqual(error["code"], "invalid_request") + self.assertEqual(error["recovery"]["title"], "Invalid request") + self.assertTrue(error["recovery"]["retryable"]) + + def test_unknown_operation_emits_error_without_result(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "nope", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "unknown_operation") + self.assertEqual(error["recovery"]["title"], "Unknown operation") + + def test_non_object_params_emits_invalid_request_error(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "paths", "params": []}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "invalid_request") + + def test_dispatcher_maps_recoverable_and_unexpected_error_states(self) -> None: + cases = ( + ("config-error", ConfigError("bad config"), "config_error"), + ("transport-error", TransportError("remote failed"), "remote_error"), + ("unexpected-error", RuntimeError("boom"), "operation_failed"), + ) + for operation, exception, code in cases: + with self.subTest(code=code): + collector = CollectingSink() + + def fail(_params, _sink, exc=exception): + raise exc + + with mock.patch.dict(service.OPERATIONS, {operation: fail}): + rc = service.run_api_request({"operation": operation, "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], code) + self.assertIn("recovery", error) + + def test_dispatcher_includes_traceback_for_unexpected_errors(self) -> None: + collector = CollectingSink() + + def fail(_params, _sink): + raise RuntimeError("boom") + + with mock.patch.dict(service.OPERATIONS, {"boom": fail}): + rc = service.run_api_request({"operation": "boom", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "operation_failed") + self.assertIn("Traceback", error["debug"]["traceback"]) + self.assertIn("RuntimeError: boom", error["debug"]["traceback"]) + + def test_discover_operation_returns_snapshot_payload(self) -> None: + collector = CollectingSink() + snapshot = BonjourDiscoverySnapshot( + instances=[BonjourServiceInstance("_airport._tcp.local.", "TC", "TC._airport._tcp.local.")], + resolved=[ + BonjourResolvedService( + name="TC", + hostname="tc.local.", + service_type="_airport._tcp.local.", + port=5009, + ipv4=("169.254.44.9", "10.0.0.2"), + properties={"syAP": "119"}, + fullname="TC._airport._tcp.local.", + ) + ], + ) + + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot): + rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) + + self.assertEqual(rc, 0) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["resolved"][0]["name"], "TC") + self.assertEqual(result["payload"]["resolved"][0]["ipv4"], ["169.254.44.9", "10.0.0.2"]) + self.assertEqual(result["payload"]["devices"][0]["name"], "TC") + self.assertEqual(result["payload"]["devices"][0]["host"], "10.0.0.2") + self.assertEqual(result["payload"]["devices"][0]["preferred_ipv4"], "10.0.0.2") + self.assertEqual(result["payload"]["devices"][0]["selected_record"]["fullname"], "TC._airport._tcp.local.") + self.assertEqual(result["payload"]["schema_version"], 1) + self.assertEqual(result["payload"]["counts"], {"instances": 1, "resolved": 1, "devices": 1}) + self.assertEqual(result["payload"]["summary"], "discovered 1 Time Capsule device(s).") + + def test_discover_operation_exposes_deduped_devices_separately_from_raw_services(self) -> None: + collector = CollectingSink() + raw_records = [ + BonjourResolvedService( + name=name, + hostname=f"{name.lower()}.local.", + service_type=service_type, + port=5009, + ipv4=ipv4, + properties={"syAP": syap}, + fullname=f"{name}.{service_type}", + ) + for name, ipv4, syap in ( + ("James", ("169.254.155.207", "192.168.1.217"), "119"), + ("Office", ("10.0.0.9",), "116"), + ) + for service_type in ( + "_adisk._tcp.local.", + "_airport._tcp.local.", + "_device-info._tcp.local.", + "_smb._tcp.local.", + ) + ] + snapshot = BonjourDiscoverySnapshot(instances=[], resolved=raw_records) + + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot): + rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) + + self.assertEqual(rc, 0) + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["counts"], {"instances": 0, "resolved": 8, "devices": 2}) + self.assertEqual([device["name"] for device in payload["devices"]], ["James", "Office"]) + self.assertEqual(payload["devices"][0]["host"], "192.168.1.217") + self.assertEqual(payload["devices"][0]["selected_record"]["service_type"], "_airport._tcp.local.") + + def test_discover_rejects_invalid_timeout_values(self) -> None: + for timeout in ("bad", "nan", -1, True): + with self.subTest(timeout=timeout): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot") as discover: + rc = service.run_api_request( + {"operation": "discover", "params": {"timeout": timeout}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "Request validation failed") + discover.assert_not_called() + + def test_discover_accepts_numeric_timeout_string(self) -> None: + collector = CollectingSink() + snapshot = BonjourDiscoverySnapshot(instances=[], resolved=[]) + + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot) as discover: + rc = service.run_api_request( + {"operation": "discover", "params": {"timeout": "0.25"}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + discover.assert_called_once_with(timeout=0.25) + + def test_configure_writes_env_without_persisting_or_leaking_password_by_default(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertIn("TC_HOST=root@10.0.0.2", config_path.read_text()) + self.assertNotIn("TC_PASSWORD=goodpw", config_path.read_text()) + self.assertEqual(parse_env_file(config_path)["TC_PASSWORD"], "") + self.assertIn("TC_DEBUG_LOGGING=false", config_path.read_text()) + serialized_events = json.dumps(collector.events) + self.assertNotIn("goodpw", serialized_events) + + def test_configure_defaults_bare_host_to_root_user(self) -> None: + collector = CollectingSink() + captured_connections: list[SshConnection] = [] + + def capture_probe(connection: SshConnection) -> ProbedDeviceState: + captured_connections.append(connection) + return probed_state() + + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", side_effect=capture_probe): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": " 10.0.0.2 ", + "password": "goodpw", + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(captured_connections[0].host, "root@10.0.0.2") + self.assertEqual(values["TC_HOST"], "root@10.0.0.2") + self.assertEqual(collector.events_of_type("result")[0]["payload"]["host"], "root@10.0.0.2") + + def test_configure_can_persist_password_for_env_compatibility_when_requested(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + "persist_password": True, + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_PASSWORD"], "goodpw") + self.assertNotIn("goodpw", json.dumps(collector.events)) + + def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + config_path.write_text( + "TC_HOST=root@10.0.0.1\n" + "TC_PASSWORD=oldpw\n" + "TC_CUSTOM_SETTING='keep me'\n" + "TC_DEBUG_LOGGING=true\n" + "TC_SAMBA_USER=old-admin\n" + "TC_PAYLOAD_DIR_NAME=old-payload\n" + ) + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "newpw", + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_HOST"], "root@10.0.0.2") + self.assertEqual(values["TC_PASSWORD"], "") + self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") + self.assertEqual(values["TC_DEBUG_LOGGING"], "true") + self.assertNotIn("TC_SAMBA_USER", values) + self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) + + def test_configure_debug_logging_param_writes_true(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + "debug_logging": True, + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_DEBUG_LOGGING"], "true") + + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh", side_effect=ACPAuthError("bad password")): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "badpw", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertFalse(config_path.exists()) + self.assertEqual(collector.events_of_type("error")[0]["code"], "auth_failed") + self.assertEqual(collector.events_of_type("error")[0]["recovery"]["suggested_operation"], "configure") + self.assertEqual(collector.events_of_type("error")[0]["recovery"]["action_ids"], ["replace_password"]) + self.assertNotIn("badpw", json.dumps(collector.events)) + + def test_configure_reports_unsupported_device(self) -> None: + collector = CollectingSink() + unsupported_state = ProbedDeviceState( + probe_result=probed_state().probe_result, + compatibility=unsupported_compatibility(), + ) + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unsupported_state): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "pw", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertFalse(config_path.exists()) + self.assertEqual(collector.events_of_type("error")[0]["code"], "unsupported_device") + + def test_configure_rejects_boolean_ssh_wait_timeout(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "pw", + "ssh_wait_timeout": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_called_once() + self.assertFalse(config_path.exists()) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn("ssh_wait_timeout must be an integer", error["message"]) + + def test_doctor_streams_check_events(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + def fake_run_doctor_checks(*_args, **kwargs): + kwargs["on_result"](CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})) + return [CheckResult("PASS", "smbd is bound to TCP 445", {"port": 445})], False + + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", side_effect=fake_run_doctor_checks): + rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + checks = collector.events_of_type("check") + self.assertEqual(len(checks), 1) + self.assertEqual(checks[0]["status"], "PASS") + self.assertEqual(checks[0]["details"], {"port": 445}) + + def test_doctor_passes_bonjour_timeout_to_checks(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", return_value=([], False)) as checks: + rc = service.run_api_request( + {"operation": "doctor", "params": {"bonjour_timeout": "2.75"}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(checks.call_args.kwargs["bonjour_timeout"], 2.75) + + def test_doctor_fatal_returns_nonzero_result_without_error_event(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + def fake_run_doctor_checks(*_args, **kwargs): + kwargs["on_result"](CheckResult("FAIL", "SMB is not reachable", {"password": "pw"})) + return [CheckResult("FAIL", "SMB is not reachable", {"password": "pw"})], True + + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", side_effect=fake_run_doctor_checks): + rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error"), []) + result = collector.events_of_type("result")[0] + self.assertEqual(result["ok"], False) + self.assertTrue(result["payload"]["fatal"]) + self.assertNotIn("pw", json.dumps(collector.events)) + + def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": True, "yes": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["host"], "root@10.0.0.2") + self.assertEqual(result["payload"]["reboot_required"], True) + self.assertEqual(result["payload"]["requires_reboot"], True) + self.assertEqual(result["payload"]["payload_family"], "netbsd6_samba4") + self.assertEqual(result["payload"]["schema_version"], 1) + + def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + remote_actions.assert_not_called() + + def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4-netbsd4be/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns-netbsd4be/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + read_mast.assert_not_called() + remote_actions.assert_not_called() + + def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn") as read_mast: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + self.assertEqual(error["details"]["action_title"], "Deploy") + self.assertIn("confirmation_id", error["details"]) + read_mast.assert_not_called() + + def test_deploy_accepts_backend_confirmation_id_before_remote_writes(self) -> None: + first = CollectingSink() + second = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + base_params = {"dry_run": False, "no_reboot": True, "mount_wait": 30} + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + rc = service.run_api_request( + {"operation": "deploy", "params": dict(base_params)}, + first.sink, + ) + + self.assertEqual(rc, 1) + confirmation_id = first.events_of_type("error")[0]["details"]["confirmation_id"] + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + confirmed = dict(base_params) + confirmed["confirmation_id"] = confirmation_id + rc = service.run_api_request( + {"operation": "deploy", "params": confirmed}, + second.sink, + ) + + self.assertEqual(rc, 0) + upload.assert_called_once() + self.assertEqual(second.events_of_type("error"), []) + + def test_deploy_rejects_boolean_mount_wait_before_remote_connection(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config") as load_config: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": True, + "mount_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + load_config.assert_not_called() + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn("mount_wait must be an integer", error["message"]) + + def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "no_reboot": True, + "confirm_deploy": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + upload.assert_called_once() + wait.assert_not_called() + self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) + + def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot") as reboot: + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + reboot.assert_called_once() + wait.assert_not_called() + verify_runtime.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["reboot_requested"], True) + self.assertEqual(payload["waited"], False) + self.assertEqual(payload["verified"], False) + + def test_deploy_no_wait_reports_reboot_request_failure(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot: + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + reboot.assert_called_once() + wait.assert_not_called() + verify_runtime.assert_not_called() + errors = collector.events_of_type("error") + self.assertEqual(errors[0]["code"], "remote_error") + self.assertIn("ssh command failed with rc=255", errors[0]["message"]) + self.assertEqual(collector.events_of_type("result"), []) + + def test_deploy_request_ssh_reboot_reports_timeout_when_request_error_is_required(self) -> None: + from timecapsulesmb.app.ops import deploy as deploy_ops + + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + with mock.patch( + "timecapsulesmb.app.ops.deploy.remote_request_reboot", + side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), + ): + with self.assertRaises(AppOperationError) as raised: + deploy_ops.request_ssh_reboot("deploy", collector.sink, connection, raise_on_request_error=True) + + self.assertEqual(raised.exception.code, "remote_error") + self.assertIn("Timed out waiting for ssh command to finish: reboot", str(raised.exception)) + + def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = collector.events_of_type("error")[0] + self.assertEqual(error["code"], "remote_error") + self.assertEqual(error["recovery"]["title"], "No HFS volumes found") + self.assertEqual(error["recovery"]["action_ids"], ["open_finder", "install_smb"]) + + def test_activate_requires_explicit_confirmation(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_validated_managed_target") as resolve_target: + with mock.patch("timecapsulesmb.app.ops.maintenance.probe_managed_runtime_conn") as runtime_probe: + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: + rc = service.run_api_request({"operation": "activate", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_target.assert_not_called() + runtime_probe.assert_not_called() + remote_actions.assert_not_called() + + def test_activate_accepts_yes_alias_for_confirmation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.maintenance.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "activate", "params": {"yes": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertEqual(result["payload"]["already_active"], True) + self.assertEqual(result["payload"]["schema_version"], 1) + self.assertEqual(result["payload"]["summary"], "NetBSD4 payload was already active.") + remote_actions.assert_not_called() + + def test_uninstall_requires_confirmation_before_remote_removal(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection") as resolve_connection: + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload") as uninstall: + rc = service.run_api_request({"operation": "uninstall", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_connection.assert_called_once() + uninstall.assert_not_called() + + def test_uninstall_requires_reboot_confirmation_before_remote_connection(self) -> None: + collector = CollectingSink() + + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn") as read_mast: + rc = service.run_api_request( + {"operation": "uninstall", "params": {"confirm_uninstall": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + read_mast.assert_not_called() + + def test_uninstall_dry_run_bypasses_confirmation_and_returns_plan(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload") as uninstall: + rc = service.run_api_request( + {"operation": "uninstall", "params": {"dry_run": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertIn("remote_actions", result["payload"]) + self.assertEqual(result["payload"]["requires_reboot"], True) + self.assertEqual(result["payload"]["schema_version"], 1) + uninstall.assert_not_called() + + def test_uninstall_no_wait_uses_mount_wait_and_skips_post_reboot_verification(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + mounted = [SimpleNamespace(volume_root="/Volumes/dk2")] + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot") as reboot: + with mock.patch("timecapsulesmb.app.ops.maintenance.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.maintenance.verify_post_uninstall") as verify: + rc = service.run_api_request( + { + "operation": "uninstall", + "params": { + "confirm_uninstall": True, + "confirm_reboot": True, + "mount_wait": 13, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(mounted_mock.call_args.kwargs["wait_seconds"], 13) + reboot.assert_called_once() + wait.assert_not_called() + verify.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["reboot_requested"], True) + self.assertEqual(payload["waited"], False) + self.assertEqual(payload["verified"], False) + + def test_fsck_requires_confirmation_before_remote_connection(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection") as resolve_connection: + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_connection.assert_not_called() + + def test_fsck_rejects_non_integer_mount_wait_before_remote_connection(self) -> None: + for value in (12.5, True): + with self.subTest(value=value): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config") as load_config: + rc = service.run_api_request( + { + "operation": "fsck", + "params": {"list_volumes": True, "mount_wait": value}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + load_config.assert_not_called() + error = collector.events_of_type("error")[0] + self.assertEqual(error["code"], "validation_failed") + self.assertIn("mount_wait must be an integer", error["message"]) + + def test_fsck_list_volumes_returns_targets_without_confirmation_or_remote_fsck(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + mounted = [MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "uuid", True, "hfs")] + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.ops.maintenance.run_ssh") as run_ssh: + rc = service.run_api_request( + { + "operation": "fsck", + "params": {"list_volumes": True, "mount_wait": 14}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(mounted_mock.call_args.kwargs["wait_seconds"], 14) + run_ssh.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["counts"], {"targets": 1}) + self.assertEqual(payload["targets"][0]["device"], "/dev/dk2") + + def test_fsck_dry_run_returns_plan_without_remote_fsck(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + mounted = [MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "uuid", True, "hfs")] + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_ssh") as run_ssh: + rc = service.run_api_request( + { + "operation": "fsck", + "params": {"dry_run": True, "no_wait": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + run_ssh.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["device"], "/dev/dk2") + self.assertEqual(payload["wait_after_reboot"], False) + + def test_repair_xattrs_uses_structured_runner(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1, scanned_files=1, unreadable=1, repairable=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[SimpleNamespace(path=Path("/Volumes/Data/broken"))], + candidates=[SimpleNamespace(path=Path("/Volumes/Data/broken"))], + summary=summary, + report="detected issues", + ) + + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", return_value=repair_result) as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + runner.assert_called_once() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["finding_count"], 1) + self.assertEqual(payload["summary"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(payload["summary_text"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(payload["stats"]["scanned"], 1) + self.assertNotIsInstance(payload["summary"], dict) + + def test_repair_xattrs_captures_direct_stdout_and_stderr_logs(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[], + candidates=[], + summary=summary, + report=None, + ) + + def fake_runner(*_args, **_kwargs): + print("stdout detail") + print("stderr detail", file=sys.stderr) + return repair_result + + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", side_effect=fake_runner): + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": True}, + }, + collector.sink, + ) + + logs = collector.events_of_type("log") + self.assertEqual(rc, 0) + self.assertIn({"info": "stdout detail"}, [{log["level"]: log["message"]} for log in logs]) + self.assertIn({"warning": "stderr detail"}, [{log["level"]: log["message"]} for log in logs]) + + def test_repair_xattrs_rejects_invalid_path_before_runner(self) -> None: + cases = [ + ({}, "missing required parameter: path"), + ({"path": ""}, "missing required parameter: path"), + ({"path": " "}, "missing required parameter: path"), + ({"path": True}, "path must be a path string"), + ] + for extra_params, message in cases: + with self.subTest(extra_params=extra_params): + collector = CollectingSink() + params = {"dry_run": True} + params.update(extra_params) + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["message"], message) + self.assertEqual(error["recovery"]["title"], "Invalid repair options") + load_config.assert_not_called() + runner.assert_not_called() + + def test_repair_xattrs_rejects_invalid_max_depth_before_runner(self) -> None: + for max_depth in ("bad", -1, True): + with self.subTest(max_depth=max_depth): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": { + "path": "/Volumes/Data", + "dry_run": True, + "max_depth": max_depth, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "Invalid repair options") + runner.assert_not_called() + + def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[], + candidates=[], + summary=summary, + report=None, + ) + + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", return_value=repair_result) as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": { + "path": "/Volumes/Data", + "dry_run": True, + "max_depth": "2", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + args = runner.call_args.args[0] + self.assertEqual(args.max_depth, 2) + + def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "linux"): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + self.assertEqual(error["recovery"]["title"], "Repair confirmation required") + runner.assert_not_called() + + def test_repair_xattrs_checks_platform_after_confirmation(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "linux"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False, "confirm_repair": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "repair-xattrs requires macOS") + load_config.assert_not_called() + runner.assert_not_called() + + def test_helper_reads_request_and_writes_ndjson(self) -> None: + output = io.StringIO() + fake_stdin = io.StringIO('{"operation":"paths","params":{}}') + with mock.patch.object(sys, "stdin", fake_stdin): + with mock.patch("timecapsulesmb.app.helper.run_api_request") as run_mock: + run_mock.side_effect = lambda request, sink: (sink.result(request["operation"], ok=True, payload={"ok": True}) or 0) + with redirect_stdout(output): + rc = helper.main([]) + + self.assertEqual(rc, 0) + line = json.loads(output.getvalue()) + self.assertEqual(line["type"], "result") + self.assertEqual(line["operation"], "paths") + self.assertEqual(line["schema_version"], 1) + self.assertTrue(line["request_id"]) + + def test_helper_rejects_invalid_json_without_leaking_pretty_error_details(self) -> None: + output = io.StringIO() + error_output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO('{"operation":"paths","password":"secret"')): + with redirect_stdout(output): + with mock.patch.object(sys, "stderr", error_output): + rc = helper.main(["--pretty-error"]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["code"], "invalid_request") + self.assertNotIn("secret", error_output.getvalue()) + + def test_helper_rejects_oversized_request_without_leaking_body(self) -> None: + output = io.StringIO() + error_output = io.StringIO() + secret = "secret" + oversized = secret + ("x" * (helper.MAX_REQUEST_CHARS + 1)) + with mock.patch.object(sys, "stdin", io.StringIO(oversized)): + with redirect_stdout(output): + with mock.patch.object(sys, "stderr", error_output): + rc = helper.main(["--pretty-error"]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["code"], "invalid_request") + self.assertIn("maximum size", event["message"]) + self.assertNotIn(secret, error_output.getvalue()) + + def test_helper_rejects_top_level_non_object_json(self) -> None: + output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO('["paths"]')): + with redirect_stdout(output): + rc = helper.main([]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["operation"], "api") + self.assertEqual(event["code"], "invalid_request") + self.assertEqual(event["schema_version"], 1) + self.assertTrue(event["request_id"]) + + def test_api_command_is_registered(self) -> None: + self.assertIs(cli_main.COMMANDS["api"], helper.main) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 65e3a6f..a20fbc1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import errno +import argparse import io import json import plistlib @@ -1235,6 +1236,40 @@ def test_repair_xattrs_non_macos_emits_platform_check_telemetry(self) -> None: self.assertEqual(finished["host_platform"], "linux") self.assertIn("stage=platform_check", finished["error"]) + def test_repair_xattrs_json_emits_ndjson_result(self) -> None: + output = io.StringIO() + result = repair_xattrs.RepairRunResult( + returncode=0, + root=Path("/Volumes/Data"), + findings=[mock.Mock()], + candidates=[mock.Mock()], + summary=repair_xattrs.RepairSummary(scanned=1, repairable=1), + report="detected issues", + ) + with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.cli.repair_xattrs.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.cli.repair_xattrs.run_repair_structured", return_value=result): + with redirect_stdout(output): + rc = repair_xattrs.main(["--path", "/Volumes/Data", "--dry-run", "--json"]) + + self.assertEqual(rc, 0) + events = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertEqual(events[0]["type"], "stage") + self.assertEqual(events[-1]["type"], "result") + self.assertEqual(events[-1]["payload"]["finding_count"], 1) + self.assertEqual(events[-1]["payload"]["summary"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(events[-1]["payload"]["summary_text"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(events[-1]["payload"]["stats"]["scanned"], 1) + self.assertEqual(events[-1]["payload"]["repairable_count"], 1) + + def test_repair_xattrs_json_repair_requires_yes(self) -> None: + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + repair_xattrs.main(["--path", "/Volumes/Data", "--json"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("--json repair requires --yes", stderr.getvalue()) + def test_bootstrap_prints_full_next_steps(self) -> None: output = io.StringIO() with mock.patch("pathlib.Path.exists", return_value=True): @@ -1461,6 +1496,7 @@ def test_configure_writes_values_from_prompts(self) -> None: self.assertNotIn("TC_NET_IFACE", fake_values) self.assertEqual(fake_values["TC_INTERNAL_SHARE_USE_DISK_ROOT"], "false") self.assertEqual(fake_values["TC_ANY_PROTOCOL"], "false") + self.assertEqual(fake_values["TC_DEBUG_LOGGING"], "false") uuid.UUID(fake_values["TC_CONFIGURE_ID"]) telemetry_values = result.mocks.telemetry_factory.call_args.args[0].values self.assertEqual(telemetry_values["TC_CONFIGURE_ID"], fake_values["TC_CONFIGURE_ID"]) @@ -1539,6 +1575,40 @@ def test_configure_hidden_any_protocol_arg_writes_true(self) -> None: self.assertEqual(result.rc, 0) self.assertEqual(result.values["TC_ANY_PROTOCOL"], "true") + def test_configure_hidden_debug_logging_arg_writes_true(self) -> None: + result = self.run_configure_cli( + ["--debug-logging"], + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + + def test_configure_bonjour_timeout_reaches_discovery(self) -> None: + result = self.run_configure_cli( + ["--bonjour-timeout", "1.25"], + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + result.mocks.discover_resolved_records.assert_called_once() + self.assertEqual(result.mocks.discover_resolved_records.call_args.kwargs["timeout"], 1.25) + + def test_configure_preserves_existing_debug_logging_when_arg_is_omitted(self) -> None: + result = self.run_configure_cli( + existing_values={"TC_DEBUG_LOGGING": "true"}, + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + def test_configure_airport_extreme_keeps_hidden_internal_share_root_default(self) -> None: def fake_prompt(label, default, _secret): if label == "Device SSH target": @@ -1570,6 +1640,7 @@ def fake_prompt(label, default, _secret): self.assertNotIn("TC_MDNS_DEVICE_MODEL", result.values) self.assertEqual(result.values["TC_INTERNAL_SHARE_USE_DISK_ROOT"], "false") self.assertEqual(result.values["TC_ANY_PROTOCOL"], "false") + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "false") def test_configure_ensures_install_id_before_telemetry(self) -> None: prompt_values = iter([ @@ -4077,6 +4148,30 @@ def test_set_ssh_returns_error_when_env_missing(self) -> None: self.assertIn("stage=load_config", finished["error"]) self.assertNotIn("TC_PASSWORD", finished["error"]) + def test_set_ssh_action_selection_covers_cli_modes(self) -> None: + cases = [ + (False, False, False, set_ssh.SetSshAction.ENABLE), + (False, False, True, set_ssh.SetSshAction.PROMPT_DISABLE), + (True, False, False, set_ssh.SetSshAction.ENABLE), + (True, False, True, set_ssh.SetSshAction.ENABLE_NOOP), + (False, True, False, set_ssh.SetSshAction.DISABLE_NOOP), + (False, True, True, set_ssh.SetSshAction.DISABLE), + ] + for explicit_enable, explicit_disable, ssh_open, expected in cases: + with self.subTest( + explicit_enable=explicit_enable, + explicit_disable=explicit_disable, + ssh_open=ssh_open, + ): + self.assertIs( + set_ssh.select_set_ssh_action( + explicit_enable=explicit_enable, + explicit_disable=explicit_disable, + ssh_open=ssh_open, + ), + expected, + ) + def test_set_ssh_enable_flow_succeeds(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} @@ -4095,6 +4190,64 @@ def test_set_ssh_enable_flow_succeeds(self) -> None: self.assertEqual(finished["ssh_initially_reachable"], False) self.assertEqual(finished["ssh_final_reachable"], True) + def test_set_ssh_status_requires_only_host(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--status"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH enabled.", output.getvalue()) + enable_mock.assert_not_called() + disable_mock.assert_not_called() + finished = self.telemetry_payload("set_ssh_finished") + self.assertEqual(finished["set_ssh_action"], "status") + + def test_set_ssh_explicit_enable_is_noop_when_already_enabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--enable"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH already enabled.", output.getvalue()) + enable_mock.assert_not_called() + + def test_set_ssh_explicit_disable_is_noop_when_already_disabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=False): + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--disable"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH already disabled.", output.getvalue()) + disable_mock.assert_not_called() + + def test_set_ssh_no_wait_skips_enable_verification(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=False): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state") as wait_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--enable", "--no-wait"]) + + self.assertEqual(rc, 0) + enable_mock.assert_called_once() + wait_mock.assert_not_called() + self.assertIn("not waiting for SSH to open", output.getvalue()) + def test_set_ssh_enable_exception_emits_failure_stage(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} @@ -4155,6 +4308,24 @@ def test_set_ssh_disable_failure_is_reported_as_ssh_error(self) -> None: self.assertNotIn("AirPyrt", finished["error"]) self.assertNotIn(ANSI_RED, finished["error"]) + def test_set_ssh_legacy_enabled_state_can_leave_ssh_enabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("builtins.input", return_value="n"): + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main([]) + + self.assertEqual(rc, 0) + self.assertIn("Leaving SSH enabled.", output.getvalue()) + disable_mock.assert_not_called() + finished = self.telemetry_payload("set_ssh_finished") + self.assertEqual(finished["result"], "success") + self.assertEqual(finished["set_ssh_action"], "leave_enabled") + self.assertEqual(finished["ssh_final_reachable"], True) + def test_set_ssh_disable_fails_when_ssh_never_goes_down(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} @@ -4249,6 +4420,21 @@ def test_set_ssh_disable_flow_confirms_ssh_disabled(self) -> None: self.assertEqual(finished["ssh_final_reachable"], False) self.assertEqual(finished["ssh_disable_persisted"], True) + def test_set_ssh_yes_disables_legacy_enabled_state_without_prompt(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("builtins.input", side_effect=AssertionError("--yes should skip prompt")) as input_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", side_effect=[True, True]): + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_device_up", return_value=True): + with redirect_stdout(output): + rc = set_ssh.main(["--yes"]) + self.assertEqual(rc, 0) + input_mock.assert_not_called() + disable_mock.assert_called_once() + def test_doctor_json_outputs_structured_results(self) -> None: output = io.StringIO() fake_result = doctor.CheckResult("PASS", "ok") @@ -4261,6 +4447,16 @@ def test_doctor_json_outputs_structured_results(self) -> None: self.assertEqual(payload["fatal"], False) self.assertEqual(payload["results"][0]["status"], "PASS") + def test_doctor_bonjour_timeout_reaches_checks(self) -> None: + output = io.StringIO() + fake_result = doctor.CheckResult("PASS", "ok") + with mock.patch("timecapsulesmb.cli.doctor.load_env_config", return_value=self.make_app_config({})): + with mock.patch("timecapsulesmb.cli.doctor.run_doctor_checks", return_value=([fake_result], False)) as checks_mock: + with redirect_stdout(output): + rc = doctor.main(["--bonjour-timeout", "2.5"]) + self.assertEqual(rc, 0) + self.assertEqual(checks_mock.call_args.kwargs["bonjour_timeout"], 2.5) + def test_doctor_ensures_install_id_before_telemetry(self) -> None: output = io.StringIO() fake_result = doctor.CheckResult("PASS", "ok") @@ -4427,6 +4623,7 @@ def fake_upload(_plan, *, connection, source_resolver): self.assertIn("NBNS_ENABLED=1\n", flash_config) self.assertIn("ANY_PROTOCOL=0\n", flash_config) self.assertIn("SMBD_DEBUG_LOGGING=1\n", flash_config) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", flash_config) self.assertNotIn("SMB_SAMBA_USER", flash_config) self.assertNotIn("MDNS_DEVICE_MODEL", flash_config) self.assertNotIn("AIRPORT_SYAP", flash_config) @@ -4453,6 +4650,75 @@ def fake_upload(_plan, *, connection, source_resolver): finished = self.telemetry_payload("deploy_finished") self.assertFalse(finished["nbns_enabled"]) + def test_deploy_uses_configured_debug_logging_without_deploy_arg(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver): + captured["flash_config"] = source_resolver[GENERATED_FLASH_CONFIG_SOURCE].read_text() + + result = self.run_deploy_cli( + ["--no-reboot"], + values=self.make_valid_env(TC_DEBUG_LOGGING="true"), + patch_actions=True, + patch_upload=True, + upload_side_effect=fake_upload, + ) + + self.assertEqual(result.rc, 0) + self.assertIn("SMBD_DEBUG_LOGGING=1\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", captured["flash_config"]) + + def test_deploy_leaves_debug_logging_disabled_without_config_or_arg(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver): + captured["flash_config"] = source_resolver[GENERATED_FLASH_CONFIG_SOURCE].read_text() + + result = self.run_deploy_cli( + ["--no-reboot"], + values=self.make_valid_env(TC_DEBUG_LOGGING="false"), + patch_actions=True, + patch_upload=True, + upload_side_effect=fake_upload, + ) + + self.assertEqual(result.rc, 0) + self.assertIn("SMBD_DEBUG_LOGGING=0\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=0\n", captured["flash_config"]) + + def test_deploy_no_wait_requests_reboot_without_observation_or_runtime_verify(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + patch_actions=True, + patch_upload=True, + wait_side_effect=AssertionError("deploy --no-wait should not observe SSH state"), + verify_runtime=self.managed_runtime_probe(False), + ) + + self.assertEqual(result.rc, 0) + result.mocks.remote_request_reboot.assert_called_once() + result.mocks.wait_for_ssh_state_conn.assert_not_called() + result.mocks.verify_managed_runtime.assert_not_called() + self.assertIn("not waiting for the device", result.text) + + def test_deploy_no_wait_fails_when_reboot_request_fails(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + patch_actions=True, + patch_upload=True, + reboot_side_effect=SshError("ssh command failed with rc=255"), + wait_side_effect=AssertionError("deploy --no-wait should not observe SSH state after request failure"), + verify_runtime=self.managed_runtime_probe(False), + raises=SystemExit, + ) + + self.assertIn("ssh command failed with rc=255", str(result.exception)) + result.mocks.remote_request_reboot.assert_called_once() + result.mocks.wait_for_ssh_state_conn.assert_not_called() + result.mocks.verify_managed_runtime.assert_not_called() + finished = self.telemetry_payload("deploy_finished") + self.assertEqual(finished["result"], "failure") + def test_deploy_rejects_removed_install_nbns_flag(self) -> None: stderr = io.StringIO() with redirect_stderr(stderr): @@ -4461,6 +4727,20 @@ def test_deploy_rejects_removed_install_nbns_flag(self) -> None: self.assertEqual(raised.exception.code, 2) self.assertIn("unrecognized arguments: --install-nbns", stderr.getvalue()) + def test_negative_shared_timeouts_are_rejected_by_parsers(self) -> None: + cases = ( + (deploy.main, ["--mount-wait", "-1", "--dry-run"], "must be 0 or greater"), + (doctor.main, ["--bonjour-timeout", "-0.1"], "must be 0 or greater"), + ) + for entrypoint, argv, message in cases: + with self.subTest(argv=argv): + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + entrypoint(argv) + self.assertEqual(raised.exception.code, 2) + self.assertIn(message, stderr.getvalue()) + def test_deploy_exits_when_mast_volumes_are_not_writable(self) -> None: volumes = (self._mast_volume("dk2"),) result = self.run_deploy_cli( @@ -6309,8 +6589,50 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) self.assertNotIn("verify Samba startup", text) finished = command_context.finish.call_args.kwargs self.assertEqual(finished["result"], "success") - self.assertEqual(finished["reboot_was_attempted"], True) - self.assertEqual(finished["device_came_back_after_reboot"], True) + + def test_flash_restore_reboot_no_wait_skips_reboot_observation(self) -> None: + output = io.StringIO() + command_context = FakeCommandContext() + target = SimpleNamespace(connection=SshConnection("root@10.0.0.2", "pw", "-o foo")) + args = argparse.Namespace(reboot=True, no_wait=True) + + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.cli.flash.observe_reboot_cycle") as observe_mock: + with redirect_stdout(output): + rc = cli_flash._finish_write( + command_context, + args=args, + operation="restore", + target=target, + log=None, + ) + + self.assertEqual(rc, 0) + reboot_mock.assert_called_once() + observe_mock.assert_not_called() + self.assertIn("not waiting for the device", output.getvalue()) + + def test_flash_restore_reboot_no_wait_fails_when_reboot_request_fails(self) -> None: + output = io.StringIO() + command_context = FakeCommandContext() + target = SimpleNamespace(connection=SshConnection("root@10.0.0.2", "pw", "-o foo")) + args = argparse.Namespace(reboot=True, no_wait=True) + + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot_mock: + with mock.patch("timecapsulesmb.cli.flash.observe_reboot_cycle") as observe_mock: + with self.assertRaises(SshError): + with redirect_stdout(output): + cli_flash._finish_write( + command_context, + args=args, + operation="restore", + target=target, + log=None, + ) + + reboot_mock.assert_called_once() + observe_mock.assert_not_called() + self.assertNotIn("not waiting for the device", output.getvalue()) def test_flash_restore_noops_when_active_bank_already_matches_apple(self) -> None: output = io.StringIO() @@ -6973,6 +7295,26 @@ def test_activate_returns_nonzero_when_verification_fails(self) -> None: self.assertEqual(rc, 1) self.assertIn("NetBSD4 activation failed.", output.getvalue()) + def test_activate_dry_run_json_outputs_activation_plan(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with mock.patch("timecapsulesmb.cli.activate.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.context.CommandContext.require_compatibility", return_value=self.make_supported_netbsd4_compatibility()): + with redirect_stdout(output): + rc = activate.main(["--dry-run", "--json"]) + self.assertEqual(rc, 0) + payload = json.loads(output.getvalue()) + self.assertIn("actions", payload) + self.assertTrue(all("kind" in action for action in payload["actions"])) + + def test_activate_json_requires_dry_run(self) -> None: + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + activate.main(["--json"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("--json currently requires --dry-run", stderr.getvalue()) + def test_uninstall_dry_run_prints_target_host(self) -> None: output = io.StringIO() values = { @@ -7121,6 +7463,49 @@ def test_uninstall_yes_reboots_and_verifies(self) -> None: self.assertEqual(finished["device_came_back_after_reboot"], True) self.assertEqual(finished["post_uninstall_verified"], True) + def test_uninstall_mount_wait_and_no_wait_skip_reboot_observation_and_verify(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.load_env_config", return_value=self.make_app_config(values))) + mast_mocks = self._patch_mast_volume_flow(stack, "uninstall") + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) + reboot_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.remote_request_reboot")) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn")) + verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) + with redirect_stdout(output): + rc = uninstall.main(["--yes", "--mount-wait", "17", "--no-wait"]) + + self.assertEqual(rc, 0) + self.assertEqual(mast_mocks.mounted_mast_volumes_conn.call_args.kwargs["wait_seconds"], 17) + reboot_mock.assert_called_once() + wait_mock.assert_not_called() + verify_mock.assert_not_called() + self.assertIn("Post-uninstall verification skipped.", output.getvalue()) + + def test_uninstall_no_wait_fails_when_reboot_request_fails(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.load_env_config", return_value=self.make_app_config(values))) + self._patch_mast_volume_flow(stack, "uninstall") + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) + reboot_mock = stack.enter_context( + mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) + ) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn")) + verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) + with self.assertRaises(SystemExit) as raised: + with redirect_stdout(output): + uninstall.main(["--yes", "--no-wait"]) + + self.assertIn("ssh command failed with rc=255", str(raised.exception)) + reboot_mock.assert_called_once() + wait_mock.assert_not_called() + verify_mock.assert_not_called() + finished = self.telemetry_payload("uninstall_finished") + self.assertEqual(finished["result"], "failure") + def test_uninstall_reboot_request_timeout_continues_when_device_reboots(self) -> None: output = io.StringIO() values = self.make_valid_env() @@ -7367,6 +7752,43 @@ def test_fsck_no_wait_skips_ssh_waits(self) -> None: self.assertEqual(rc, 0) observe_mock.assert_not_called() + def test_fsck_list_volumes_mounts_with_custom_wait_without_remote_fsck(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.load_env_config", return_value=self.make_app_config(values))) + mast_mocks = self._patch_mast_volume_flow( + stack, + "fsck", + mounted_volumes=(self._mast_volume("dk2"), self._mast_volume("dk5", builtin=False)), + ) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh")) + with redirect_stdout(output): + rc = fsck.main(["--list-volumes", "--mount-wait", "11"]) + + self.assertEqual(rc, 0) + self.assertEqual(mast_mocks.mounted_mast_volumes_conn.call_args.kwargs["wait_seconds"], 11) + run_ssh_mock.assert_not_called() + self.assertIn("Mounted HFS volumes:", output.getvalue()) + self.assertIn("/dev/dk5", output.getvalue()) + + def test_fsck_dry_run_selects_target_without_remote_fsck_or_prompt(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.load_env_config", return_value=self.make_app_config(values))) + self._patch_mast_volume_flow(stack, "fsck", mounted_volumes=(self._mast_volume("dk2"),)) + input_mock = stack.enter_context(mock.patch("builtins.input", side_effect=AssertionError("dry run should not prompt"))) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh")) + with redirect_stdout(output): + rc = fsck.main(["--dry-run"]) + + self.assertEqual(rc, 0) + input_mock.assert_not_called() + run_ssh_mock.assert_not_called() + self.assertIn("Dry run: fsck plan", output.getvalue()) + self.assertIn("/sbin/fsck_hfs -fy /dev/dk2", output.getvalue()) + def test_fsck_no_reboot_omits_reboot_and_waits(self) -> None: output = io.StringIO() values = { diff --git a/tests/test_cli_flows.py b/tests/test_cli_flows.py index f86eb63..dbaf4aa 100644 --- a/tests/test_cli_flows.py +++ b/tests/test_cli_flows.py @@ -288,6 +288,20 @@ def test_request_ssh_reboot_records_timeout_without_raising(self) -> None: self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "Timed out waiting for ssh command to finish: reboot") self.assertIn("SSH reboot request timed out; checking whether the device is rebooting...", output.getvalue()) + def test_request_ssh_reboot_raises_timeout_when_request_error_is_required(self) -> None: + command_context = FakeCommandContext() + with mock.patch( + "timecapsulesmb.cli.flows.remote_request_reboot", + side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), + ): + with self.assertRaises(SshCommandTimeout): + request_ssh_reboot(self.make_connection(), command_context, raise_on_request_error=True) + + self.assertEqual(command_context.debug_fields["reboot_request_strategy"], "ssh") + self.assertEqual(command_context.debug_fields["ssh_reboot_succeeded"], False) + self.assertEqual(command_context.debug_fields["ssh_reboot_timed_out"], True) + self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "Timed out waiting for ssh command to finish: reboot") + def test_request_ssh_reboot_records_ssh_error_without_raising(self) -> None: command_context = FakeCommandContext() output = io.StringIO() @@ -300,6 +314,16 @@ def test_request_ssh_reboot_records_ssh_error_without_raising(self) -> None: self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "ssh failed") self.assertIn("SSH reboot request failed; checking whether the device is rebooting anyway...", output.getvalue()) + def test_request_ssh_reboot_raises_ssh_error_when_request_error_is_required(self) -> None: + command_context = FakeCommandContext() + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh failed")): + with self.assertRaises(SshError): + request_ssh_reboot(self.make_connection(), command_context, raise_on_request_error=True) + + self.assertEqual(command_context.debug_fields["reboot_request_strategy"], "ssh") + self.assertEqual(command_context.debug_fields["ssh_reboot_succeeded"], False) + self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "ssh failed") + def test_request_reboot_and_wait_fails_when_device_never_goes_down_after_acp_request(self) -> None: command_context = FakeCommandContext() output = io.StringIO() diff --git a/tests/test_config.py b/tests/test_config.py index 1a20280..f310863 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,6 +25,7 @@ parse_bool, parse_env_file, parse_env_value, + preserved_env_file_values, require_valid_app_config, render_env_text, validate_app_config, @@ -146,8 +147,44 @@ def test_render_env_text_contains_config_keys(self) -> None: self.assertNotIn("TC_PAYLOAD_DIR_NAME", rendered) self.assertIn("TC_INTERNAL_SHARE_USE_DISK_ROOT=false", rendered) self.assertIn("TC_ANY_PROTOCOL=false", rendered) + self.assertIn("TC_DEBUG_LOGGING=false", rendered) self.assertIn("TC_CONFIGURE_ID=12345678-1234-1234-1234-123456789012", rendered) + def test_render_env_text_preserves_custom_settings_but_omits_deprecated_keys(self) -> None: + values = dict(DEFAULTS) + values.update({ + "TC_PASSWORD": "secret", + "TC_CUSTOM_SETTING": "kept value", + "CUSTOM_FLAG": "", + "TC_SAMBA_USER": "admin", + "TC_PAYLOAD_DIR_NAME": "samba4", + "TC_MDNS_INSTANCE_NAME": "old-name", + }) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + path.write_text(render_env_text(values)) + reparsed = parse_env_file(path) + + self.assertEqual(reparsed["TC_CUSTOM_SETTING"], "kept value") + self.assertEqual(reparsed["CUSTOM_FLAG"], "") + self.assertNotIn("TC_SAMBA_USER", reparsed) + self.assertNotIn("TC_PAYLOAD_DIR_NAME", reparsed) + self.assertNotIn("TC_MDNS_INSTANCE_NAME", reparsed) + + def test_preserved_env_file_values_filters_deprecated_runtime_keys(self) -> None: + values = { + "TC_HOST": "root@10.0.0.2", + "TC_CUSTOM_SETTING": "kept", + "TC_AIRPORT_SYAP": "119", + "TC_MDNS_DEVICE_MODEL": "TimeCapsule8,119", + "NET_IPV4_HINT": "10.0.0.2", + } + + preserved = preserved_env_file_values(values) + + self.assertEqual(preserved, {"TC_HOST": "root@10.0.0.2", "TC_CUSTOM_SETTING": "kept"}) + def test_env_example_does_not_include_runtime_derived_settings(self) -> None: values = parse_env_file(REPO_ROOT / ".env.example") self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) @@ -473,6 +510,12 @@ def test_validate_app_config_uses_profiles(self) -> None: errors = validate_app_config(config, profile="deploy") self.assertEqual(errors[0].kind, "invalid_value") self.assertEqual(errors[0].key, "TC_ANY_PROTOCOL") + values["TC_ANY_PROTOCOL"] = "false" + values["TC_DEBUG_LOGGING"] = "not-bool" + config = AppConfig.from_values(values, file_values=values) + errors = validate_app_config(config, profile="deploy") + self.assertEqual(errors[0].kind, "invalid_value") + self.assertEqual(errors[0].key, "TC_DEBUG_LOGGING") def test_flash_profile_ignores_deploy_only_settings(self) -> None: values = dict(DEFAULTS) @@ -485,6 +528,7 @@ def test_flash_profile_ignores_deploy_only_settings(self) -> None: values["TC_PAYLOAD_DIR_NAME"] = "/bad" values["TC_INTERNAL_SHARE_USE_DISK_ROOT"] = "not-bool" values["TC_ANY_PROTOCOL"] = "not-bool" + values["TC_DEBUG_LOGGING"] = "not-bool" config = AppConfig.from_values(values, file_values=values) self.assertEqual(validate_app_config(config, profile="flash"), []) diff --git a/tests/test_discovery_devices.py b/tests/test_discovery_devices.py new file mode 100644 index 0000000..1724a90 --- /dev/null +++ b/tests/test_discovery_devices.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import unittest + +from timecapsulesmb.discovery.bonjour import BonjourResolvedService +from timecapsulesmb.discovery.devices import device_candidate_to_jsonable, device_candidates_from_records + + +class DiscoveryDeviceCandidateTests(unittest.TestCase): + def test_builds_selectable_devices_from_airport_records_and_prefers_lan_ipv4(self) -> None: + records = [ + self.record("James", "_adisk._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("James", "_airport._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("James", "_device-info._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("James", "_smb._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("Office", "_adisk._tcp.local.", ["10.0.0.9"]), + self.record("Office", "_airport._tcp.local.", ["10.0.0.9"]), + self.record("Office", "_device-info._tcp.local.", ["10.0.0.9"]), + self.record("Office", "_smb._tcp.local.", ["10.0.0.9"]), + ] + + devices = device_candidates_from_records(records) + + self.assertEqual([device.name for device in devices], ["James", "Office"]) + self.assertEqual(devices[0].host, "192.168.1.217") + self.assertEqual(devices[0].ssh_host, "root@192.168.1.217") + self.assertEqual(devices[0].preferred_ipv4, "192.168.1.217") + self.assertFalse(devices[0].link_local_only) + self.assertEqual(devices[0].selected_record.service_type, "_airport._tcp.local.") + + def test_ignores_non_airport_records_even_when_they_have_time_capsule_metadata(self) -> None: + records = [ + self.record("SMB Only", "_smb._tcp.local.", ["10.0.0.2"], syap="119"), + self.record("Device Info", "_device-info._tcp.local.", ["10.0.0.2"], syap="119"), + ] + + self.assertEqual(device_candidates_from_records(records), []) + + def test_cli_can_build_candidates_from_already_filtered_mock_records(self) -> None: + records = [ + self.record("SMB Only", "_smb._tcp.local.", ["10.0.0.2"], syap="", model=""), + ] + + devices = device_candidates_from_records(records, airport_only=False) + + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].host, "10.0.0.2") + self.assertEqual(devices[0].selected_record.service_type, "_smb._tcp.local.") + + def test_dedupes_repeated_airport_records_and_keeps_best_address_candidate(self) -> None: + records = [ + self.record("Office", "_airport._tcp.local.", ["169.254.44.9"], hostname="office.local."), + self.record("Office", "_airport._tcp.local.", ["169.254.44.9", "10.0.0.2"], hostname="office.local."), + ] + + devices = device_candidates_from_records(records) + + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].host, "10.0.0.2") + self.assertEqual(devices[0].addresses, ("169.254.44.9", "10.0.0.2")) + + def test_link_local_only_candidate_is_explicit_and_does_not_produce_ssh_host(self) -> None: + devices = device_candidates_from_records([ + self.record("Office", "_airport._tcp.local.", ["169.254.44.9"], hostname="office.local.") + ]) + + device = devices[0] + self.assertEqual(device.host, "office.local.") + self.assertIsNone(device.ssh_host) + self.assertIsNone(device.preferred_ipv4) + self.assertTrue(device.link_local_only) + + def test_json_payload_keeps_raw_selected_record_for_configure(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="119", model="TimeCapsule8,119") + device = device_candidates_from_records([record])[0] + + payload = device_candidate_to_jsonable(device) + + self.assertEqual(payload["host"], "10.0.0.2") + self.assertEqual(payload["ssh_host"], "root@10.0.0.2") + self.assertEqual(payload["syap"], "119") + self.assertEqual(payload["model"], "TimeCapsule8,119") + self.assertEqual(payload["selected_record"]["fullname"], "Office._airport._tcp.local.") + self.assertEqual(payload["selected_record"]["ipv4"], ["10.0.0.2"]) + + def record( + self, + name: str, + service_type: str, + ipv4: list[str], + *, + hostname: str | None = None, + syap: str = "119", + model: str = "TimeCapsule8,119", + ) -> BonjourResolvedService: + return BonjourResolvedService( + name=name, + hostname=hostname or f"{name.lower()}.local.", + service_type=service_type, + port=5009, + ipv4=ipv4, + properties={"syAP": syap, "model": model}, + fullname=f"{name}.{service_type}", + ) diff --git a/tests/test_storage_runtime.py b/tests/test_storage_runtime.py index 2b221d9..cc6198a 100644 --- a/tests/test_storage_runtime.py +++ b/tests/test_storage_runtime.py @@ -896,6 +896,19 @@ def test_flash_runtime_config_contains_runtime_settings_and_no_share_name(self) self.assertNotIn("MDNS_HOST_LABEL", rendered) self.assertNotIn("TC_SHARE_NAME", rendered) + def test_flash_runtime_config_uses_saved_debug_logging(self) -> None: + config = AppConfig.from_values({"TC_DEBUG_LOGGING": "true"}) + + rendered = render_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + ) + + self.assertIn("SMBD_DEBUG_LOGGING=1\n", rendered) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", rendered) + def test_common_runtime_identity_normalizers_match_python(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp)