diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d710d8c5..3f378cb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,66 @@ jobs: - name: Run full test suite run: python -m pytest + + swift-test: + name: Swift Tests (macOS) + runs-on: macos-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Show tool versions + run: | + xcodebuild -version + swift --version + + - name: Run Swift tests + run: swift test --package-path macos/TimeCapsuleSMB + + package-app: + name: Package App (macOS native) + runs-on: macos-latest + needs: + - swift-test + env: + HOMEBREW_NO_INSTALL_CLEANUP: "1" + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Restore package cache + uses: actions/cache@v4 + with: + path: macos/TimeCapsuleSMB/.build/package-app + key: package-app-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('macos/TimeCapsuleSMB/tools/package_app.py', 'macos/TimeCapsuleSMB/Package.swift', 'macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg', 'pyproject.toml', 'requirements.txt', 'src/**', 'bin/**') }} + restore-keys: | + package-app-${{ runner.os }}-${{ runner.arch }}- + + - name: Install native packaging tools + run: | + brew install samba + brew tap hudochenkov/sshpass + brew install sshpass + + smbclient="$(brew --prefix samba)/bin/smbclient" + sshpass="$(brew --prefix sshpass)/bin/sshpass" + + test -x "$smbclient" + test -x "$sshpass" + file "$smbclient" "$sshpass" + lipo -archs "$smbclient" + lipo -archs "$sshpass" + + { + echo "TCAPSULE_PACKAGE_SMBCLIENT=$smbclient" + echo "TCAPSULE_PACKAGE_SSHPASS=$sshpass" + } >> "$GITHUB_ENV" + + - name: Package and validate native app + run: | + python3 macos/TimeCapsuleSMB/tools/package_app.py \ + --configuration release \ + --arch native \ + --full-validation diff --git a/.gitignore b/.gitignore index 965ba7a0..2fa6dbf3 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/DETAIL.md b/DETAIL.md index 10957bfa..0a2cc66b 100644 --- a/DETAIL.md +++ b/DETAIL.md @@ -14,14 +14,15 @@ What is working now: - static tiny SMB / Time Machine mDNS advertiser - static NBNS responder for NetBIOS name discovery - boot-time runtime staging via `/mnt/Flash/rc.local` -- boot-time watchdog for `smbd`, the mDNS helper, and the NBNS helper when enabled +- boot-time manager for `smbd`, the mDNS helper, and the NBNS helper when enabled - direct SMB service on port `445` - Bonjour advertisement for: - managed `_smb._tcp` - managed `_adisk._tcp` - - Apple-cloned `_airport._tcp` - - Apple-cloned `_afpovertcp._tcp` - - other Apple-cloned records + - generated `_device-info._tcp` + - generated `_airport._tcp` + - generated `_afpovertcp._tcp` + - generated USB printer records when applicable - authenticated SMB access using: - examples and docs use Samba username `admin` - generated auth stores a `root` Samba account @@ -128,13 +129,11 @@ The actual working split is: - tiny persistent boot hook on flash: - `/mnt/Flash/rc.local` - `/mnt/Flash/common.sh` - - `/mnt/Flash/start-samba.sh` - - `/mnt/Flash/watchdog.sh` + - `/mnt/Flash/boot.sh` + - `/mnt/Flash/manager.sh` - `/mnt/Flash/dfree.sh` - `/mnt/Flash/mdns-advertiser` - `/mnt/Flash/tcapsulesmb.conf` - - `/mnt/Flash/allmdns.txt` - - `/mnt/Flash/applemdns.txt` - transient runtime on RAM disk: - `/mnt/Memory/samba4` - `/mnt/Locks` @@ -252,7 +251,7 @@ Current maintainer build lanes: The direct scripts target the NetBSD 7 lane by default. The `*oldle.sh` and `*oldbe.sh` wrappers select the NetBSD 4 little-endian and big-endian lanes. -## Why We Snapshot Apple’s mDNS And Override Only SMB / Time Machine +## Why We Generate Apple-Compatible mDNS And Override SMB / Time Machine This was investigated deeply. @@ -270,25 +269,23 @@ Important findings: - some Apple-advertised services such as USB printer advertisements should be preserved if present - the current Samba runtime uses `MaSt` as the source of truth for volumes and ADISK UUIDs; it does not read `/etc/cifs/cs_cfg.txt` -So the current system does not fully replace Apple mDNS with a hardcoded record set. Instead it uses a separate tiny helper: +So the current system does not hand control back to Apple mDNS for SMB and Time Machine, but it also does not discard the Apple device identity users expect. Instead it uses a separate tiny helper: - [bin/mdns/mdns-advertiser](bin/mdns/mdns-advertiser) This helper: -- can save a raw LAN-wide mDNS snapshot to `/mnt/Flash/allmdns.txt` -- can generate an AirPort-only Apple identity snapshot at `/mnt/Flash/applemdns.txt` -- normally captures a filtered Apple identity snapshot before takeover -- can fall back to generated AirPort identity records when capture does not produce `applemdns.txt` -- gracefully kills Apple `mDNSResponder` during takeover -- replays Apple snapshot records afterward -- overrides only: +- derives Apple-compatible `_airport._tcp` fields from local AirPort identity values +- can advertise USB printer services when a local AirPort printer identity is present +- advertises managed records for: - `_smb._tcp.local.` - `_adisk._tcp.local.` +- can suppress managed SMB/ADISK records in diskless mode while keeping generated AirPort identity records +- aggressively terminates Apple `mDNSResponder` during takeover and binds UDP `5353` - continues to point clients at our `smbd` on port `445` Current practical result: - Our `_smb._tcp` and `_adisk._tcp` remain authoritative -- Apple `_airport._tcp` and other records can be preserved -- snapshot replay preserves non-ASCII or binary host targets via `HOST_HEX` +- Apple `_airport._tcp` identity can still be advertised for AirPort Utility +- attached USB printer advertisements can be generated from local AirPort printer metadata ## Bonjour Discovery Boundaries @@ -301,92 +298,54 @@ That distinction matters: Do not merge `_airport`, `_smb`, and `_device-info` records inside `bonjour.discover()`. Merging service records creates ambiguous objects with one name/hostname but multiple meanings, and it causes duplicate-looking or misleading configure/doctor output. The stored `service_type` should remain the raw observed value. Callers should filter raw discovery results by the service prefix they actually need, such as `_airport` for configure and `_smb` for doctor/deploy. Prefix filtering intentionally matches both `_smb._tcp.local.` and `_smb._tcp.local`. -## Apple mDNS Snapshot File - -The mDNS snapshot files are: - -- `/mnt/Flash/allmdns.txt` -- `/mnt/Flash/applemdns.txt` +## Generated mDNS Records Current behavior: -- `start-samba.sh` stages the managed Samba runtime and launches `watchdog.sh` -- the watchdog starts `mdns-advertiser --save-all-snapshot ... --save-snapshot ...` once a usable IPv4 is available -- the final `mdns-advertiser --load-snapshot` phase waits for capture to finish or time out before killing `mDNSResponder` -- if capture does not produce `applemdns.txt`, `mdns-advertiser --save-airport-snapshot` writes a generated local `_airport._tcp` fallback from values read directly on the device -- `mdns-advertiser --load-snapshot` then kills `mDNSResponder` and replays the captured or generated snapshot -- if snapshot load fails, the helper falls back to the generated managed records - -The raw `allmdns.txt` file is intentionally diagnostic and may contain all Apple records that were captured on the LAN. - -The `applemdns.txt` file is the one used for replay: -- the preferred path contains captured records tied to the matching local `_airport._tcp` identity, including supported non-SMB Apple services such as printer advertisements -- the fallback path contains a generated `_airport._tcp` record for the local unit -- if capture cannot be tied back to the local unit, `applemdns.txt` is not refreshed and the generated fallback is used -- if no local identity MACs are available, the helper saves the raw capture for diagnostics but still refuses to trust it for replay - -However, on replay: -- `_smb._tcp` from the snapshot is ignored -- `_adisk._tcp` from the snapshot is ignored -- our managed `_smb._tcp` and `_adisk._tcp` are advertised instead +- `boot.sh` prepares the RAM runtime and launches `manager.sh` +- `manager.sh` waits for usable network addresses, payload state, and AirPort identity data +- the manager launches `mdns-advertiser` from `/mnt/Flash` with `--generated-airport-services` +- `mdns-advertiser` generates managed `_smb._tcp`, `_adisk._tcp`, `_device-info._tcp`, and `_airport._tcp` records from live runtime state +- when a USB printer is attached and discoverable through AirPort metadata, the manager also passes `_riousbprint._tcp` and `_pdl-datastream._tcp` arguments +- if the disk payload is unavailable, the manager can launch the advertiser in diskless mode so AirPort identity remains visible while SMB/ADISK records are suppressed +- `mdns-advertiser` kills Apple `mDNSResponder` during takeover and keeps UDP `5353` owned by the managed helper + +The old snapshot files `/mnt/Flash/allmdns.txt` and `/mnt/Flash/applemdns.txt` are not part of the active runtime path. Deploy and uninstall deliberately leave those files alone if they exist from older experiments because they are diagnostic artifacts, not current managed state. ## Boot Flow In Detail The boot logic lives in: - [src/timecapsulesmb/assets/boot/samba4/rc.local](src/timecapsulesmb/assets/boot/samba4/rc.local) -- [src/timecapsulesmb/assets/boot/samba4/start-samba.sh](src/timecapsulesmb/assets/boot/samba4/start-samba.sh) -- [src/timecapsulesmb/assets/boot/samba4/watchdog.sh](src/timecapsulesmb/assets/boot/samba4/watchdog.sh) +- [src/timecapsulesmb/assets/boot/samba4/boot.sh](src/timecapsulesmb/assets/boot/samba4/boot.sh) +- [src/timecapsulesmb/assets/boot/samba4/manager.sh](src/timecapsulesmb/assets/boot/samba4/manager.sh) +- [src/timecapsulesmb/assets/boot/samba4/common.d/](src/timecapsulesmb/assets/boot/samba4/common.d) ### `rc.local` -`rc.local` is intentionally tiny. It just backgrounds `start-samba.sh`. +`rc.local` is intentionally tiny. It just backgrounds `boot.sh`. This matters because: - boot ordering is messy - the HDD device nodes may not exist yet when `rc.local` first runs - a longer wait loop belongs in the second-stage script, not directly inline in the boot hook -### `start-samba.sh` +### `boot.sh` -`start-samba.sh` does the real work: +`boot.sh` performs the one-shot startup preparation: 1. sources `/mnt/Flash/common.sh` and `/mnt/Flash/tcapsulesmb.conf` -2. kills any prior managed `smbd`, mDNS advertiser, NBNS responder, and watchdog +2. kills any prior managed `smbd`, mDNS advertiser, NBNS responder, and manager 3. prepares the dedicated Samba lock ramdisk at `/mnt/Locks` 4. recreates the RAM runtime tree under `/mnt/Memory/samba4` -5. auto-discovers usable IPv4 CIDRs for Samba binding from the current device interfaces -6. reads valid HFS partitions from `/usr/bin/acp -A MaSt` -7. writes the current `MaSt` rows to the runtime topology signature -8. requests `diskd.useVolume` for every valid `MaSt` volume -9. polls all candidate volumes for one shared `APPLE_MOUNT_WAIT_SECONDS` window, default `30` -10. leaves still-unmounted volumes unavailable; the boot runtime no longer falls back to `mount_hfs` -11. builds RAM state files under `/mnt/Memory/samba4/var`: - - `shares.tsv` - - `adisk.tsv` - - `topology.signature` -12. applies share path rules: - - external volumes always share `/Volumes/dkN` - - internal volumes share `/Volumes/dkN/ShareRoot` unless `INTERNAL_SHARE_USE_DISK_ROOT=1` - - internal `ShareRoot` is created when needed -13. resolves the persistent payload by scanning mounted `MaSt` volumes in internal-first order for `.samba4` -14. writes `payload.tsv` so the watchdog can find the selected payload volume/device later -15. configures payload runtime logs under `/logs/` -16. copies `smbd`, auth files, and optional `nbns-advertiser` into RAM -17. generates `/mnt/Memory/samba4/etc/smb.conf` directly from runtime state -18. starts `smbd` and waits up to `15` seconds for the IPv4 TCP `445` listener -19. starts `watchdog.sh` with no disk/root positional arguments - -The watchdog owns mDNS and NBNS startup after `smbd` is running. This keeps advertiser recovery in the same supervisor path that handles later service failures. +5. prepares compatibility symlinks under `/root` +6. starts `manager.sh` if it is not already running + +The manager owns disk discovery, Samba staging, service startup, mDNS takeover, NBNS startup, and later recovery. This keeps boot short and puts all recurring runtime reconciliation in one process. The boot log is written to: - `/mnt/Memory/samba4/var/rc.local.log` -Supported `start-samba.sh` modes: -- `--print-topology-signature` prints the current `MaSt` topology for watchdog comparison. -- `--refresh-disk-state` is diagnostic-only: it refreshes disk state files but does not stop services, regenerate live `smb.conf`, or restart Samba, mDNS, NBNS, or watchdog. -- `--reload-disk-runtime` is the live recovery path used by watchdog: it refreshes disk state, restages the runtime, regenerates `smb.conf`, and starts managed services. - Long-running process logs are written under: -- `/logs/watchdog.log` +- `/logs/manager.log` - `/logs/mdns.log` - `/logs/nbns.log` - `/logs/log.smbd` @@ -413,41 +372,49 @@ Current mount behavior: - if the NetBSD 4 mfs mount fails, startup aborts instead of falling back to the tiny root filesystem Operational behavior: -- `start-samba.sh` clears `/mnt/Locks/*` before starting `smbd` -- `watchdog.sh` also clears `/mnt/Locks/*` before restarting `smbd` +- `boot.sh` clears `/mnt/Locks/*` during startup preparation +- `manager.sh` clears `/mnt/Locks/*` before restarting `smbd` -### `watchdog.sh` +### `manager.sh` -`watchdog.sh` is a simple long-running supervisor launched at boot from flash. +`manager.sh` is the long-running supervisor launched at boot from flash. Current behavior: - runs a disk/topology pass every `10` seconds -- runs a managed service pass every `30` seconds -- sleeps `10` seconds after a failed recovery pass before trying again +- runs a Samba bind pass every `10` seconds by default +- runs a full managed service pass every `30` seconds +- retries failed recovery work on the next due pass - reads `MaSt` directly through the shared runtime helpers -- if disk topology changed, live-reloads when possible and falls back to `/mnt/Flash/start-samba.sh --reload-disk-runtime` when a full runtime restart is required -- remounts every active share volume from `shares.tsv`; if share state is unavailable, the runtime is treated as unhealthy and reloaded +- debounces disk topology changes before applying runtime updates +- requests `diskd.useVolume` for valid `MaSt` volumes and builds current share/ADISK state from mounted volumes +- applies share path rules: + - external volumes always share `/Volumes/dkN` + - internal volumes share `/Volumes/dkN/ShareRoot` unless `INTERNAL_SHARE_USE_DISK_ROOT=1` + - internal `ShareRoot` is created when needed +- resolves the persistent payload by scanning mounted `MaSt` volumes in internal-first order for `.samba4` +- writes current `adisk.tsv` under `/mnt/Memory/samba4/var` +- copies `smbd`, auth files, and optional `nbns-advertiser` into RAM when inputs change +- generates `/mnt/Memory/samba4/etc/smb.conf` directly from runtime state +- starts or reloads `smbd` as needed and keeps it bound to the current interfaces +- starts generated mDNS advertisement from `/mnt/Flash/mdns-advertiser` +- starts NBNS when `NBNS_ENABLED=1` - if the payload volume is unavailable, stops managed Samba/mDNS/NBNS and retries later -- if only `smbd` is missing, starts it again from the RAM-staged binary and config -- if `mdns-advertiser` is missing, starts the snapshot capture/load path the first time and restarts it from the existing `adisk.tsv` after that -- if `NBNS_ENABLED=1` and `nbns-advertiser` is missing, restarts it +- if disk, identity, network, or USB printer state changes, refreshes the affected generated config and service state This is intentionally simple: - SMB transfers are not interrupted because `smbd` is only restarted when absent - the mDNS helper is also only restarted when absent - disk topology changes restart through the same path as boot, so share generation, mDNS, and smbd config stay coherent -The watchdog log is written to: -- `/logs/watchdog.log` when the payload volume is mounted -- `/mnt/Memory/samba4/var/watchdog.log` as a RAM fallback while the payload volume is unavailable +The manager log is written to: +- `/logs/manager.log` when the payload volume is mounted +- `/mnt/Memory/samba4/var/manager.log` as a RAM fallback while the payload volume is unavailable Important implementation detail: - `mdns-advertiser` is short enough to match directly with `pkill` -- the watchdog therefore uses the truncated process name for liveness checks and restarts +- the manager therefore uses the truncated process name for liveness checks and restarts NetBSD 4-specific shell note: -- `rc.local` uses a subshell-scoped `set +e` workaround only around the watchdog probe/start block -- this avoids a NetBSD 4 `/bin/sh` edge case where launching a background job from an `if` branch can make the script report status `1` - backgrounded jobs redirect stdin from `/dev/null` so they do not hold the SSH session open during manual activation ## SMB Runtime Layout @@ -531,7 +498,7 @@ Operational note: - the live runtime config at `/mnt/Memory/samba4/etc/smb.conf` is regenerated on each boot - `/mnt/Memory` is a RAM disk, so live edits there are ephemeral - temporary debug edits such as one-off `log level = ...` lines will disappear after reboot -- watchdog logs under `/mnt/Memory/samba4/var` are also ephemeral for the same reason +- manager logs under `/mnt/Memory/samba4/var` are also ephemeral for the same reason ## mDNS Advertiser Details @@ -553,12 +520,13 @@ Important properties: At runtime it can: - advertise managed `_smb._tcp.local.` - advertise managed `_adisk._tcp.local.` -- advertise loaded snapshot records -- optionally advertise fallback generated `_airport._tcp.local.` -- generate an AirPort-only Apple snapshot with `--save-airport-snapshot` -- save an Apple snapshot with `--save-snapshot` -- load and replay an Apple snapshot with `--load-snapshot` -- answer A queries for loaded snapshot host targets using the current runtime IPv4 +- advertise managed `_device-info._tcp.local.` +- advertise generated `_afpovertcp._tcp.local.` on port `548` +- advertise generated `_airport._tcp.local.` records from local AirPort identity fields +- optionally advertise `_riousbprint._tcp.local.` and `_pdl-datastream._tcp.local.` for an attached USB printer +- suppress SMB/ADISK records in diskless mode while preserving generated AirPort identity records +- aggressively take over UDP `5353` from Apple `mDNSResponder` +- track runtime interface changes in auto-IP mode Current validation and behavior notes: - mDNS host labels are validated as DNS-label-safe host labels @@ -686,7 +654,7 @@ Workflow details: ## Host-Side Architecture Current important package areas: -- [src/timecapsulesmb/cli/](src/timecapsulesmb/cli): command entrypoints for `bootstrap`, `paths`, `validate-install`, `discover`, `configure`, `set-ssh`, `deploy`, `activate`, `doctor`, `fsck`, `repair-xattrs`, and `uninstall` +- [src/timecapsulesmb/cli/](src/timecapsulesmb/cli): command entrypoints for `bootstrap`, `paths`, `validate-install`, `discover`, `configure`, `set-ssh`, `deploy`, `activate`, `doctor`, `fsck`, `repair-xattrs`, `uninstall`, and the app-facing `api` helper - [src/timecapsulesmb/core/](src/timecapsulesmb/core): shared config parsing, defaults, and common models - [src/timecapsulesmb/transport/](src/timecapsulesmb/transport): local command execution plus SSH and SCP helpers - [src/timecapsulesmb/discovery/](src/timecapsulesmb/discovery): Bonjour-based device discovery @@ -843,8 +811,8 @@ Current deploy flow: - renders and uploads the packaged boot/runtime files: - `rc.local` - `common.sh` - - `start-samba.sh` - - `watchdog.sh` + - `boot.sh` + - `manager.sh` - `dfree.sh` - generates and uploads flash runtime config: - `/mnt/Flash/tcapsulesmb.conf` @@ -869,13 +837,13 @@ Current compatibility behavior: - `configure` reuses the same classification logic for compatibility and displayed device identity NetBSD 4 activation behavior: -- `tcapsule deploy` uploads the NetBSD 4 payload, reboots, waits for SSH, watches for an already-running `/mnt/Flash/rc.local` or `/mnt/Flash/start-samba.sh` for up to 20 seconds, runs `/mnt/Flash/rc.local` only if startup is not already in progress, and verifies managed `smbd` plus mDNS takeover -- `tcapsule deploy --no-reboot` uploads the payload, stops the old watchdog plus `wcifsfs`, runs `/mnt/Flash/rc.local`, and verifies managed `smbd` plus mDNS takeover on both NetBSD 4 and NetBSD 6 devices +- `tcapsule deploy` uploads the NetBSD 4 payload, reboots, waits for SSH, watches for an already-running `/mnt/Flash/rc.local`, `/mnt/Flash/boot.sh`, or `/mnt/Flash/manager.sh`, runs `/mnt/Flash/rc.local` only if startup is not already in progress, and verifies managed `smbd` plus mDNS takeover +- `tcapsule deploy --no-reboot` uploads the payload, stops the manager plus any legacy watchdog process and `wcifsfs`, runs `/mnt/Flash/rc.local`, and verifies managed `smbd` plus mDNS takeover on both NetBSD 4 and NetBSD 6 devices - `tcapsule activate` repeats the no-reboot activation sequence without re-uploading files -- Apple `mDNSResponder` takeover is now handled inside `mdns-advertiser` when `--load-snapshot` is used +- Apple `mDNSResponder` takeover is handled inside `mdns-advertiser` during normal generated-advertisement startup - tested 1st-generation NetBSD 4 hardware does not persist an `/etc` boot hook and therefore needs manual activation after reboot - other NetBSD 4 generations may auto-start if their firmware runs `/mnt/Flash/rc.local` early in boot, but that is not yet proven -- `activate` is intentionally conservative: if `smbd` already owns TCP `445` and `mdns-advertiser` already owns UDP `5353`, or if `/mnt/Flash/rc.local` or `/mnt/Flash/start-samba.sh` is already running, it skips running `/mnt/Flash/rc.local` +- `activate` is intentionally conservative: if `smbd` already owns TCP `445` and `mdns-advertiser` already owns UDP `5353`, or if `/mnt/Flash/rc.local`, `/mnt/Flash/boot.sh`, or `/mnt/Flash/manager.sh` is already running, it skips running `/mnt/Flash/rc.local` The current password flow is: - `TC_PASSWORD` is also used as the Samba password @@ -905,6 +873,7 @@ Hidden operator mode: ## Client Telemetry Client telemetry is now emitted by: +- `tcapsule api` - `tcapsule bootstrap` - `tcapsule paths` - `tcapsule validate-install` @@ -920,6 +889,7 @@ Client telemetry is now emitted by: - `tcapsule uninstall` Current event model: +- app helper operations emit operation-specific app events through the `api` command - `bootstrap_started` - `bootstrap_finished` - `paths_started` @@ -960,7 +930,7 @@ Current transport behavior: ## Uninstall Current uninstall behavior: -- stops the watchdog first so it cannot restart `smbd` during teardown +- stops the manager first so it cannot restart `smbd` during teardown - removes the managed payload, flash hooks, runtime tree, and compatibility symlinks - runs remote uninstall actions sequentially over SSH - prompts before reboot by default @@ -997,14 +967,14 @@ Current important outputs: Current active deploy artifact sizes: - NetBSD 6 `smbd`: about `9.7M` -- NetBSD 6 `mdns-advertiser`: about `276K` -- NetBSD 6 `nbns-advertiser`: about `190K` +- NetBSD 6 `mdns-advertiser`: about `310K` +- NetBSD 6 `nbns-advertiser`: about `210K` - NetBSD 4 little-endian `smbd`: about `9.7M` - NetBSD 4 big-endian `smbd`: about `9.7M` -- NetBSD 4 little-endian `mdns-advertiser`: about `218K` -- NetBSD 4 big-endian `mdns-advertiser`: about `216K` -- NetBSD 4 little-endian `nbns-advertiser`: about `133K` -- NetBSD 4 big-endian `nbns-advertiser`: about `132K` +- NetBSD 4 little-endian `mdns-advertiser`: about `255K` +- NetBSD 4 big-endian `mdns-advertiser`: about `253K` +- NetBSD 4 little-endian `nbns-advertiser`: about `155K` +- NetBSD 4 big-endian `nbns-advertiser`: about `155K` It assumes: - a NetBSD VM diff --git a/GUI_ARCH.md b/GUI_ARCH.md new file mode 100644 index 00000000..58c5e328 --- /dev/null +++ b/GUI_ARCH.md @@ -0,0 +1,426 @@ +# 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 coordinated through one global `OperationCoordinator`, + but work is separated into lanes. Each lane has one active helper operation at + a time, while unrelated lanes can run independently. +- 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 + AppCloseGuard.swift + BundleLayout.swift + Backend/ + BackendClient.swift + BackendPayloads.swift + HelperLocator.swift + HelperRunner.swift + OperationParams.swift + PendingConfirmation.swift + Profiles/ + DeviceProfile.swift + DeviceProfileEditorStore.swift + DeviceRegistryStore.swift + PasswordStore.swift + Policies/ + DashboardActionPolicy.swift + DeviceStatusPolicy.swift + DoctorCheckDomainPolicy.swift + HostCompatibilityPolicy.swift + RecoveryActionMapper.swift + SMBAddressPolicy.swift + Workflows/ + ActivityStore.swift + AddDeviceFlowStore.swift + AppReadinessStore.swift + DashboardStore.swift + DeployWorkflowStore.swift + DeviceDashboardSession.swift + DeviceDiscoveryMonitorStore.swift + DoctorStore.swift + FlashWorkflowStore.swift + MaintenanceStore.swift + OperationCoordinator.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:) +``` + +The coordinator owns separate lanes: + +- `.app` for app readiness and global discovery +- `.device()` for profile-scoped operations +- `.candidateHost()` for unsaved device setup work +- `.localPath()` for local mounted-share maintenance + +Responsibilities: + +- reject a second operation in the same lane while that lane is busy +- allow unrelated lanes to run independently when their work cannot corrupt the + same profile or operation state +- expose active operation and active profile ID +- expose all active operations for Activity and close-guard behavior +- 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 +- Settings + +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 +- disabled NetBSD4 flash boot hook scaffold + +Settings contains device-level profile editing: + +- display name +- host/IP +- profile save/reset state +- advanced runtime defaults for deploy/reconfigure: + - mount wait + - ATA idle seconds + - ATA standby seconds + - NBNS enabled + - internal share uses disk root + - allow any SMB protocol + - force debug logging + +Raw events, helper path, readiness validation, profile ID, config path, and other +technical diagnostics belong in Diagnostics or compact Advanced disclosures, not +in the primary workflow controls. + +App-level Settings are a top-level sidebar surface and stay separate from the +device profile editor. They own: + +- defaults for newly added devices +- global Bonjour/checkup discovery timeout defaults +- telemetry preference +- helper path override +- Diagnostics raw-event display default +- update/version check controls +- Time Machine warning policy + +## Close Guard + +The app must route window close and Command-Q through shared close-guard +behavior. If any operation lane has active work or a pending confirmation, the +user gets a native confirmation before the app closes. + +This guard should be based on `OperationCoordinator.hasActiveWork`, not on a +single backend client, because operations can run on multiple lanes. + +## 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 on the same lane +- independent app, device, candidate, and local-path lane behavior +- 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 +- close guard behavior for window close and Command-Q + +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/Makefile b/Makefile index acecdf3a..c2e1eca4 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ # make venv - create local virtualenv at .venv # make install - install Python dependencies into .venv # make test - run C compile checks and Python pytest suite -# make test-parallel - run C compile checks and module-parallel test runner +# make test-parallel - run C compile checks and pytest-xdist suite # make coverage - run Python tests with coverage and show missing lines # make coverage-html - write an HTML coverage report to htmlcov/ # make test-c - compile-check mdns/nbns helper sources @@ -42,7 +42,7 @@ test: install test-c $(PY) -m pytest test-parallel: install test-c - PYTHONPATH=src $(PY) -m tests.run_parallel --jobs auto --verbose + $(PY) -m pytest -n auto --dist loadfile coverage: install $(PY) -m coverage run -m pytest diff --git a/README.md b/README.md index c077a4b4..44c2b5c5 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ By default, `tcapsule deploy` reboots after deployment and then waits for the de .venv/bin/tcapsule deploy --yes ``` -There are also other flags such as `--no-nbns`, `--no-reboot` and `--dry-run`, but leave those alone unless you have a specific reason to use them. `--no-reboot` uploads the files, stops the old watchdog plus `wcifsfs`, and starts the deployed runtime immediately by running `/mnt/Flash/rc.local`. +There are also other flags such as `--no-nbns`, `--no-reboot` and `--dry-run`, but leave those alone unless you have a specific reason to use them. `--no-reboot` uploads the files, stops the manager process and `wcifsfs`, and starts the deployed runtime immediately by running `/mnt/Flash/rc.local`. If you want a machine-readable deployment plan without changing the device, use: @@ -146,7 +146,7 @@ Advanced NetBSD 4 users can back up the firmware with: .venv/bin/tcapsule flash ``` -The command is read-only by default. On supported devices, `tcapsule flash --patch` can install the persistent boot hook and `tcapsule flash --restore` can restore the selected active bank from Apple stock firmware downloaded from Apple's catalog. Both write modes modify only one bank and leave the other flash bank untouched, then run validation by reading the written bank back after ACP accepts the write. If both banks pass the active-bank checks, use `--active-bank primary` or `--active-bank secondary` to choose explicitly. +The command is read-only by default. On supported devices, `tcapsule flash --patch` can install the persistent boot hook and `tcapsule flash --restore` can restore the selected active bank from Apple stock firmware downloaded from Apple's catalog. Both write modes modify only one bank and leave the other flash bank untouched, then run validation by reading the written bank back after ACP accepts the write. Patch mode normally requires exactly one safely selected active bank; `--force` is available only for patch mode and bypasses the backup/active-candidate preflight to target the primary bank. Patch mode cannot send a reboot or poweroff command after a successful write. After `tcapsule flash --patch` reports success, a user needs to manually unplug the device to reboot, and then wait a few minutes for the device to boot to run `tcapsule doctor`. Restore mode can request a software reboot with `tcapsule flash --restore --reboot`; after that, use `tcapsule flash --check-apple` to verify the active bank matches Apple stock firmware. @@ -195,7 +195,7 @@ Run: .venv/bin/tcapsule uninstall ``` -This removes the managed TimeCapsuleSMB payload from the internal disk and removes the loader files from `/mnt/Flash`. Apple wipes the filesystem on the device after every reboot, except for `/mnt/Flash`, so that's where we install the loader script. If you delete the 6 non-Apple files we put in `/mnt/Flash`, and delete the `.samba4` folder on the hard drive, and then reboot, you can restore your machine to factory clean condition. +This removes the managed TimeCapsuleSMB payload from the internal disk and removes the loader files from `/mnt/Flash`. Apple wipes the filesystem on the device after every reboot, except for `/mnt/Flash`, so that's where we install the loader scripts. If you delete the 7 payload files in `/mnt/Flash`, delete the `.samba4` folder on the hard drive, and then reboot, you can restore your machine to factory clean condition. By default `uninstall` asks before rebooting the Time Capsule. If you want to skip the reboot confirmation prompt, use: @@ -260,7 +260,8 @@ That is the reason the repository contains both: and boot files such as: - [src/timecapsulesmb/assets/boot/samba4/rc.local](src/timecapsulesmb/assets/boot/samba4/rc.local) -- [src/timecapsulesmb/assets/boot/samba4/start-samba.sh](src/timecapsulesmb/assets/boot/samba4/start-samba.sh) +- [src/timecapsulesmb/assets/boot/samba4/boot.sh](src/timecapsulesmb/assets/boot/samba4/boot.sh) +- [src/timecapsulesmb/assets/boot/samba4/manager.sh](src/timecapsulesmb/assets/boot/samba4/manager.sh) There are other constraints the Time Capsule places on us: - The NetBSD 6 source code does not support earmv4 builds, so we need to build from NetBSD 7. diff --git a/gui.md b/gui.md new file mode 100644 index 00000000..9cc1d99b --- /dev/null +++ b/gui.md @@ -0,0 +1,824 @@ +# 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 + - Activity + - Settings + - saved device rows + - Add Time Capsule + +Future top-level surfaces: + +- Help + +- Device detail area + - selected device summary + - primary action + - health and warnings + - workflow tabs: Overview, Install / Update, Checkup, Maintenance, Settings + +- 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 + +Current implementation status: + +- Overview has Connection, Runtime, and Checkup sections. +- Detailed Finder/Bonjour, SMB auth, Time Machine, disk, and metadata signals + are grouped in the Checkup tab. + +- Secondary actions + - Maintenance + - Uninstall + - Settings + +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 +- Persistent NetBSD4 Boot Hook, disabled in the current build + +Current implementation status: + +- NetBSD4 activation, disk repair, file metadata repair, and uninstall are + implemented as planned workflows with explicit state machines, dry-run plans + where applicable, confirmations, progress timelines, advanced options, and + typed backend payloads. +- Activation is hidden for devices that do not need NetBSD4 post-reboot + activation. +- Successful uninstall clears the saved deploy/install snapshot so the app no + longer presents the device as installed. +- The persistent NetBSD4 boot hook has a NetBSD4-only GUI scaffold, but the + read-only and write workflows are still disabled. + +### 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 remain disabled before release unless it has gone through separate +acceptance testing. The current GUI exposes a NetBSD4-only disabled scaffold for +this area under Maintenance. + +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: + +- visible only for NetBSD4 devices +- disabled in the current build +- read-only backup/analyze should be the first enabled mode +- write actions stay disabled in release builds until explicitly enabled + +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 + +Settings are split by scope. + +Device Settings are the fifth device dashboard tab and contain: + +- display name +- host/IP +- profile save/reset state +- runtime defaults under an Advanced disclosure: + - mount wait + - ATA idle seconds + - ATA standby seconds + - NBNS enabled + - internal share uses disk root + - allow any SMB protocol + - force debug logging + +App-level Settings are a separate top-level sidebar surface and contain: + +- new-device defaults for NBNS, SMB compatibility flags, debug logging, mount + wait, and ATA settings +- default Bonjour/checkup timeout +- telemetry preference +- helper path override +- Diagnostics raw-event display default +- update check on launch, manual update check, and version metadata URL override +- Time Machine warning policy + +Device-level settings still planned or only partially represented: + +- stored password status +- replace stored password entry point +- forget device +- refresh identity + +## Background Jobs + +The app already runs these without presenting them as commands: + +- app bundle validation +- payload manifest validation +- host macOS warning check +- periodic Bonjour discovery +- Keychain availability check + +Still planned: + +- lightweight selected-device reachability refresh + +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 + Persistent NetBSD4 Boot Hook (disabled) + Settings + device profile + advanced runtime defaults + +Diagnostics + app readiness + helper path + raw operation events + copy diagnostics + +Add Time Capsule + Discover + Manual Address + Authenticate + Enable SSH + Identify + Save + +Activity + current operation + historical operations + copied diagnostics + +Future App 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/Assets/AppIcon/tcs.jpg b/macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg new file mode 100644 index 00000000..8073ccfe Binary files /dev/null and b/macos/TimeCapsuleSMB/Assets/AppIcon/tcs.jpg differ diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift new file mode 100644 index 00000000..e6ba3184 --- /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(.v14)], + 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/App/AppCloseGuard.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift new file mode 100644 index 00000000..264c4170 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppCloseGuard.swift @@ -0,0 +1,197 @@ +import AppKit +import ObjectiveC +import SwiftUI + +enum AppCloseGuardRequest: Equatable { + case windowClose + case appQuit +} + +struct AppCloseGuardPrompt: Equatable { + let title: String + let message: String + let cancelTitle: String + let confirmTitle: String + + static var activeOperation: AppCloseGuardPrompt { + AppCloseGuardPrompt( + title: L10n.string("close_guard.title"), + message: L10n.string("close_guard.message"), + cancelTitle: L10n.string("close_guard.keep_open"), + confirmTitle: L10n.string("close_guard.close_anyway") + ) + } +} + +private struct AppCloseGuardPolicy { + var hasBlockingActivity: () -> Bool = { false } + + var requiresConfirmation: Bool { + hasBlockingActivity() + } +} + +@MainActor +protocol AppCloseGuardPresenting: AnyObject { + func confirmClose( + _ prompt: AppCloseGuardPrompt, + for request: AppCloseGuardRequest, + modalFor window: NSWindow?, + completion: @escaping @MainActor (Bool) -> Void + ) +} + +@MainActor +private final class AppCloseGuardAlertPresenter: AppCloseGuardPresenting { + func confirmClose( + _ prompt: AppCloseGuardPrompt, + for _: AppCloseGuardRequest, + modalFor window: NSWindow?, + completion: @escaping @MainActor (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = prompt.title + alert.informativeText = prompt.message + alert.addButton(withTitle: prompt.cancelTitle) + alert.addButton(withTitle: prompt.confirmTitle) + + if let window, window.isVisible { + alert.beginSheetModal(for: window) { response in + completion(response == .alertSecondButtonReturn) + } + return + } + + DispatchQueue.main.async { + let response = alert.runModal() + completion(response == .alertSecondButtonReturn) + } + } +} + +@MainActor +public final class AppCloseGuard: NSObject { + public static let shared = AppCloseGuard() + + var presenter: AppCloseGuardPresenting = AppCloseGuardAlertPresenter() + + private var policy = AppCloseGuardPolicy() + + public func configure(hasBlockingActivity: @escaping () -> Bool) { + policy = AppCloseGuardPolicy(hasBlockingActivity: hasBlockingActivity) + } + + func shouldCloseWindow(_ window: NSWindow) -> Bool { + guard policy.requiresConfirmation else { + return true + } + presenter.confirmClose( + AppCloseGuardPrompt.activeOperation, + for: .windowClose, + modalFor: window + ) { [weak window] confirmed in + guard confirmed, let window else { + return + } + window.close() + } + return false + } + + func shouldTerminateApplication(_ application: NSApplication) -> NSApplication.TerminateReply { + guard policy.requiresConfirmation else { + return .terminateNow + } + presenter.confirmClose( + AppCloseGuardPrompt.activeOperation, + for: .appQuit, + modalFor: application.keyWindow ?? application.mainWindow + ) { confirmed in + application.reply(toApplicationShouldTerminate: confirmed) + } + return .terminateLater + } + + func attach(to window: NSWindow) { + if window.delegate is GuardedWindowDelegate { + return + } + let delegate = GuardedWindowDelegate(downstream: window.delegate, closeGuard: self) + objc_setAssociatedObject(window, &windowCloseGuardDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + window.delegate = delegate + } +} + +@MainActor +public final class AppCloseGuardApplicationDelegate: NSObject, NSApplicationDelegate { + var closeGuard: AppCloseGuard = .shared + + public override init() { + super.init() + } + + public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + closeGuard.shouldTerminateApplication(sender) + } +} + +private var windowCloseGuardDelegateKey: UInt8 = 0 + +private final class GuardedWindowDelegate: NSObject, NSWindowDelegate { + private weak var downstream: NSWindowDelegate? + private let closeGuard: AppCloseGuard + + init(downstream: NSWindowDelegate?, closeGuard: AppCloseGuard) { + self.downstream = downstream + self.closeGuard = closeGuard + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + if let downstreamAllows = downstream?.windowShouldClose?(sender), !downstreamAllows { + return false + } + return closeGuard.shouldCloseWindow(sender) + } + + func windowWillClose(_ notification: Notification) { + downstream?.windowWillClose?(notification) + } + + override func responds(to aSelector: Selector!) -> Bool { + if super.responds(to: aSelector) { + return true + } + return downstream?.responds(to: aSelector) ?? false + } + + override func forwardingTarget(for aSelector: Selector!) -> Any? { + guard let downstream, downstream.responds(to: aSelector) else { + return super.forwardingTarget(for: aSelector) + } + return downstream + } +} + +struct WindowCloseGuardInstaller: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + GuardedWindowAnchorView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let window = nsView.window else { + return + } + AppCloseGuard.shared.attach(to: window) + } + + private final class GuardedWindowAnchorView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { + return + } + AppCloseGuard.shared.attach(to: window) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppResourceBundle.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppResourceBundle.swift new file mode 100644 index 00000000..3e1bb2d2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppResourceBundle.swift @@ -0,0 +1,83 @@ +import Foundation + +enum AppResourceBundleLocator { + static let bundleDirectoryName = "TimeCapsuleSMBMac_TimeCapsuleSMBApp.bundle" + + static func bundleURL( + appBundleURL: URL = Bundle.main.bundleURL, + resourceURL: URL? = Bundle.main.resourceURL, + fileManager: FileManager = .default + ) -> URL? { + for candidate in candidateURLs(appBundleURL: appBundleURL, resourceURL: resourceURL) { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue { + return candidate + } + } + return nil + } + + static func candidateURLs(appBundleURL: URL, resourceURL: URL?) -> [URL] { + var candidates: [URL] = [] + if let resourceURL { + candidates.append(resourceURL.appendingPathComponent(bundleDirectoryName, isDirectory: true)) + } + candidates.append(appBundleURL.appendingPathComponent("Contents/Resources", isDirectory: true) + .appendingPathComponent(bundleDirectoryName, isDirectory: true)) + candidates.append(appBundleURL.appendingPathComponent(bundleDirectoryName, isDirectory: true)) + candidates.append(appBundleURL.deletingLastPathComponent() + .appendingPathComponent(bundleDirectoryName, isDirectory: true)) + + var seen: Set = [] + return candidates.filter { url in + let key = url.standardizedFileURL.path + if seen.contains(key) { + return false + } + seen.insert(key) + return true + } + } +} + +enum AppResourceBundle { + static var bundle: Bundle { + resolvedBundle + } + + static var bundleURL: URL? { + resolvedBundle.bundleURL + } + + private static let resolvedBundle: Bundle = { + if let url = AppResourceBundleLocator.bundleURL(), + let bundle = Bundle(url: url) { + return bundle + } + #if DEBUG + return Bundle.module + #else + return Bundle.main + #endif + }() +} + +public enum AppLaunchResourceValidation { + public static func validate() -> String? { + guard let bundleURL = AppResourceBundle.bundleURL else { + return "TimeCapsuleSMB resource bundle could not be located." + } + + let localizable = bundleURL + .appendingPathComponent("en.lproj", isDirectory: true) + .appendingPathComponent("Localizable.strings") + guard FileManager.default.isReadableFile(atPath: localizable.path) else { + return "TimeCapsuleSMB resource bundle is missing en.lproj/Localizable.strings." + } + + guard L10n.string("screen.readiness", language: .english) == "Readiness" else { + return "TimeCapsuleSMB localized strings did not load from the resource bundle." + } + return nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppRoute.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppRoute.swift new file mode 100644 index 00000000..2116d9d9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppRoute.swift @@ -0,0 +1,31 @@ +import Foundation + +enum AppRoute: Equatable, Hashable, Identifiable { + case allDevices + case activity + case appSettings + case addDevice + case device(DeviceProfile.ID) + + var id: String { + switch self { + case .allDevices: + return "all" + case .activity: + return "activity" + case .appSettings: + return "settings" + case .addDevice: + return "add" + case .device(let profileID): + return "device:\(profileID)" + } + } + + var selectedDeviceID: DeviceProfile.ID? { + guard case .device(let profileID) = self else { + return nil + } + return profileID + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift new file mode 100644 index 00000000..6455f794 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppSettings.swift @@ -0,0 +1,507 @@ +import Combine +import Foundation + +enum AppLanguage: String, CaseIterable, Codable, Identifiable, Equatable { + case system + case english = "en" + case simplifiedChinese = "zh-Hans" + + var id: String { + rawValue + } + + var title: String { + switch self { + case .system: + return L10n.string("app_language.system") + case .english: + return L10n.string("app_language.english") + case .simplifiedChinese: + return L10n.string("app_language.simplified_chinese") + } + } + + var localizationIdentifier: String? { + switch self { + case .system: + return nil + case .english: + return rawValue + case .simplifiedChinese: + return rawValue + } + } + + var locale: Locale { + switch self { + case .system: + return .current + case .english: + return Locale(identifier: "en") + case .simplifiedChinese: + return Locale(identifier: "zh-Hans-CN") + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = AppLanguage(rawValue: rawValue) ?? .system + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +enum AppAppearance: String, CaseIterable, Codable, Identifiable, Equatable { + case system + case light + case dark + + var id: String { + rawValue + } + + var title: String { + switch self { + case .system: + return L10n.string("app_appearance.system") + case .light: + return L10n.string("app_appearance.light") + case .dark: + return L10n.string("app_appearance.dark") + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = AppAppearance(rawValue: rawValue) ?? .system + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +struct AppSettings: Codable, Equatable { + var language: AppLanguage + var appearance: AppAppearance + var defaultBonjourTimeoutSeconds: Double + var defaultDeviceSettings: DeviceProfileSettings + var telemetryEnabled: Bool + var helperPathOverride: String + var showRawBackendEventsByDefault: Bool + var checkForUpdatesOnLaunch: Bool + var versionCheckURL: String + var timeMachineWarningsEnabled: Bool + + static let `default` = AppSettings( + language: .system, + appearance: .system, + defaultBonjourTimeoutSeconds: 6, + defaultDeviceSettings: .default, + telemetryEnabled: true, + helperPathOverride: "", + showRawBackendEventsByDefault: true, + checkForUpdatesOnLaunch: true, + versionCheckURL: "", + timeMachineWarningsEnabled: true + ) + + init( + language: AppLanguage = .system, + appearance: AppAppearance = .system, + defaultBonjourTimeoutSeconds: Double, + defaultDeviceSettings: DeviceProfileSettings, + telemetryEnabled: Bool, + helperPathOverride: String, + showRawBackendEventsByDefault: Bool, + checkForUpdatesOnLaunch: Bool, + versionCheckURL: String, + timeMachineWarningsEnabled: Bool + ) { + self.language = language + self.appearance = appearance + self.defaultBonjourTimeoutSeconds = defaultBonjourTimeoutSeconds + self.defaultDeviceSettings = defaultDeviceSettings + self.telemetryEnabled = telemetryEnabled + self.helperPathOverride = helperPathOverride + self.showRawBackendEventsByDefault = showRawBackendEventsByDefault + self.checkForUpdatesOnLaunch = checkForUpdatesOnLaunch + self.versionCheckURL = versionCheckURL + self.timeMachineWarningsEnabled = timeMachineWarningsEnabled + } + + private enum CodingKeys: String, CodingKey { + case language + case appearance + case defaultBonjourTimeoutSeconds + case defaultDeviceSettings + case telemetryEnabled + case helperPathOverride + case showRawBackendEventsByDefault + case checkForUpdatesOnLaunch + case versionCheckURL + case timeMachineWarningsEnabled + } + + init(from decoder: Decoder) throws { + let defaults = Self.default + let container = try decoder.container(keyedBy: CodingKeys.self) + language = try container.decodeIfPresent(AppLanguage.self, forKey: .language) ?? defaults.language + appearance = try container.decodeIfPresent(AppAppearance.self, forKey: .appearance) ?? defaults.appearance + defaultBonjourTimeoutSeconds = Self.decodeNonNegativeDouble( + from: container, + forKey: .defaultBonjourTimeoutSeconds, + defaultValue: defaults.defaultBonjourTimeoutSeconds + ) + defaultDeviceSettings = try container.decodeIfPresent(DeviceProfileSettings.self, forKey: .defaultDeviceSettings) + ?? defaults.defaultDeviceSettings + telemetryEnabled = try container.decodeIfPresent(Bool.self, forKey: .telemetryEnabled) ?? defaults.telemetryEnabled + helperPathOverride = try container.decodeIfPresent(String.self, forKey: .helperPathOverride) ?? defaults.helperPathOverride + showRawBackendEventsByDefault = try container.decodeIfPresent(Bool.self, forKey: .showRawBackendEventsByDefault) + ?? defaults.showRawBackendEventsByDefault + checkForUpdatesOnLaunch = try container.decodeIfPresent(Bool.self, forKey: .checkForUpdatesOnLaunch) + ?? defaults.checkForUpdatesOnLaunch + versionCheckURL = try container.decodeIfPresent(String.self, forKey: .versionCheckURL) ?? defaults.versionCheckURL + timeMachineWarningsEnabled = try container.decodeIfPresent(Bool.self, forKey: .timeMachineWarningsEnabled) + ?? defaults.timeMachineWarningsEnabled + } + + private static func decodeNonNegativeDouble( + from container: KeyedDecodingContainer, + forKey key: CodingKeys, + defaultValue: Double + ) -> Double { + guard let value = try? container.decodeIfPresent(Double.self, forKey: key), + value.isFinite, + value >= 0 + else { + return defaultValue + } + return value + } +} + +enum AppSettingsValidationError: Equatable, LocalizedError { + case invalidBonjourTimeout + case invalidMountWait + case invalidAtaIdleSeconds + case invalidAtaStandby + case invalidVersionCheckURL + + var errorDescription: String? { + switch self { + case .invalidBonjourTimeout: + return L10n.string("app_settings.error.bonjour_timeout") + case .invalidMountWait: + return L10n.string("app_settings.error.mount_wait") + case .invalidAtaIdleSeconds: + return L10n.string("app_settings.error.ata_idle") + case .invalidAtaStandby: + return L10n.string("app_settings.error.ata_standby") + case .invalidVersionCheckURL: + return L10n.string("app_settings.error.version_url") + } + } +} + +enum AppSettingsState: String, Equatable { + case idle + case loading + case loaded + case saving + case failed +} + +enum AppSettingsStoreError: Equatable, LocalizedError { + case corruptSettings(String) + case io(String) + + var errorDescription: String? { + switch self { + case .corruptSettings(let message): + return L10n.format("app_settings.error.corrupt", message) + case .io(let message): + return message + } + } +} + +@MainActor +final class AppSettingsStore: ObservableObject { + @Published private(set) var state: AppSettingsState = .idle + @Published private(set) var settings: AppSettings = .default + @Published private(set) var error: AppSettingsStoreError? + + let settingsURL: URL + + private let repository: AppSettingsRepository + + convenience init() { + let appSupport = BundleLayout.applicationSupportDirectory() ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/TimeCapsuleSMB", isDirectory: true) + self.init(settingsURL: appSupport.appendingPathComponent("app-settings.json")) + } + + init(settingsURL: URL, fileManager: FileManager = .default) { + self.settingsURL = settingsURL + self.repository = AppSettingsRepository(settingsURL: settingsURL, fileManager: fileManager) + } + + func load() async { + state = .loading + error = nil + do { + settings = try await repository.load() + state = .loaded + } catch { + fail(error) + } + } + + func save(_ nextSettings: AppSettings) async throws { + state = .saving + error = nil + do { + try await repository.save(nextSettings) + settings = nextSettings + state = .loaded + } catch { + fail(error) + throw error + } + } + + func reset() async throws { + try await save(.default) + } + + private func fail(_ error: Error) { + if let appSettingsError = error as? AppSettingsStoreError { + self.error = appSettingsError + } else { + self.error = .io(error.localizedDescription) + } + state = .failed + } +} + +private actor AppSettingsRepository { + private let settingsURL: URL + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(settingsURL: URL, fileManager: FileManager) { + self.settingsURL = settingsURL + self.fileManager = fileManager + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + self.decoder = JSONDecoder() + } + + func load() throws -> AppSettings { + guard fileManager.fileExists(atPath: settingsURL.path) else { + return .default + } + do { + let data = try Data(contentsOf: settingsURL) + return try decoder.decode(AppSettings.self, from: data) + } catch let decoding as DecodingError { + throw AppSettingsStoreError.corruptSettings(String(describing: decoding)) + } catch let settingsError as AppSettingsStoreError { + throw settingsError + } catch { + throw AppSettingsStoreError.io(error.localizedDescription) + } + } + + func save(_ settings: AppSettings) throws { + do { + try fileManager.createDirectory( + at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try encoder.encode(settings) + try data.write(to: settingsURL, options: [.atomic]) + } catch { + throw AppSettingsStoreError.io(error.localizedDescription) + } + } +} + +struct AppSettingsDraft: Equatable { + var language: AppLanguage + var appearance: AppAppearance + var defaultBonjourTimeoutSeconds: String + var nbnsEnabled: Bool + var internalShareUseDiskRoot: Bool + var anyProtocol: Bool + var debugLogging: Bool + var mountWaitSeconds: String + var ataIdleSeconds: String + var ataStandby: String + var telemetryEnabled: Bool + var helperPathOverride: String + var showRawBackendEventsByDefault: Bool + var checkForUpdatesOnLaunch: Bool + var versionCheckURL: String + var timeMachineWarningsEnabled: Bool + + init(settings: AppSettings) { + language = settings.language + appearance = settings.appearance + defaultBonjourTimeoutSeconds = Self.formatDouble(settings.defaultBonjourTimeoutSeconds) + nbnsEnabled = settings.defaultDeviceSettings.nbnsEnabled + internalShareUseDiskRoot = settings.defaultDeviceSettings.internalShareUseDiskRoot + anyProtocol = settings.defaultDeviceSettings.anyProtocol + debugLogging = settings.defaultDeviceSettings.debugLogging + mountWaitSeconds = String(settings.defaultDeviceSettings.mountWaitSeconds) + ataIdleSeconds = String(settings.defaultDeviceSettings.ataIdleSeconds) + ataStandby = settings.defaultDeviceSettings.ataStandby.map(String.init) ?? "" + telemetryEnabled = settings.telemetryEnabled + helperPathOverride = settings.helperPathOverride + showRawBackendEventsByDefault = settings.showRawBackendEventsByDefault + checkForUpdatesOnLaunch = settings.checkForUpdatesOnLaunch + versionCheckURL = settings.versionCheckURL + timeMachineWarningsEnabled = settings.timeMachineWarningsEnabled + } + + func validatedSettings() throws -> AppSettings { + guard let bonjourTimeout = ValueParsers.nonNegativeDouble(defaultBonjourTimeoutSeconds) else { + throw AppSettingsValidationError.invalidBonjourTimeout + } + guard let mountWait = ValueParsers.nonNegativeInteger(mountWaitSeconds) else { + throw AppSettingsValidationError.invalidMountWait + } + guard let ataIdle = ValueParsers.nonNegativeInteger(ataIdleSeconds) else { + throw AppSettingsValidationError.invalidAtaIdleSeconds + } + let trimmedAtaStandby = ataStandby.trimmingCharacters(in: .whitespacesAndNewlines) + let parsedAtaStandby: Int? + if trimmedAtaStandby.isEmpty { + parsedAtaStandby = nil + } else if let value = ValueParsers.nonNegativeInteger(trimmedAtaStandby) { + parsedAtaStandby = value + } else { + throw AppSettingsValidationError.invalidAtaStandby + } + + let trimmedVersionURL = versionCheckURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedVersionURL.isEmpty, !Self.isHTTPURL(trimmedVersionURL) { + throw AppSettingsValidationError.invalidVersionCheckURL + } + + return AppSettings( + language: language, + appearance: appearance, + defaultBonjourTimeoutSeconds: bonjourTimeout, + defaultDeviceSettings: DeviceProfileSettings( + nbnsEnabled: nbnsEnabled, + internalShareUseDiskRoot: internalShareUseDiskRoot, + anyProtocol: anyProtocol, + debugLogging: debugLogging, + mountWaitSeconds: mountWait, + ataIdleSeconds: ataIdle, + ataStandby: parsedAtaStandby + ), + telemetryEnabled: telemetryEnabled, + helperPathOverride: helperPathOverride.trimmingCharacters(in: .whitespacesAndNewlines), + showRawBackendEventsByDefault: showRawBackendEventsByDefault, + checkForUpdatesOnLaunch: checkForUpdatesOnLaunch, + versionCheckURL: trimmedVersionURL, + timeMachineWarningsEnabled: timeMachineWarningsEnabled + ) + } + + private static func formatDouble(_ value: Double) -> String { + guard value.rounded() == value else { + return String(value) + } + return String(Int(value)) + } + + private static func isHTTPURL(_ text: String) -> Bool { + guard let url = URL(string: text), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host != nil + else { + return false + } + return true + } +} + +@MainActor +final class AppSettingsEditorStore: ObservableObject { + @Published var draft: AppSettingsDraft + @Published private(set) var baseline: AppSettings + @Published private(set) var isSaving = false + @Published private(set) var errorMessage: String? + + init(settings: AppSettings = .default) { + self.baseline = settings + self.draft = AppSettingsDraft(settings: settings) + } + + var hasChanges: Bool { + guard let settings = try? draft.validatedSettings() else { + return true + } + return settings != baseline + } + + var validationError: String? { + do { + _ = try draft.validatedSettings() + return nil + } catch { + return error.localizedDescription + } + } + + var canSave: Bool { + validationError == nil && hasChanges && !isSaving + } + + func sync(settings: AppSettings) { + guard !isSaving else { + return + } + baseline = settings + draft = AppSettingsDraft(settings: settings) + errorMessage = nil + } + + func resetDraft() { + draft = AppSettingsDraft(settings: baseline) + errorMessage = nil + } + + func restoreDefaultsDraft() { + draft = AppSettingsDraft(settings: .default) + errorMessage = nil + } + + func save(appStore: AppStore) async { + do { + let settings = try draft.validatedSettings() + isSaving = true + errorMessage = nil + try await appStore.saveAppSettings(settings) + baseline = settings + draft = AppSettingsDraft(settings: settings) + } catch { + errorMessage = error.localizedDescription + } + isSaving = false + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift new file mode 100644 index 00000000..be61d55c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppStore.swift @@ -0,0 +1,281 @@ +import Combine +import Foundation + +@MainActor +final class AppStore: ObservableObject { + @Published private(set) var route: AppRoute = .allDevices + + let appReadinessStore: AppReadinessStore + let appSettingsStore: AppSettingsStore + let appUpdateStore: AppUpdateStore + let deviceRegistry: DeviceRegistryStore + let operationCoordinator: OperationCoordinator + let passwordStore: PasswordStore + let profilePersistence: DeviceProfilePersistenceService + let activityStore: ActivityStore + let deviceDiscovery: DeviceDiscoveryStore + let reachabilityStore: DeviceReachabilityStore + + private var cancellables: Set = [] + + convenience init() { + let coordinator = OperationCoordinator() + self.init( + appReadinessStore: AppReadinessStore(backend: coordinator.appLane.backend), + appSettingsStore: AppSettingsStore(), + deviceRegistry: DeviceRegistryStore(), + operationCoordinator: coordinator, + passwordStore: KeychainPasswordStore(), + activityStore: ActivityStore(coordinator: coordinator) + ) + } + + init( + appReadinessStore: AppReadinessStore, + appSettingsStore: AppSettingsStore? = nil, + deviceRegistry: DeviceRegistryStore, + operationCoordinator: OperationCoordinator, + passwordStore: PasswordStore, + profilePersistence: DeviceProfilePersistenceService? = nil, + activityStore: ActivityStore? = nil, + appUpdateStore: AppUpdateStore? = nil, + deviceDiscovery: DeviceDiscoveryStore? = nil, + reachabilityStore: DeviceReachabilityStore? = nil + ) { + self.appReadinessStore = appReadinessStore + self.appSettingsStore = appSettingsStore ?? AppSettingsStore() + self.deviceRegistry = deviceRegistry + self.operationCoordinator = operationCoordinator + self.passwordStore = passwordStore + self.profilePersistence = profilePersistence ?? DeviceProfilePersistenceService( + registry: deviceRegistry, + passwordStore: passwordStore + ) + self.activityStore = activityStore ?? ActivityStore(coordinator: operationCoordinator) + self.appUpdateStore = appUpdateStore ?? AppUpdateStore(coordinator: operationCoordinator) + self.deviceDiscovery = deviceDiscovery ?? DeviceDiscoveryStore( + coordinator: operationCoordinator, + readinessStore: appReadinessStore, + registry: deviceRegistry + ) + self.reachabilityStore = reachabilityStore ?? DeviceReachabilityStore(coordinator: operationCoordinator) + + deviceRegistry.$profiles + .sink { [weak self] profiles in + self?.syncSelection(profiles: profiles) + } + .store(in: &cancellables) + } + + var selectedProfile: DeviceProfile? { + deviceRegistry.profile(id: selectedDeviceID) + } + + var selectedDeviceID: DeviceProfile.ID? { + route.selectedDeviceID + } + + var showingAddDevice: Bool { + route == .addDevice + } + + var showingActivity: Bool { + route == .activity + } + + var showingAppSettings: Bool { + route == .appSettings + } + + var backend: BackendClient { + operationCoordinator.appLane.backend + } + + func start() async { + await appSettingsStore.load() + applyAppSettings(appSettingsStore.settings) + await deviceRegistry.load() + await refreshPasswordStates() + appReadinessStore.start() + deviceDiscovery.startMonitoring() + if appSettingsStore.settings.checkForUpdatesOnLaunch { + appUpdateStore.checkNow(settings: appSettingsStore.settings) + } + } + + func navigate(to route: AppRoute) { + self.route = normalizedRoute(route) + } + + func select(_ profile: DeviceProfile) { + navigate(to: .device(profile.id)) + } + + func showAddDevice() { + navigate(to: .addDevice) + } + + func showActivity() { + navigate(to: .activity) + } + + func showAppSettings() { + navigate(to: .appSettings) + } + + func showAllDevices() { + navigate(to: .allDevices) + } + + func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary { + let passwordState = effectivePasswordState(for: profile) + let activeOperation = operationCoordinator.activeOperation(for: profile) + let displayStatus = DeviceStatusPolicy.status( + for: profile, + passwordState: passwordState, + activeOperation: activeOperation + ) + let primaryAction = DashboardPrimaryActionPolicy.primaryAction( + for: profile, + passwordState: passwordState, + activeOperation: activeOperation + ) + return DeviceDashboardSummary( + profile: profile, + passwordState: passwordState, + displayStatus: displayStatus, + primaryAction: primaryAction, + hostWarning: HostCompatibilityPolicy.warning(enabled: appSettingsStore.settings.timeMachineWarningsEnabled) + ) + } + + func saveAppSettings(_ settings: AppSettings) async throws { + let previousSettings = appSettingsStore.settings + try await appSettingsStore.save(settings) + applyAppSettings(settings) + if previousSettings.telemetryEnabled != settings.telemetryEnabled { + syncTelemetryPreference(settings.telemetryEnabled) + } + if previousSettings.helperPathOverride != settings.helperPathOverride + || readinessVersionCheck(for: previousSettings) != readinessVersionCheck(for: settings) + { + appReadinessStore.start() + } + } + + func password(for profile: DeviceProfile) -> String? { + profilePersistence.credential(for: profile).password + } + + @discardableResult + func saveProfileEdits( + profile: DeviceProfile, + fields: DeviceProfileEditableFields, + replacementPassword: String? = nil + ) async throws -> DeviceProfile { + try await profilePersistence.saveProfileEdits( + profile: profile, + fields: fields, + replacementPassword: replacementPassword + ) + } + + func forget(_ profile: DeviceProfile) async throws { + let wasSelectedProfile = selectedDeviceID == profile.id + try await profilePersistence.forget(profile) + if wasSelectedProfile { + route = firstProfileRoute() ?? .allDevices + } + } + + func refreshPasswordStates() async { + await profilePersistence.refreshCredentialStates() + } + + func diagnosticsExportContext(includeBackendEvents: Bool = true) -> DiagnosticsExportContext { + DiagnosticsExportContext( + generatedAt: Date(), + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development", + appBuild: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "development", + applicationSupportPath: appSettingsStore.settingsURL.deletingLastPathComponent().path, + helperPath: backend.helperPath, + appSettings: appSettingsStore.settings, + readinessState: appReadinessStore.state.kind, + readinessVersionPayload: appReadinessStore.versionCheckPayload, + capabilities: appReadinessStore.capabilities, + validation: appReadinessStore.validation, + runtimeIssues: appReadinessStore.issues, + updateState: appUpdateStore.state, + updatePayload: appUpdateStore.payload, + updateError: appUpdateStore.error, + selectedProfile: selectedProfile, + activeOperations: operationCoordinator.activeOperations, + pendingConfirmation: operationCoordinator.pendingConfirmation, + events: includeBackendEvents ? operationCoordinator.allLanes.flatMap { $0.backend.events } : [] + ) + } + + private func normalizedRoute(_ route: AppRoute) -> AppRoute { + guard case .device(let profileID) = route else { + return route + } + if deviceRegistry.profile(id: profileID) != nil { + return route + } + return firstProfileRoute() ?? .allDevices + } + + private func firstProfileRoute() -> AppRoute? { + deviceRegistry.profiles.first.map { .device($0.id) } + } + + private func effectivePasswordState(for profile: DeviceProfile) -> DevicePasswordState { + switch profilePersistence.credential(for: profile) { + case .available: + return .available + case .missing: + return .missing + case .invalid: + return .invalid + case .unavailable: + return .keychainUnavailable + } + } + + private func applyAppSettings(_ settings: AppSettings) { + let previousLanguage = L10n.currentLanguage + L10n.apply(language: settings.language) + if backend.helperPath != settings.helperPathOverride { + backend.helperPath = settings.helperPathOverride + } + appReadinessStore.applyVersionCheck(readinessVersionCheck(for: settings)) + deviceDiscovery.applyAppSettings(settings) + if previousLanguage != settings.language { + activityStore.refresh() + objectWillChange.send() + } + } + + private func readinessVersionCheck(for settings: AppSettings) -> AppReadinessVersionCheck { + AppReadinessVersionCheck(url: settings.versionCheckURL) + } + + private func syncTelemetryPreference(_ enabled: Bool) { + let params: [String: JSONValue] = ["enabled": .bool(enabled)] + _ = operationCoordinator.run( + operation: "set-telemetry", + params: params, + laneKey: .localPath("app-settings") + ) + } + + private func syncSelection(profiles: [DeviceProfile]) { + guard case .device(let selectedDeviceID) = route else { + return + } + if profiles.contains(where: { $0.id == selectedDeviceID }) { + return + } + route = profiles.first.map { .device($0.id) } ?? .allDevices + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppViewComposition.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppViewComposition.swift new file mode 100644 index 00000000..dbc0adb9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/AppViewComposition.swift @@ -0,0 +1,39 @@ +import Foundation + +@MainActor +struct AppViewComposition { + let appStore: AppStore + let addDeviceStore: AddDeviceFlowStore + let appSettingsEditorStore: AppSettingsEditorStore + let dashboardStore: DashboardStore + + static func production() -> AppViewComposition { + let appStore = AppStore() + return AppViewComposition(appStore: appStore) + } + + init(appStore: AppStore) { + self.appStore = appStore + self.addDeviceStore = AddDeviceFlowStore( + coordinator: appStore.operationCoordinator, + registry: appStore.deviceRegistry, + passwordStore: appStore.passwordStore, + profilePersistence: appStore.profilePersistence, + discovery: appStore.deviceDiscovery + ) + self.appSettingsEditorStore = AppSettingsEditorStore(settings: appStore.appSettingsStore.settings) + self.dashboardStore = DashboardStore(appStore: appStore) + } + + init( + appStore: AppStore, + addDeviceStore: AddDeviceFlowStore, + appSettingsEditorStore: AppSettingsEditorStore, + dashboardStore: DashboardStore + ) { + self.appStore = appStore + self.addDeviceStore = addDeviceStore + self.appSettingsEditorStore = appSettingsEditorStore + self.dashboardStore = dashboardStore + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift new file mode 100644 index 00000000..2ec6fede --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/BundleLayout.swift @@ -0,0 +1,344 @@ +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 pythonPackagesMissing + case distributionRootMissing + case artifactManifestMissing + case artifactManifestInvalid + case distributionArtifactsMissing + case toolsDirectoryMissing + case applicationSupportUnavailable + case stateDirectoryUnavailable + case unsupportedVersion + case versionMetadataUnavailable + case installValidationFailed + case helperLaunchFailed + case contractDecodeFailed + case operationFailed +} + +public struct BundleRuntimeIssue: Identifiable, Equatable, Sendable { + public var id: String { + "\(code.rawValue):\(messageOverride ?? ""):\(context ?? "")" + } + + public let code: BundleRuntimeIssueCode + public let severity: BundleRuntimeIssueSeverity + private let messageOverride: String? + private let recoveryOverride: String? + private let context: String? + + public var message: String { + messageOverride ?? Self.defaultMessage(for: code, context: context) + } + + public var recovery: String { + recoveryOverride ?? Self.defaultRecovery(for: code, context: context) + } + + public init( + code: BundleRuntimeIssueCode, + severity: BundleRuntimeIssueSeverity, + message: String? = nil, + recovery: String? = nil, + context: String? = nil + ) { + self.code = code + self.severity = severity + self.messageOverride = message + self.recoveryOverride = recovery + self.context = context + } + + private static func defaultMessage(for code: BundleRuntimeIssueCode, context: String?) -> String { + switch code { + case .helperMissing: + return L10n.string("bundle_issue.helper_missing.message") + case .helperNotExecutable: + return L10n.string("bundle_issue.helper_not_executable.message") + case .pythonPackagesMissing: + return L10n.string("bundle_issue.python_packages_missing.message") + case .distributionRootMissing: + return L10n.string("bundle_issue.distribution_root_missing.message") + case .artifactManifestMissing: + return L10n.string("bundle_issue.artifact_manifest_missing.message") + case .artifactManifestInvalid: + return L10n.string("bundle_issue.artifact_manifest_invalid.message") + case .distributionArtifactsMissing: + if let context, let count = Int(context) { + return L10n.format("bundle_issue.distribution_artifacts_missing_count.message", count) + } + return L10n.string("bundle_issue.distribution_artifacts_missing.message") + case .toolsDirectoryMissing: + return L10n.string("bundle_issue.tools_directory_missing.message") + case .applicationSupportUnavailable: + return L10n.string("bundle_issue.application_support_unavailable.message") + case .stateDirectoryUnavailable: + return L10n.string("bundle_issue.state_directory_unavailable.message") + case .unsupportedVersion: + return L10n.string("bundle_issue.unsupported_version.message") + case .versionMetadataUnavailable: + return L10n.string("bundle_issue.version_metadata_unavailable.message") + case .installValidationFailed: + return L10n.string("bundle_issue.install_validation_failed.message") + case .helperLaunchFailed: + return L10n.string("bundle_issue.helper_launch_failed.message") + case .contractDecodeFailed: + return L10n.string("bundle_issue.contract_decode_failed.message") + case .operationFailed: + return L10n.string("bundle_issue.operation_failed.message") + } + } + + private static func defaultRecovery(for code: BundleRuntimeIssueCode, context: String?) -> String { + switch code { + case .helperMissing, + .helperNotExecutable, + .pythonPackagesMissing, + .distributionRootMissing, + .artifactManifestMissing, + .artifactManifestInvalid, + .distributionArtifactsMissing: + return L10n.string("bundle_issue.recovery.reinstall") + case .toolsDirectoryMissing: + return L10n.string("bundle_issue.tools_directory_missing.recovery") + case .applicationSupportUnavailable: + return L10n.string("bundle_issue.application_support_unavailable.recovery") + case .stateDirectoryUnavailable: + return L10n.string("bundle_issue.state_directory_unavailable.recovery") + case .unsupportedVersion: + if let context, !context.isEmpty { + return L10n.format("app_readiness.recovery.update_required", context) + } + return L10n.string("bundle_issue.unsupported_version.recovery") + case .versionMetadataUnavailable: + return L10n.string("app_readiness.recovery.version_metadata_unavailable") + case .installValidationFailed: + return L10n.string("app_readiness.recovery.install_validation_failed") + case .helperLaunchFailed, + .operationFailed: + return L10n.string("app_readiness.recovery.retry_diagnostics") + case .contractDecodeFailed: + return L10n.string("app_readiness.recovery.contract_mismatch") + } + } +} + +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 artifactManifestURL: URL + public let toolsBinURL: URL + public let pythonPackagesURL: 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, + artifactManifestURL: URL? = nil, + toolsBinURL: URL? = nil, + pythonPackagesURL: URL? = nil, + applicationSupportURL: URL, + configURL: URL? = nil, + stateDirectoryURL: URL? = nil + ) { + self.appBundleURL = appBundleURL + self.executableURL = executableURL + self.resourceURL = resourceURL + self.helperURL = helperURL + let resolvedDistributionRoot = distributionRootURL ?? resourceURL.appendingPathComponent("Distribution", isDirectory: true) + self.distributionRootURL = resolvedDistributionRoot + self.artifactManifestURL = artifactManifestURL + ?? resolvedDistributionRoot.appendingPathComponent("artifact-manifest.json") + self.toolsBinURL = toolsBinURL ?? resourceURL.appendingPathComponent("Tools/bin", isDirectory: true) + self.pythonPackagesURL = pythonPackagesURL + ?? resourceURL + .appendingPathComponent("Python", isDirectory: true) + .appendingPathComponent("site-packages", isDirectory: true) + 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 + )) + } else if !fileManager.isExecutableFile(atPath: helperURL.path) { + issues.append(BundleRuntimeIssue( + code: .helperNotExecutable, + severity: .error + )) + } + if !isDirectory(pythonPackagesURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .pythonPackagesMissing, + severity: .error + )) + } + if !isDirectory(distributionRootURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .distributionRootMissing, + severity: .error + )) + } else { + let binURL = distributionRootURL.appendingPathComponent("bin", isDirectory: true) + if !isDirectory(binURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .distributionArtifactsMissing, + severity: .error + )) + } + issues.append(contentsOf: artifactManifestIssues(fileManager: fileManager)) + } + if !isDirectory(toolsBinURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .toolsDirectoryMissing, + severity: .warning + )) + } + if !isWritableDirectory(applicationSupportURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .applicationSupportUnavailable, + severity: .error + )) + } + if stateDirectoryURL != applicationSupportURL, + !isWritableDirectory(stateDirectoryURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .stateDirectoryUnavailable, + severity: .error + )) + } + return issues + } + + private func artifactManifestIssues(fileManager: FileManager) -> [BundleRuntimeIssue] { + guard fileManager.fileExists(atPath: artifactManifestURL.path) else { + return [BundleRuntimeIssue( + code: .artifactManifestMissing, + severity: .error + )] + } + do { + let data = try Data(contentsOf: artifactManifestURL) + let manifest = try JSONDecoder().decode(ArtifactManifest.self, from: data) + guard !manifest.artifactPaths.contains(where: isUnsafeArtifactPath) else { + return [BundleRuntimeIssue( + code: .artifactManifestInvalid, + severity: .error + )] + } + let missing = manifest.artifactPaths.filter { + !fileManager.fileExists(atPath: distributionRootURL.appendingPathComponent($0).path) + } + guard missing.isEmpty else { + return [BundleRuntimeIssue( + code: .distributionArtifactsMissing, + severity: .error, + context: "\(missing.count)" + )] + } + return [] + } catch { + return [BundleRuntimeIssue( + code: .artifactManifestInvalid, + severity: .error + )] + } + } + + private func isWritableDirectory(_ url: URL, fileManager: FileManager) -> Bool { + do { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } catch { + return false + } + guard isDirectory(url, fileManager: fileManager) else { + return false + } + let probe = url.appendingPathComponent(".timecapsulesmb-write-test-\(UUID().uuidString)") + do { + try Data().write(to: probe) + try? fileManager.removeItem(at: probe) + return true + } catch { + return false + } + } + + private func isUnsafeArtifactPath(_ path: String) -> Bool { + path.isEmpty + || path.hasPrefix("/") + || path.split(separator: "/").contains("..") + } + + private func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} + +private struct ArtifactManifest: Decodable { + let artifacts: [String: ArtifactRecord] + + var artifactPaths: [String] { + artifacts.values.map(\.path).sorted() + } +} + +private struct ArtifactRecord: Decodable { + let path: String +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/DiagnosticsExport.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/DiagnosticsExport.swift new file mode 100644 index 00000000..507133c0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/DiagnosticsExport.swift @@ -0,0 +1,262 @@ +import Foundation + +struct DiagnosticsExportContext { + var generatedAt: Date + var appVersion: String + var appBuild: String + var applicationSupportPath: String + var helperPath: String + var appSettings: AppSettings + var readinessState: AppReadinessStateKind + var readinessVersionPayload: VersionCheckPayload? + var capabilities: CapabilitiesPayload? + var validation: InstallValidationPayload? + var runtimeIssues: [BundleRuntimeIssue] + var updateState: AppUpdateState + var updatePayload: VersionCheckPayload? + var updateError: BackendErrorViewModel? + var selectedProfile: DeviceProfile? + var activeOperations: [OperationLaneKey: ActiveOperation] + var pendingConfirmation: PendingConfirmation? + var events: [BackendEvent] +} + +struct DiagnosticsExportBuilder { + var maxEvents = 50 + + func build(context: DiagnosticsExportContext) -> String { + var lines: [String] = [] + lines.append("TimeCapsuleSMB Diagnostics") + lines.append("Generated: \(format(date: context.generatedAt))") + lines.append("") + + appendSection("App", to: &lines) { lines in + append("Version", value: context.appVersion, to: &lines) + append("Build", value: context.appBuild, to: &lines) + append("Application Support", value: context.applicationSupportPath, to: &lines) + append("Helper Override", value: context.helperPath.isEmpty ? "auto" : context.helperPath, to: &lines) + } + + appendSection("Settings", to: &lines) { lines in + append("Appearance", value: context.appSettings.appearance.rawValue, to: &lines) + append("Telemetry Enabled", value: context.appSettings.telemetryEnabled, to: &lines) + append("Raw Events Default", value: context.appSettings.showRawBackendEventsByDefault, to: &lines) + append("Check Updates On Launch", value: context.appSettings.checkForUpdatesOnLaunch, to: &lines) + append("Version Check URL", value: context.appSettings.versionCheckURL.isEmpty ? "auto" : context.appSettings.versionCheckURL, to: &lines) + append("Time Machine Warnings", value: context.appSettings.timeMachineWarningsEnabled, to: &lines) + append("Default NBNS", value: context.appSettings.defaultDeviceSettings.nbnsEnabled, to: &lines) + append("Default Debug Logging", value: context.appSettings.defaultDeviceSettings.debugLogging, to: &lines) + append("Default Mount Wait", value: context.appSettings.defaultDeviceSettings.mountWaitSeconds, to: &lines) + append("Default ATA Idle", value: context.appSettings.defaultDeviceSettings.ataIdleSeconds, to: &lines) + append("Default ATA Standby", value: context.appSettings.defaultDeviceSettings.ataStandby.map(String.init) ?? "device default", to: &lines) + } + + appendSection("Readiness", to: &lines) { lines in + append("State", value: context.readinessState.title, to: &lines) + if let version = context.readinessVersionPayload { + append("Version Check", value: "\(version.summary) Source: \(version.source)", to: &lines) + } + if let capabilities = context.capabilities { + append("Helper Version", value: "\(capabilities.helperVersion) (\(capabilities.helperVersionCode))", to: &lines) + append("Distribution Root", value: capabilities.distributionRoot, to: &lines) + append("Artifact Manifest SHA256", value: capabilities.artifactManifestSHA256 ?? "missing", to: &lines) + append("Operations", value: capabilities.operations.sorted().joined(separator: ", "), to: &lines) + } + if let validation = context.validation { + append("Validation", value: validation.summary, to: &lines) + append("Validation Counts", value: sortedDescription(validation.counts), to: &lines) + for check in validation.checks { + append("Check \(check.id)", value: "\(check.ok ? "PASS" : "FAIL") - \(check.message)", to: &lines) + } + } + if context.runtimeIssues.isEmpty { + append("Runtime Issues", value: "none", to: &lines) + } else { + for issue in context.runtimeIssues { + append("Runtime Issue", value: "\(issue.severity.rawValue)/\(issue.code.rawValue): \(issue.message) Recovery: \(issue.recovery)", to: &lines) + } + } + } + + appendSection("Updates", to: &lines) { lines in + append("State", value: context.updateState.title, to: &lines) + if let payload = context.updatePayload { + append("Summary", value: payload.summary, to: &lines) + append("Source", value: payload.source, to: &lines) + append("Local Version Code", value: payload.localVersionCode, to: &lines) + append("Current Version", value: payload.currentVersion.map(String.init) ?? "unknown", to: &lines) + append("Minimum Supported Version", value: payload.minSupportedVersion.map(String.init) ?? "unknown", to: &lines) + append("Latest Tag", value: payload.latestTag ?? "unknown", to: &lines) + append("Download URL", value: payload.downloadURL, to: &lines) + } + if let error = context.updateError { + append("Error", value: "\(error.operation) \(error.code): \(error.message)", to: &lines) + } + } + + appendSection("Selected Device", to: &lines) { lines in + if let profile = context.selectedProfile { + append("ID", value: profile.id, to: &lines) + append("Name", value: profile.title, to: &lines) + append("Host", value: profile.displayTarget, to: &lines) + append("Model", value: profile.model ?? "unknown", to: &lines) + append("SYAP", value: profile.syap ?? "unknown", to: &lines) + append("OS", value: [profile.osName, profile.osRelease].compactMap { $0 }.joined(separator: " ").nilIfEmpty ?? "unknown", to: &lines) + append("Arch", value: profile.arch ?? "unknown", to: &lines) + append("Payload Family", value: profile.payloadFamily ?? "unknown", to: &lines) + append("Password State", value: profile.passwordState.title, to: &lines) + append("Last Checkup", value: profile.lastCheckup?.summary ?? "none", to: &lines) + append("Runtime State", value: profile.runtimeState?.localizedSummary ?? "unknown", to: &lines) + append("Last Deploy", value: profile.lastDeployState?.localizedSummary ?? "none", to: &lines) + } else { + append("Selected", value: "none", to: &lines) + } + } + + appendSection("Operations", to: &lines) { lines in + if context.activeOperations.isEmpty { + append("Active", value: "none", to: &lines) + } else { + for key in context.activeOperations.keys.sorted(by: { $0.description < $1.description }) { + guard let operation = context.activeOperations[key] else { continue } + append("Active \(key.description)", value: operation.operation, to: &lines) + } + } + if let confirmation = context.pendingConfirmation { + append("Pending Confirmation", value: "\(confirmation.operation): \(confirmation.title)", to: &lines) + } else { + append("Pending Confirmation", value: "none", to: &lines) + } + } + + appendSection("Backend Events", to: &lines) { lines in + let boundedEvents = context.events.suffix(maxEvents) + if boundedEvents.isEmpty { + append("Events", value: "none", to: &lines) + } else { + for event in boundedEvents { + append("Event", value: eventSummary(event), to: &lines) + } + } + } + + return lines.joined(separator: "\n") + } + + private func appendSection(_ title: String, to lines: inout [String], body: (inout [String]) -> Void) { + lines.append("## \(title)") + body(&lines) + lines.append("") + } + + private func append(_ label: String, value: Bool, to lines: inout [String]) { + append(label, value: value ? "true" : "false", to: &lines) + } + + private func append(_ label: String, value: Int, to lines: inout [String]) { + append(label, value: String(value), to: &lines) + } + + private func append(_ label: String, value: String, to lines: inout [String]) { + lines.append("- \(label): \(redacted(value, key: label))") + } + + private func eventSummary(_ event: BackendEvent) -> String { + var parts = [ + event.type, + event.operation, + event.code, + event.stage, + event.status, + event.message + ].compactMap { $0?.nilIfEmpty } + if let payload = event.payload { + parts.append("payload=\(redacted(payload, key: "payload").compactDisplayText)") + } + if let details = event.details { + parts.append("details=\(redacted(details, key: "details").compactDisplayText)") + } + if let debug = event.debug { + parts.append("debug=\(redacted(debug, key: "debug").compactDisplayText)") + } + return parts.joined(separator: " | ") + } + + private func redacted(_ value: JSONValue, key: String?) -> JSONValue { + if shouldRedact(key: key) { + return .string("") + } + switch value { + case .object(let object): + return .object(object.mapValuesWithKeys { childKey, childValue in + redacted(childValue, key: childKey) + }) + case .array(let values): + return .array(values.map { redacted($0, key: key) }) + default: + return value + } + } + + private func redacted(_ value: String, key: String?) -> String { + shouldRedact(key: key) ? "" : value + } + + private func shouldRedact(key: String?) -> Bool { + guard let key = key?.lowercased() else { + return false + } + return key.contains("password") + || key.contains("token") + || key.contains("secret") + || key.contains("authorization") + || key.contains("api_key") + || key.contains("apikey") + || key.contains("private_key") + || key.contains("privatekey") + || key.contains("credentials") + } + + private func sortedDescription(_ values: [String: Int]) -> String { + values.keys.sorted().map { "\($0)=\(values[$0] ?? 0)" }.joined(separator: ", ") + } + + private func format(date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } +} + +private extension JSONValue { + var compactDisplayText: String { + guard let data = try? JSONEncoder.sortedCompact.encode(self), + let text = String(data: data, encoding: .utf8) + else { + return displayText + } + return text + } +} + +private extension JSONEncoder { + static var sortedCompact: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + } +} + +private extension Dictionary { + func mapValuesWithKeys(_ transform: (Key, Value) -> T) -> [Key: T] { + Dictionary(uniqueKeysWithValues: map { element in + (element.key, transform(element.key, element.value)) + }) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift new file mode 100644 index 00000000..17f11a7a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/Localization.swift @@ -0,0 +1,59 @@ +import Foundation + +enum L10n { + private static let lock = NSLock() + private static var selectedLanguage: AppLanguage = .system + + static var currentLanguage: AppLanguage { + lock.lock() + defer { lock.unlock() } + return selectedLanguage + } + + static func apply(language: AppLanguage) { + lock.lock() + selectedLanguage = language + lock.unlock() + } + + static func string(_ key: String) -> String { + string(key, language: currentLanguage) + } + + static func format(_ key: String, _ arguments: CVarArg...) -> String { + let language = currentLanguage + return String(format: string(key, language: language), locale: language.locale, arguments: arguments) + } + + static func string(_ key: String, language: AppLanguage) -> String { + let fallback = AppResourceBundle.bundle.localizedString(forKey: key, value: key, table: nil) + guard let bundle = bundle(for: language) else { + return fallback + } + return bundle.localizedString(forKey: key, value: fallback, table: nil) + } + + static func strings(language: AppLanguage) -> [String: String] { + guard let bundle = bundle(for: language) ?? bundle(for: .english), + let url = bundle.url(forResource: "Localizable", withExtension: "strings"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let strings = plist as? [String: String] else { + return [:] + } + return strings + } + + private static func bundle(for language: AppLanguage) -> Bundle? { + guard let identifier = language.localizationIdentifier else { + return nil + } + for candidate in [identifier, identifier.lowercased()] { + if let path = AppResourceBundle.bundle.path(forResource: candidate, ofType: "lproj"), + let bundle = Bundle(path: path) { + return bundle + } + } + return nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/URLOpening.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/URLOpening.swift new file mode 100644 index 00000000..4c06d7ef --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/App/URLOpening.swift @@ -0,0 +1,16 @@ +import Foundation +#if canImport(AppKit) +import AppKit +#endif + +protocol URLOpening { + func open(_ url: URL) +} + +struct WorkspaceURLOpener: URLOpening { + func open(_ url: URL) { + #if canImport(AppKit) + NSWorkspace.shared.open(url) + #endif + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift new file mode 100644 index 00000000..c993d230 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendClient.swift @@ -0,0 +1,174 @@ +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 makeSibling() -> BackendClient { + BackendClient(runner: runner, helperPath: helperPath) + } + + 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, + requestID: String = UUID().uuidString + ) { + 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, requestID: requestID) + 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, requestID] in + let result = await runner.run( + helperPath: helperPath.isEmpty ? nil : helperPath, + operation: operation, + params: runParams, + requestID: requestID, + 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, + requestID: confirmation.requestID + ) + } + + func cancelPendingConfirmation() { + guard let confirmation = pendingConfirmation, !isRunning else { return } + pendingConfirmation = nil + events.append(BackendEvent.error( + operation: confirmation.operation, + code: "confirmation_cancelled", + message: L10n.string("helper.error.cancelled"), + requestId: confirmation.requestID + )) + } + + 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, + requestID: activeCall.requestID, + 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? + let requestID: String +} + +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/Backend/BackendPayloadDecoding.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloadDecoding.swift new file mode 100644 index 00000000..bdac20fc --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/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/Backend/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift new file mode 100644 index 00000000..182a0acc --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendPayloads.swift @@ -0,0 +1,1094 @@ +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 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 VersionCheckPayload: Decodable, Equatable { + let schemaVersion: Int + let shouldBlock: Bool + let updateAvailable: Bool + let checkedURL: String + let message: String + let downloadURL: String + let localVersionCode: Int + let currentVersion: Int? + let minSupportedVersion: Int? + let latestTag: String? + let source: String + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case shouldBlock = "should_block" + case updateAvailable = "update_available" + case checkedURL = "checked_url" + case message + case downloadURL = "download_url" + case localVersionCode = "local_version_code" + case currentVersion = "current_version" + case minSupportedVersion = "min_supported_version" + case latestTag = "latest_tag" + case source + case summary + } +} + +struct ReachabilityPayload: Decodable, Equatable { + let schemaVersion: Int + let status: String + let sshHost: String? + let smbHost: String? + let checks: [ReachabilityCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case status + case sshHost = "ssh_host" + case smbHost = "smb_host" + case checks + case counts + case summary + } +} + +struct ReachabilityCheckPayload: Decodable, Equatable { + let id: String + let status: String + let message: String + let host: String? + let detail: String? +} + +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) ?? [] + } +} + +enum DeployStartupMode: String, Decodable, Equatable { + case rebootThenVerify = "reboot_then_verify" + case rebootThenActivate = "reboot_then_activate" + case activateNow = "activate_now" + + static func fallback(netbsd4: Bool, requiresReboot: Bool) -> DeployStartupMode { + if !requiresReboot { + return .activateNow + } + return netbsd4 ? .rebootThenActivate : .rebootThenVerify + } +} + +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 startupMode: DeployStartupMode + 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 startupMode = "startup_mode" + 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.startupMode = try container.decodeIfPresent(DeployStartupMode.self, forKey: .startupMode) + ?? DeployStartupMode.fallback(netbsd4: netbsd4, requiresReboot: requiresReboot) + 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 FlashBankPayload: Decodable, Equatable, Identifiable { + let name: String + let device: String + let size: Int + let sha256: String + let backupValid: Bool + let activeCandidate: Bool + let wouldWrite: Bool + let writeDecision: String + let login: JSONValue? + let footer: JSONValue? + let patch: JSONValue? + let patchError: String? + let analysisError: String? + + var id: String { name } + + enum CodingKeys: String, CodingKey { + case name + case device + case size + case sha256 + case backupValid = "backup_valid" + case activeCandidate = "active_candidate" + case wouldWrite = "would_write" + case writeDecision = "write_decision" + case login + case footer + case patch + case patchError = "patch_error" + case analysisError = "analysis_error" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.device = try container.decodeIfPresent(String.self, forKey: .device) ?? "" + self.size = try container.decodeIfPresent(Int.self, forKey: .size) ?? 0 + self.sha256 = try container.decodeIfPresent(String.self, forKey: .sha256) ?? "" + self.backupValid = try container.decodeIfPresent(Bool.self, forKey: .backupValid) ?? false + self.activeCandidate = try container.decodeIfPresent(Bool.self, forKey: .activeCandidate) ?? false + self.wouldWrite = try container.decodeIfPresent(Bool.self, forKey: .wouldWrite) ?? false + self.writeDecision = try container.decodeIfPresent(String.self, forKey: .writeDecision) ?? "" + self.login = try container.decodeIfPresent(JSONValue.self, forKey: .login) + self.footer = try container.decodeIfPresent(JSONValue.self, forKey: .footer) + self.patch = try container.decodeIfPresent(JSONValue.self, forKey: .patch) + self.patchError = try container.decodeIfPresent(String.self, forKey: .patchError) + self.analysisError = try container.decodeIfPresent(String.self, forKey: .analysisError) + } +} + +struct FlashBackupPayload: Decodable, Equatable { + let schemaVersion: Int + let backupDir: String + let host: String? + let syap: String? + let deviceModel: String? + let osRelease: String? + let activeBank: String? + let banks: [FlashBankPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case backupDir = "backup_dir" + case host + case syap + case deviceModel = "device_model" + case osRelease = "os_release" + case activeBank = "active_bank" + case banks + 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.backupDir = try container.decode(String.self, forKey: .backupDir) + self.host = try container.decodeIfPresent(String.self, forKey: .host) + self.syap = try container.decodeIfPresent(String.self, forKey: .syap) + self.deviceModel = try container.decodeIfPresent(String.self, forKey: .deviceModel) + self.osRelease = try container.decodeIfPresent(String.self, forKey: .osRelease) + self.activeBank = try container.decodeIfPresent(String.self, forKey: .activeBank) + self.banks = try container.decodeIfPresent([FlashBankPayload].self, forKey: .banks) ?? [] + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct FlashAppleFirmwareMatchPayload: Decodable, Equatable { + let matched: Bool + let templateSource: String + let templatePath: String? + let templateProductID: String? + let templateVersion: String? + let templateSHA256: String? + let innerSHA256: String? + let innerSize: Int? + let keyID: String? + let innerModel: Int? + let innerVersion: String? + + enum CodingKeys: String, CodingKey { + case matched + case templateSource = "template_source" + case templatePath = "template_path" + case templateProductID = "template_product_id" + case templateVersion = "template_version" + case templateSHA256 = "template_sha256" + case innerSHA256 = "inner_sha256" + case innerSize = "inner_size" + case keyID = "key_id" + case innerModel = "inner_model" + case innerVersion = "inner_version" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.matched = try container.decodeIfPresent(Bool.self, forKey: .matched) ?? false + self.templateSource = try container.decodeIfPresent(String.self, forKey: .templateSource) ?? "" + self.templatePath = try container.decodeIfPresent(String.self, forKey: .templatePath) + self.templateProductID = try container.decodeIfPresent(String.self, forKey: .templateProductID) + self.templateVersion = try container.decodeIfPresent(String.self, forKey: .templateVersion) + self.templateSHA256 = try container.decodeIfPresent(String.self, forKey: .templateSHA256) + self.innerSHA256 = try container.decodeIfPresent(String.self, forKey: .innerSHA256) + self.innerSize = try container.decodeIfPresent(Int.self, forKey: .innerSize) + self.keyID = try container.decodeIfPresent(String.self, forKey: .keyID) + self.innerModel = try container.decodeIfPresent(Int.self, forKey: .innerModel) + self.innerVersion = try container.decodeIfPresent(String.self, forKey: .innerVersion) + } +} + +struct FlashFirmwarePayload: Decodable, Equatable { + let templateSource: String + let templatePath: String? + let templateProductID: String? + let templateVersion: String? + let templateSHA256: String? + let payloadSHA256: String? + let payloadSize: Int? + let expectedPrefixSHA256: String? + let expectedPrefixSize: Int? + let expectedLoginClassification: String? + let keyID: String? + let innerModel: Int? + let innerVersion: String? + let innerPayloadSize: Int? + + enum CodingKeys: String, CodingKey { + case templateSource = "template_source" + case templatePath = "template_path" + case templateProductID = "template_product_id" + case templateVersion = "template_version" + case templateSHA256 = "template_sha256" + case payloadSHA256 = "payload_sha256" + case payloadSize = "payload_size" + case expectedPrefixSHA256 = "expected_prefix_sha256" + case expectedPrefixSize = "expected_prefix_size" + case expectedLoginClassification = "expected_login_classification" + case keyID = "key_id" + case innerModel = "inner_model" + case innerVersion = "inner_version" + case innerPayloadSize = "inner_payload_size" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.templateSource = try container.decodeIfPresent(String.self, forKey: .templateSource) ?? "" + self.templatePath = try container.decodeIfPresent(String.self, forKey: .templatePath) + self.templateProductID = try container.decodeIfPresent(String.self, forKey: .templateProductID) + self.templateVersion = try container.decodeIfPresent(String.self, forKey: .templateVersion) + self.templateSHA256 = try container.decodeIfPresent(String.self, forKey: .templateSHA256) + self.payloadSHA256 = try container.decodeIfPresent(String.self, forKey: .payloadSHA256) + self.payloadSize = try container.decodeIfPresent(Int.self, forKey: .payloadSize) + self.expectedPrefixSHA256 = try container.decodeIfPresent(String.self, forKey: .expectedPrefixSHA256) + self.expectedPrefixSize = try container.decodeIfPresent(Int.self, forKey: .expectedPrefixSize) + self.expectedLoginClassification = try container.decodeIfPresent(String.self, forKey: .expectedLoginClassification) + self.keyID = try container.decodeIfPresent(String.self, forKey: .keyID) + self.innerModel = try container.decodeIfPresent(Int.self, forKey: .innerModel) + self.innerVersion = try container.decodeIfPresent(String.self, forKey: .innerVersion) + self.innerPayloadSize = try container.decodeIfPresent(Int.self, forKey: .innerPayloadSize) + } +} + +struct FlashPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let backupDir: String + let mode: FlashPlanMode + let writeRequested: Bool + let alreadySatisfied: Bool + let activeBank: String? + let banks: [FlashBankPayload] + let flashPlan: JSONValue? + let appleFirmwareMatch: FlashAppleFirmwareMatchPayload? + let firmwarePayload: FlashFirmwarePayload? + let firmwarePayloadPath: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case backupDir = "backup_dir" + case mode + case writeRequested = "write_requested" + case alreadySatisfied = "already_satisfied" + case activeBank = "active_bank" + case banks + case flashPlan = "flash_plan" + case appleFirmwareMatch = "apple_firmware_match" + case firmwarePayload = "firmware_payload" + case firmwarePayloadPath = "firmware_payload_path" + 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.backupDir = try container.decode(String.self, forKey: .backupDir) + self.mode = try container.decodeIfPresent(FlashPlanMode.self, forKey: .mode) ?? .patch + self.writeRequested = try container.decodeIfPresent(Bool.self, forKey: .writeRequested) ?? false + self.alreadySatisfied = try container.decodeIfPresent(Bool.self, forKey: .alreadySatisfied) ?? false + self.activeBank = try container.decodeIfPresent(String.self, forKey: .activeBank) + self.banks = try container.decodeIfPresent([FlashBankPayload].self, forKey: .banks) ?? [] + self.flashPlan = try container.decodeIfPresent(JSONValue.self, forKey: .flashPlan) + self.appleFirmwareMatch = try container.decodeIfPresent(FlashAppleFirmwareMatchPayload.self, forKey: .appleFirmwareMatch) + self.firmwarePayload = try container.decodeIfPresent(FlashFirmwarePayload.self, forKey: .firmwarePayload) + self.firmwarePayloadPath = try container.decodeIfPresent(String.self, forKey: .firmwarePayloadPath) + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct FlashWritePayload: Decodable, Equatable { + let schemaVersion: Int + let backupDir: String + let mode: FlashPlanMode + let writeStatus: String + let writeValidated: Bool + let writeOutcome: JSONValue? + let writeResult: JSONValue? + let writeMayHaveModifiedDevice: Bool + let postWriteAction: String + let rebootRequested: Bool + let rebooted: Bool + let waitedAfterReboot: Bool + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case backupDir = "backup_dir" + case mode + case writeStatus = "write_status" + case writeValidated = "write_validated" + case writeOutcome = "write_outcome" + case writeResult = "write_result" + case postWriteAction = "post_write_action" + case rebootRequested = "reboot_requested" + case rebooted + case waitedAfterReboot = "waited_after_reboot" + 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.backupDir = try container.decode(String.self, forKey: .backupDir) + self.mode = try container.decodeIfPresent(FlashPlanMode.self, forKey: .mode) ?? .patch + self.writeStatus = try container.decodeIfPresent(String.self, forKey: .writeStatus) ?? "" + self.writeValidated = try container.decodeIfPresent(Bool.self, forKey: .writeValidated) ?? false + self.writeOutcome = try container.decodeIfPresent(JSONValue.self, forKey: .writeOutcome) + self.writeResult = try container.decodeIfPresent(JSONValue.self, forKey: .writeResult) + self.writeMayHaveModifiedDevice = Self.decodeWriteMayHaveModifiedDevice(from: writeOutcome) + self.postWriteAction = try container.decodeIfPresent(String.self, forKey: .postWriteAction) + ?? Self.stringValue(from: writeOutcome, key: "post_write_action") + ?? "" + self.rebootRequested = try container.decodeIfPresent(Bool.self, forKey: .rebootRequested) + ?? Self.boolValue(from: writeOutcome, key: "reboot_requested") + ?? false + self.rebooted = try container.decodeIfPresent(Bool.self, forKey: .rebooted) + ?? Self.boolValue(from: writeOutcome, key: "rebooted") + ?? false + self.waitedAfterReboot = try container.decodeIfPresent(Bool.self, forKey: .waitedAfterReboot) + ?? Self.boolValue(from: writeOutcome, key: "waited_after_reboot") + ?? false + self.summary = try container.decode(String.self, forKey: .summary) + } + + private static func decodeWriteMayHaveModifiedDevice(from value: JSONValue?) -> Bool { + boolValue(from: value, key: "write_may_have_modified_device") ?? false + } + + private static func stringValue(from value: JSONValue?, key: String) -> String? { + guard let value, case .object(let values) = value, case .string(let string)? = values[key] else { + return nil + } + return string + } + + private static func boolValue(from value: JSONValue?, key: String) -> Bool? { + guard let value, case .object(let values) = value else { + return nil + } + guard case .bool(let boolValue)? = values[key] else { + return nil + } + return boolValue + } +} + +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? + let localizationKey: 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" + case localizationKey = "localization_key" + } + + init( + title: String, + message: String?, + actions: [String], + actionIDs: [String], + retryable: Bool, + suggestedOperation: String?, + docsAnchor: String?, + localizationKey: String? = nil + ) { + self.title = title + self.message = message + self.actions = actions + self.actionIDs = actionIDs + self.retryable = retryable + self.suggestedOperation = suggestedOperation + self.docsAnchor = docsAnchor + self.localizationKey = localizationKey + } + + init(_ snapshot: DeviceRecoverySnapshot) { + self.init( + title: snapshot.title, + message: snapshot.message, + actions: snapshot.actions, + actionIDs: snapshot.actionIDs, + retryable: snapshot.retryable, + suggestedOperation: snapshot.suggestedOperation, + docsAnchor: snapshot.docsAnchor, + localizationKey: snapshot.localizationKey + ) + } + + 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) + self.localizationKey = try container.decodeIfPresent(String.self, forKey: .localizationKey) + } +} + +extension BackendRecoveryPayload { + var hasGuidanceText: Bool { + let titleText = title.normalizedRecoveryText + if let message, !message.normalizedRecoveryText.isEmpty, message.normalizedRecoveryText != titleText { + return true + } + return actions.contains { !$0.normalizedRecoveryText.isEmpty } + } +} + +private extension String { + var normalizedRecoveryText: String { + trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendSummaryLocalization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendSummaryLocalization.swift new file mode 100644 index 00000000..072cd25a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendSummaryLocalization.swift @@ -0,0 +1,604 @@ +import Foundation + +enum BackendSummaryLocalization { + static func localized(_ summary: String, operation: String, payload: JSONValue? = nil) -> String { + if let payload, let structured = localizedStructuredSummary(operation: operation, payload: payload) { + return structured + } + return localizedKnownSummary(summary) + } + + private static func localizedKnownSummary(_ summary: String) -> String { + let normalized = summary.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "helper capabilities resolved.": + return L10n.string("backend.summary.helper_capabilities_resolved") + case "install validation passed.": + return L10n.string("backend.summary.install_validation_passed") + case "install validation failed.": + return L10n.string("backend.summary.install_validation_failed") + case "telemetry is enabled.": + return L10n.string("backend.summary.telemetry_enabled") + case "telemetry is disabled.": + return L10n.string("backend.summary.telemetry_disabled") + case "update required.": + return L10n.string("backend.summary.update_required") + case "timecapsulesmb is up to date.": + return L10n.string("backend.summary.up_to_date") + case "version metadata is unavailable.": + return L10n.string("backend.summary.version_metadata_unavailable") + case "configuration saved and ssh authentication verified.": + return L10n.string("backend.summary.configuration_saved") + case "deployment dry-run plan generated.": + return L10n.string("backend.summary.deploy_plan_generated") + case "deployment completed.": + return L10n.string("backend.summary.deploy_completed") + case "netbsd4 activation dry-run plan generated.": + return L10n.string("backend.summary.activation_plan_generated") + case "netbsd4 payload was already active.": + return L10n.string("backend.summary.activation_already_active") + case "netbsd4 activation completed.": + return L10n.string("backend.summary.activation_completed") + case "uninstall dry-run plan generated.": + return L10n.string("backend.summary.uninstall_plan_generated") + case "uninstall completed.": + return L10n.string("backend.summary.uninstall_completed") + case "uninstall completed without post-reboot verification.": + return L10n.string("backend.summary.uninstall_unverified") + case "fsck dry-run plan generated.", "dry-run plan generated for fsck.": + return L10n.string("backend.summary.fsck_plan_generated") + case "fsck completed.", "disk repair completed with fsck.": + return L10n.string("backend.summary.fsck_completed") + case "flash plan is already satisfied; no write is needed.": + return L10n.string("backend.summary.flash_plan_already_satisfied") + case "flash write was not needed.": + return L10n.string("backend.summary.flash_write_not_needed") + case "flash patch write validated; manual power cycle required.": + return L10n.string("backend.summary.flash_patch_write_validated_power_cycle") + case "flash restore write validated; device rebooted.": + return L10n.string("backend.summary.flash_restore_write_validated_rebooted") + case "flash restore write validated; reboot requested.": + return L10n.string("backend.summary.flash_restore_write_validated_reboot_requested") + case "flash restore write validated; manual reboot required.": + return L10n.string("backend.summary.flash_restore_write_validated_manual_reboot") + case "flash write completed.": + return L10n.string("backend.summary.flash_write_completed") + case "doctor checks passed.": + return L10n.string("backend.summary.doctor_checks_passed") + case "doctor found one or more fatal problems.": + return L10n.string("backend.summary.doctor_found_fatal") + case "operation exited.": + return L10n.string("backend.summary.operation_exited") + case "ssh reachable; smb port reachable.": + return L10n.string("backend.summary.reachability.all_reachable") + case "ssh reachable, smb port closed.": + return L10n.string("backend.summary.reachability.ssh_only") + case "smb port reachable, ssh closed.": + return L10n.string("backend.summary.reachability.smb_only") + case "could not reach ssh or smb.": + return L10n.string("backend.summary.reachability.unreachable") + default: + return summary + } + } + + private static func localizedStructuredSummary(operation: String, payload: JSONValue) -> String? { + switch operation { + case "capabilities": + return L10n.string("backend.summary.helper_capabilities_resolved") + case "validate-install": + return payload.bool("ok").map { installValidationSummary(ok: $0) } + case "set-telemetry": + return payload.bool("telemetry_enabled").map { telemetrySummary(enabled: $0) } + case "version-check": + return versionSummary( + source: payload.string("source"), + shouldBlock: payload.bool("should_block"), + updateAvailable: payload.bool("update_available") + ) + case "discover": + return payload.count("devices").map { discoveredDevicesSummary(count: $0) } + case "configure": + return L10n.string("backend.summary.configuration_saved") + case "deploy": + return deploySummary(payload: payload) + case "doctor": + return payload.bool("fatal").map(doctorSummary) + case "activate": + return activationSummary(payload: payload) + case "uninstall": + return uninstallSummary(payload: payload) + case "fsck": + return fsckSummary(payload: payload) + case "repair-xattrs": + let findings = payload.int("finding_count") ?? payload.count("findings") + let repairable = payload.int("repairable_count") ?? payload.count("repairable") + guard findings != nil || repairable != nil else { + return nil + } + return repairXattrsSummary(findings: findings, repairable: repairable) + case "flash": + return flashSummary(payload: payload) + case "reachability": + return reachabilitySummary(status: payload.string("status"), summary: payload.string("summary")) + default: + return nil + } + } + + static func installValidationSummary(ok: Bool?) -> String { + ok == false + ? L10n.string("backend.summary.install_validation_failed") + : L10n.string("backend.summary.install_validation_passed") + } + + static func telemetrySummary(enabled: Bool?) -> String { + enabled == false + ? L10n.string("backend.summary.telemetry_disabled") + : L10n.string("backend.summary.telemetry_enabled") + } + + static func versionSummary(source: String?, shouldBlock: Bool?, updateAvailable: Bool?) -> String { + if source == "unavailable" { + return L10n.string("backend.summary.version_metadata_unavailable") + } + if shouldBlock == true { + return L10n.string("backend.summary.update_required") + } + if updateAvailable == true { + return L10n.string("backend.summary.update_available") + } + return L10n.string("backend.summary.up_to_date") + } + + static func discoveredDevicesSummary(count: Int?) -> String { + L10n.format("backend.summary.discovered_devices", count ?? 0) + } + + static func deployResultSummary(summary: String, message: String?, netbsd4: Bool) -> String { + if isNetBSD4ActivationMessage(message ?? summary) { + return netbsd4ActivationCompletedWithFollowup() + } + return localizedKnownSummary(message ?? summary) + } + + static func activationResultSummary(summary: String, message: String?, alreadyActive: Bool) -> String { + if alreadyActive { + return L10n.string("backend.summary.activation_already_active") + } + if isNetBSD4ActivationMessage(message ?? summary) { + return netbsd4ActivationCompletedWithFollowup() + } + return localizedKnownSummary(message ?? summary) + } + + static func hfsVolumesFoundSummary(count: Int) -> String { + L10n.format("backend.summary.hfs_volumes_found", count) + } + + static func repairXattrsSummary(findings: Int?, repairable: Int?) -> String { + L10n.format("backend.summary.repair_xattrs_found", findings ?? 0, repairable ?? 0) + } + + static func flashBackupSummary(backupDir: String?) -> String { + L10n.format("backend.summary.flash_backup_saved", backupDir ?? L10n.string("value.unknown")) + } + + static func flashPlanSummary( + mode: String, + alreadySatisfied: Bool, + writeRequested: Bool, + appleFirmwareMatch: FlashAppleFirmwareMatchPayload?, + firmwarePayload: FlashFirmwarePayload? + ) -> String { + if mode == "check_apple" { + let version = appleFirmwareMatch?.templateVersion + if appleFirmwareMatch?.matched == true { + return L10n.format("backend.summary.flash_apple_stock_matches", flashVersionSuffix(version)) + } + return L10n.format("backend.summary.flash_apple_stock_mismatch", flashVersionSuffix(version)) + } + if mode == "download_only" { + return L10n.format( + "backend.summary.flash_apple_restore_validated", + flashAppleRestoreDetail(version: firmwarePayload?.templateVersion, product: firmwarePayload?.templateProductID) + ) + } + if alreadySatisfied { + return L10n.string("backend.summary.flash_plan_already_satisfied") + } + let modeText = flashModeText(mode) + if writeRequested { + return L10n.format("backend.summary.flash_write_plan_generated", modeText) + } + return L10n.format("backend.summary.flash_plan_generated", modeText) + } + + static func flashWriteSummary( + mode: String, + writeStatus: String, + writeValidated: Bool, + postWriteAction: String, + rebootRequested: Bool, + rebooted: Bool + ) -> String { + if writeStatus == "not_needed" { + return L10n.string("backend.summary.flash_write_not_needed") + } + if writeValidated && mode == "patch" { + return L10n.string("backend.summary.flash_patch_write_validated_power_cycle") + } + if writeValidated && mode == "restore" { + if postWriteAction == "ssh_reboot" && rebooted { + return L10n.string("backend.summary.flash_restore_write_validated_rebooted") + } + if postWriteAction == "ssh_reboot" && rebootRequested { + return L10n.string("backend.summary.flash_restore_write_validated_reboot_requested") + } + return L10n.string("backend.summary.flash_restore_write_validated_manual_reboot") + } + if writeValidated { + return L10n.format("backend.summary.flash_write_validated", flashModeText(mode)) + } + return L10n.string("backend.summary.flash_write_completed") + } + + static func reachabilitySummary(status: String?, summary: String?) -> String { + if let summary { + let localized = localizedKnownSummary(summary) + if localized != summary { + return localized + } + } + switch status { + case "reachable": + return L10n.string("backend.summary.reachability.all_reachable") + case "partial": + return summary ?? L10n.string("backend.summary.reachability.partial") + case "unreachable": + return L10n.string("backend.summary.reachability.unreachable") + default: + return summary ?? L10n.string("value.unknown") + } + } + + private static func deploySummary(payload: JSONValue) -> String? { + if payload.array("uploads") != nil || payload.array("post_deploy_checks") != nil { + return L10n.string("backend.summary.deploy_plan_generated") + } + if isNetBSD4ActivationMessage(payload.string("message") ?? payload.string("summary")) { + return netbsd4ActivationCompletedWithFollowup() + } + return nil + } + + private static func doctorSummary(fatal: Bool) -> String { + fatal ? L10n.string("backend.summary.doctor_found_fatal") : L10n.string("backend.summary.doctor_checks_passed") + } + + private static func activationSummary(payload: JSONValue) -> String? { + if payload.array("actions") != nil || payload.array("post_activation_checks") != nil { + return L10n.string("backend.summary.activation_plan_generated") + } + return activationResultSummary( + summary: payload.string("summary") ?? "", + message: payload.string("message"), + alreadyActive: payload.bool("already_active") == true + ) + } + + private static func uninstallSummary(payload: JSONValue) -> String? { + if payload.array("remote_actions") != nil || payload.array("payload_dirs") != nil { + return L10n.string("backend.summary.uninstall_plan_generated") + } + if let verified = payload.bool("verified") { + return verified + ? L10n.string("backend.summary.uninstall_completed") + : L10n.string("backend.summary.uninstall_unverified") + } + if let summary = payload.string("summary") { + return localizedKnownSummary(summary) + } + return nil + } + + private static func fsckSummary(payload: JSONValue) -> String? { + if let targetCount = payload.count("targets") { + return hfsVolumesFoundSummary(count: targetCount) + } + if payload.string("device") != nil || payload.string("mountpoint") != nil { + return L10n.string("backend.summary.fsck_completed") + } + if payload.object("target") != nil { + return L10n.string("backend.summary.fsck_plan_generated") + } + return nil + } + + private static func flashSummary(payload: JSONValue) -> String? { + if let writeStatus = payload.string("write_status") { + return flashWriteSummary( + mode: payload.string("mode") ?? "unknown", + writeStatus: writeStatus, + writeValidated: payload.bool("write_validated") == true, + postWriteAction: payload.string("post_write_action") ?? "", + rebootRequested: payload.bool("reboot_requested") == true, + rebooted: payload.bool("rebooted") == true + ) + } + if let mode = payload.string("mode") { + let appleFirmwareMatch = try? payload.object("apple_firmware_match")?.decode(FlashAppleFirmwareMatchPayload.self) + let firmwarePayload = try? payload.object("firmware_payload")?.decode(FlashFirmwarePayload.self) + return flashPlanSummary( + mode: mode, + alreadySatisfied: payload.bool("already_satisfied") == true, + writeRequested: payload.bool("write_requested") == true, + appleFirmwareMatch: appleFirmwareMatch, + firmwarePayload: firmwarePayload + ) + } + if payload.string("backup_dir") != nil { + return flashBackupSummary(backupDir: payload.string("backup_dir")) + } + return nil + } + + private static func isNetBSD4ActivationMessage(_ message: String?) -> Bool { + message?.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("netbsd4 activation complete.") == true + } + + private static func netbsd4ActivationCompletedWithFollowup() -> String { + L10n.format( + "backend.summary.activation_completed_with_followup", + L10n.string("backend.summary.activation_followup") + ) + } + + private static func flashModeText(_ mode: String) -> String { + switch mode { + case "patch": + return L10n.string("backend.summary.flash_mode.patch") + case "restore": + return L10n.string("backend.summary.flash_mode.restore") + case "check_apple": + return L10n.string("backend.summary.flash_mode.check_apple") + case "download_only": + return L10n.string("backend.summary.flash_mode.download_only") + default: + return mode + } + } + + private static func flashVersionSuffix(_ version: String?) -> String { + guard let version, !version.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "" + } + return L10n.format("backend.summary.flash_version_suffix", version) + } + + private static func flashAppleRestoreDetail(version: String?, product: String?) -> String { + var parts: [String] = [] + if let version, !version.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + parts.append(L10n.format("backend.summary.flash_apple_restore_version", version)) + } + if let product, !product.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + parts.append(L10n.format("backend.summary.flash_apple_restore_product", product)) + } + guard !parts.isEmpty else { + return "" + } + return L10n.format( + "backend.summary.flash_apple_restore_detail", + parts.joined(separator: L10n.string("value.list_separator")) + ) + } +} + +extension CapabilitiesPayload { + var localizedSummary: String { + L10n.string("backend.summary.helper_capabilities_resolved") + } +} + +extension InstallValidationPayload { + var localizedSummary: String { + BackendSummaryLocalization.installValidationSummary(ok: ok) + } +} + +extension VersionCheckPayload { + var localizedSummary: String { + BackendSummaryLocalization.versionSummary( + source: source, + shouldBlock: shouldBlock, + updateAvailable: updateAvailable + ) + } +} + +extension ReachabilityPayload { + var localizedSummary: String { + BackendSummaryLocalization.reachabilitySummary(status: status, summary: summary) + } +} + +extension DiscoverPayload { + var localizedSummary: String { + BackendSummaryLocalization.discoveredDevicesSummary(count: devices.count) + } +} + +extension ConfigurePayload { + var localizedSummary: String { + L10n.string("backend.summary.configuration_saved") + } +} + +extension DeployPlanPayload { + var localizedSummary: String { + L10n.string("backend.summary.deploy_plan_generated") + } +} + +extension DeployResultPayload { + var localizedSummary: String { + BackendSummaryLocalization.deployResultSummary(summary: summary, message: message, netbsd4: netbsd4) + } + + var localizedMessage: String { + localizedSummary + } +} + +extension DoctorPayload { + var localizedSummary: String { + fatal ? L10n.string("backend.summary.doctor_found_fatal") : L10n.string("backend.summary.doctor_checks_passed") + } +} + +extension ActivationPlanPayload { + var localizedSummary: String { + L10n.string("backend.summary.activation_plan_generated") + } +} + +extension ActivationResultPayload { + var localizedSummary: String { + BackendSummaryLocalization.activationResultSummary(summary: summary, message: message, alreadyActive: alreadyActive) + } + + var localizedMessage: String { + localizedSummary + } +} + +extension UninstallPlanPayload { + var localizedSummary: String { + L10n.string("backend.summary.uninstall_plan_generated") + } +} + +extension MaintenanceResultPayload { + var localizedUninstallSummary: String { + verified == false + ? L10n.string("backend.summary.uninstall_unverified") + : L10n.string("backend.summary.uninstall_completed") + } +} + +extension FsckVolumeListPayload { + var localizedSummary: String { + BackendSummaryLocalization.hfsVolumesFoundSummary(count: targets.count) + } +} + +extension FsckPlanPayload { + var localizedSummary: String { + L10n.string("backend.summary.fsck_plan_generated") + } +} + +extension FsckResultPayload { + var localizedSummary: String { + L10n.string("backend.summary.fsck_completed") + } +} + +extension RepairXattrsPayload { + var localizedSummary: String { + BackendSummaryLocalization.repairXattrsSummary(findings: findingCount, repairable: repairableCount) + } +} + +extension FlashBackupPayload { + var localizedSummary: String { + BackendSummaryLocalization.flashBackupSummary(backupDir: backupDir) + } +} + +extension FlashPlanPayload { + var localizedSummary: String { + BackendSummaryLocalization.flashPlanSummary( + mode: mode.rawValue, + alreadySatisfied: alreadySatisfied, + writeRequested: writeRequested, + appleFirmwareMatch: appleFirmwareMatch, + firmwarePayload: firmwarePayload + ) + } +} + +extension FlashWritePayload { + var localizedSummary: String { + BackendSummaryLocalization.flashWriteSummary( + mode: mode.rawValue, + writeStatus: writeStatus, + writeValidated: writeValidated, + postWriteAction: postWriteAction, + rebootRequested: rebootRequested, + rebooted: rebooted + ) + } +} + +private extension JSONValue { + func string(_ key: String) -> String? { + stringValue(for: key) + } + + func bool(_ key: String) -> Bool? { + guard case .object(let values) = self, case .bool(let value)? = values[key] else { + return nil + } + return value + } + + func int(_ key: String) -> Int? { + guard case .object(let values) = self, let value = values[key] else { + return nil + } + switch value { + case .number(let number) where number.isFinite: + return Int(number) + case .string(let string): + return Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + return nil + } + } + + func array(_ key: String) -> [JSONValue]? { + guard case .object(let values) = self, case .array(let array)? = values[key] else { + return nil + } + return array + } + + func object(_ key: String) -> JSONValue? { + guard case .object(let values) = self, case .object? = values[key] else { + return nil + } + return values[key] + } + + func count(_ key: String) -> Int? { + if let array = array(key) { + return array.count + } + guard case .object(let values) = self, + case .object(let counts)? = values["counts"], + let value = counts[key] else { + return nil + } + switch value { + case .number(let number) where number.isFinite: + return Int(number) + case .string(let string): + return Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + return nil + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendViewModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendViewModels.swift new file mode 100644 index 00000000..9b18f8c0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/BackendViewModels.swift @@ -0,0 +1,187 @@ +import Foundation + +struct OperationStageState: Equatable { + let operation: String + let stage: String + let risk: String? + let cancellable: Bool? + let description: String? + + init( + operation: String, + stage: String, + risk: String? = nil, + cancellable: Bool? = nil, + description: String? = nil + ) { + self.operation = operation + self.stage = stage + self.risk = risk + self.cancellable = cancellable + self.description = description + } + + 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 + } +} + +enum WorkflowLocalError: Equatable { + case operationAlreadyRunning + case operationCouldNotStart + case deployOptionsInvalid + case ataIdleSecondsInvalid + case ataStandbyInvalid + case deployPlanStale + case deployPlanNotReady + case mountWaitInvalid + case activationPlanRequired + case uninstallPlanStale + case uninstallPlanNotReady + case fsckTargetRequired + case fsckPlanStale + case fsckPlanNotReady + case repairXattrsDepthInvalid + case repairXattrsPathRequired + case repairXattrsScanStale + case flashBackupUnavailable + case flashBackupRequired + case flashWritesDisabled + case flashModeReadOnly + case flashPlanRequired + case flashPlanStale + + var code: String { + switch self { + case .operationAlreadyRunning: + return "operation_already_running" + case .operationCouldNotStart: + return "operation_could_not_start" + case .deployOptionsInvalid: + return "deploy_options_invalid" + case .ataIdleSecondsInvalid: + return "ata_idle_seconds_invalid" + case .ataStandbyInvalid: + return "ata_standby_invalid" + case .deployPlanStale: + return "deploy_plan_stale" + case .deployPlanNotReady: + return "deploy_plan_not_ready" + case .mountWaitInvalid: + return "mount_wait_invalid" + case .activationPlanRequired: + return "activation_plan_required" + case .uninstallPlanStale: + return "uninstall_plan_stale" + case .uninstallPlanNotReady: + return "uninstall_plan_not_ready" + case .fsckTargetRequired: + return "fsck_target_required" + case .fsckPlanStale: + return "fsck_plan_stale" + case .fsckPlanNotReady: + return "fsck_plan_not_ready" + case .repairXattrsDepthInvalid: + return "repair_xattrs_depth_invalid" + case .repairXattrsPathRequired: + return "repair_xattrs_path_required" + case .repairXattrsScanStale: + return "repair_xattrs_scan_stale" + case .flashBackupUnavailable: + return "flash_backup_unavailable" + case .flashBackupRequired: + return "flash_backup_required" + case .flashWritesDisabled: + return "flash_writes_disabled" + case .flashModeReadOnly: + return "flash_mode_read_only" + case .flashPlanRequired: + return "flash_plan_required" + case .flashPlanStale: + return "flash_plan_stale" + } + } + + var message: String { + L10n.string("workflow.error.\(code)") + } +} + +struct BackendErrorViewModel: Equatable { + let operation: String + let code: String + private let rawMessage: String? + let localError: WorkflowLocalError? + let recovery: BackendRecoveryPayload? + + var message: String { + localError?.message ?? rawMessage ?? "" + } + + init(event: BackendEvent) { + self.operation = event.operation + self.code = event.code ?? "operation_failed" + self.rawMessage = event.message ?? event.localizedSummary + self.localError = nil + 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.rawMessage = message + self.localError = nil + self.recovery = recovery + } + + init(operation: String, localError: WorkflowLocalError, recovery: BackendRecoveryPayload? = nil) { + self.operation = operation + self.code = localError.code + self.rawMessage = nil + self.localError = localError + self.recovery = recovery + } + + init(operation: String, deployState: DeviceDeployStateSnapshot) { + self.operation = operation + self.code = deployState.errorCode ?? "operation_failed" + self.rawMessage = deployState.localizedSummary + self.localError = nil + self.recovery = deployState.recovery.map(BackendRecoveryPayload.init) + } +} + +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 + } + + var localizedPayloadSummaryText: String? { + guard let payloadSummaryText else { + return nil + } + return BackendSummaryLocalization.localized(payloadSummaryText, operation: operation, payload: payload) + } + + var localizedSummary: String { + if type == "result", let localizedPayloadSummaryText { + return localizedPayloadSummaryText + } + return summary + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift new file mode 100644 index 00000000..ccef0279 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperLocator.swift @@ -0,0 +1,247 @@ +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"]) + } + if output["TCAPSULE_CLIENT"] == nil { + output["TCAPSULE_CLIENT"] = "macos_gui" + } + 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/Backend/HelperPipeReader.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperPipeReader.swift new file mode 100644 index 00000000..e205461b --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperPipeReader.swift @@ -0,0 +1,142 @@ +import Darwin +import Dispatch +import Foundation + +public protocol HelperPipeReading: Sendable { + func chunks(from handle: FileHandle) -> AsyncThrowingStream +} + +public final class ReadabilityPipeReader: HelperPipeReading, @unchecked Sendable { + public init() {} + + public func chunks(from handle: FileHandle) -> AsyncThrowingStream { + let state = PipeReadState(handle: handle) + return AsyncThrowingStream { continuation in + state.start(continuation: continuation) + } + } +} + +private final class PipeReadState: @unchecked Sendable { + private let fileDescriptor: CInt + private let queue = DispatchQueue(label: "TimeCapsuleSMB.PipeReader") + private let lock = NSLock() + private var source: DispatchSourceRead? + private var originalFlags: CInt? + private var completed = false + + init(handle: FileHandle) { + fileDescriptor = handle.fileDescriptor + } + + func start(continuation: AsyncThrowingStream.Continuation) { + continuation.onTermination = { [weak self] _ in + self?.finish() + } + + queue.async { + guard !self.isCompleted else { + return + } + + let originalFlags = fcntl(self.fileDescriptor, F_GETFL) + guard originalFlags != -1 else { + self.finish { + continuation.finish(throwing: Self.posixError(errno)) + } + return + } + if fcntl(self.fileDescriptor, F_SETFL, originalFlags | O_NONBLOCK) == -1 { + self.finish { + continuation.finish(throwing: Self.posixError(errno)) + } + return + } + + let source = DispatchSource.makeReadSource(fileDescriptor: self.fileDescriptor, queue: self.queue) + source.setEventHandler { + self.readAvailableData(continuation: continuation) + } + + self.lock.lock() + guard !self.completed else { + self.lock.unlock() + _ = fcntl(self.fileDescriptor, F_SETFL, originalFlags) + source.resume() + source.cancel() + return + } + self.originalFlags = originalFlags + self.source = source + source.resume() + self.lock.unlock() + } + } + + private func readAvailableData(continuation: AsyncThrowingStream.Continuation) { + guard !isCompleted else { + return + } + + while true { + var buffer = [UInt8](repeating: 0, count: 4096) + let bytesRead = Darwin.read(fileDescriptor, &buffer, buffer.count) + + if bytesRead > 0 { + continuation.yield(Data(buffer[0.. Void)? = nil) { + lock.lock() + guard !completed else { + lock.unlock() + return + } + completed = true + let source = source + self.source = nil + let originalFlags = originalFlags + self.originalFlags = nil + lock.unlock() + + if let originalFlags { + _ = fcntl(fileDescriptor, F_SETFL, originalFlags) + } + source?.cancel() + finishContinuation?() + } + + private static func posixError(_ code: CInt) -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRequestWriter.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRequestWriter.swift new file mode 100644 index 00000000..e2de7bae --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRequestWriter.swift @@ -0,0 +1,150 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#endif +import Dispatch + +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 + } + #if canImport(Darwin) + var noSigpipe: CInt = 1 + _ = fcntl(handle.fileDescriptor, F_SETNOSIGPIPE, &noSigpipe) + #endif + + 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 fileDescriptor: CInt + private let chunkSize: Int + private let queue = DispatchQueue(label: "TimeCapsuleSMB.PipeRequestWriter") + private var offset = 0 + private var originalFlags: CInt? + private var continuation: CheckedContinuation? + private var source: DispatchSourceWrite? + private var completed = false + + init(data: Data, handle: FileHandle, chunkSize: Int) { + self.data = data + self.fileDescriptor = handle.fileDescriptor + self.chunkSize = max(1, chunkSize) + } + + func start(continuation: CheckedContinuation) { + queue.async { + if self.completed { + continuation.resume(throwing: CancellationError()) + return + } + + let originalFlags = fcntl(self.fileDescriptor, F_GETFL) + guard originalFlags != -1 else { + self.completed = true + continuation.resume(throwing: Self.posixError(errno)) + return + } + self.originalFlags = originalFlags + if fcntl(self.fileDescriptor, F_SETFL, originalFlags | O_NONBLOCK) == -1 { + self.completed = true + continuation.resume(throwing: Self.posixError(errno)) + return + } + + self.continuation = continuation + let source = DispatchSource.makeWriteSource(fileDescriptor: self.fileDescriptor, queue: self.queue) + self.source = source + source.setEventHandler { [weak self] in + self?.writeAvailableData() + } + source.resume() + } + } + + func cancel() { + queue.async { + self.complete(.failure(CancellationError())) + } + } + + private func writeAvailableData() { + guard !completed else { + return + } + + while offset < data.count { + let length = min(chunkSize, data.count - offset) + let written = data.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { + return 0 + } + return Darwin.write(fileDescriptor, baseAddress.advanced(by: offset), length) + } + + if written > 0 { + offset += written + continue + } + + if written == -1 { + switch errno { + case EAGAIN, EWOULDBLOCK: + return + case EINTR: + continue + default: + complete(.failure(Self.posixError(errno))) + return + } + } + + complete(.failure(Self.posixError(EPIPE))) + return + } + + complete(.success(())) + } + + private func complete(_ result: Result) { + guard !completed else { + return + } + completed = true + let continuation = self.continuation + self.continuation = nil + let source = self.source + self.source = nil + + if let originalFlags { + _ = fcntl(fileDescriptor, F_SETFL, originalFlags) + } + source?.cancel() + continuation?.resume(with: result) + } + + private static func posixError(_ code: CInt) -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRunner.swift new file mode 100644 index 00000000..da98c704 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/HelperRunner.swift @@ -0,0 +1,310 @@ +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], + requestID: String, + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult +} + +public final class HelperRunner: @unchecked Sendable, HelperRunning { + private let locator: HelperLocator + private let stderrLimit: Int + private let requestWriter: any HelperRequestWriting + private let pipeReader: any HelperPipeReading + + public init( + locator: HelperLocator = HelperLocator(), + stderrLimit: Int = 64 * 1024, + requestWriter: any HelperRequestWriting = PipeRequestWriter(), + pipeReader: any HelperPipeReading = ReadabilityPipeReader() + ) { + self.locator = locator + self.stderrLimit = stderrLimit + self.requestWriter = requestWriter + self.pipeReader = pipeReader + } + + public func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + requestID: String, + 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, requestId: requestID)) + 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 stderrPipe = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = stderrPipe + + do { + try process.run() + } catch { + await eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription, requestId: requestID)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let pipeReader = self.pipeReader + let stdoutTask = Task.detached { + await Self.readOutput(output.fileHandleForReading, pipeReader: pipeReader, onEvent: eventSink) + } + let stderrLimit = self.stderrLimit + let stderrTask = Task.detached { + await Self.readCapped(stderrPipe.fileHandleForReading, limit: stderrLimit, pipeReader: pipeReader) + } + + 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), + "request_id": JSONValue.string(requestID), + "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, requestId: requestID)) + await stdoutTask.value + let stderr = await stderrTask.value + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) + } + + let writeResult = await writeRequest(requestData, to: input.fileHandleForWriting, process: process) + + if case .failure(let error) = writeResult { + if Task.isCancelled || error is CancellationError { + await Self.cancelForTelemetry(process) + } else { + await Self.terminate(process) + } + await stdoutTask.value + let stderr = await stderrTask.value + if Task.isCancelled || error is CancellationError { + let sawTerminalEvent = await terminalTracker.sawTerminalEvent + if !sawTerminalEvent { + await eventSink(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + requestId: requestID, + debug: stderr.isEmpty ? nil : .object(["stderr": .string(stderr)]) + )) + } + return HelperRunResult(exitCode: 130, sawTerminalEvent: await terminalTracker.sawTerminalEvent, stderr: stderr) + } + await eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription, requestId: requestID)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) + } + + await withTaskCancellationHandler { + await Self.waitForExit(process) + } onCancel: { + Task { + await Self.cancelForTelemetry(process) + } + } + let cancelled = Task.isCancelled + + await stdoutTask.value + let stderrText = await stderrTask.value + let sawTerminalEvent = await terminalTracker.sawTerminalEvent + if cancelled && !sawTerminalEvent { + await eventSink(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + requestId: requestID, + 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"), + requestId: requestID, + 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 func writeRequest(_ data: Data, to handle: FileHandle, process: Process) async -> Result { + let requestWriter = self.requestWriter + return await withTaskCancellationHandler { + defer { + try? handle.close() + } + do { + try await requestWriter.write(data, to: handle) + return .success(()) + } catch { + return .failure(error) + } + } onCancel: { + Task { + await Self.cancelForTelemetry(process) + } + } + } + + private static func readOutput( + _ handle: FileHandle, + pipeReader: any HelperPipeReading, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async { + var parser = OutputLineParser() + do { + for try await data in pipeReader.chunks(from: handle) { + for event in parser.append(data) { + await onEvent(event) + } + } + } catch { + return + } + for event in parser.finish() { + await onEvent(event) + } + } + + private static func readCapped( + _ handle: FileHandle, + limit: Int, + pipeReader: any HelperPipeReading + ) async -> String { + var output = Data() + do { + for try await data in pipeReader.chunks(from: handle) { + if output.count < limit { + output.append(data.prefix(limit - output.count)) + } + } + } catch { + return String(decoding: output, as: UTF8.self) + } + return String(decoding: output, as: UTF8.self) + } + + 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 { + let pid = process.processIdentifier + if pid > 0 { + kill(pid, SIGKILL) + } + } + } + + private static func cancelForTelemetry(_ process: Process) async { + guard process.isRunning else { + return + } + let pid = process.processIdentifier + guard pid > 0 else { + return + } + kill(pid, SIGINT) + for _ in 0..<20 { + if !process.isRunning { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + await terminate(process) + } +} + +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/Backend/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/Models.swift new file mode 100644 index 00000000..1e2497f1 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/Models.swift @@ -0,0 +1,233 @@ +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, + requestId: String? = UUID().uuidString, + debug: JSONValue? = nil + ) -> BackendEvent { + BackendEvent( + requestId: requestId, + type: "error", + operation: operation, + code: code, + message: message, + debug: debug + ) + } + + public func withRequestId(_ requestId: String) -> BackendEvent { + BackendEvent( + schemaVersion: schemaVersion, + requestId: requestId, + type: type, + operation: operation, + code: code, + stage: stage, + level: level, + message: message, + status: status, + ok: ok, + payload: payload, + details: details, + debug: debug, + recovery: recovery, + risk: risk, + cancellable: cancellable, + description: description + ) + } + + 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/Backend/OperationCredentialInjector.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationCredentialInjector.swift new file mode 100644 index 00000000..c5790bff --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationCredentialInjector.swift @@ -0,0 +1,18 @@ +import Foundation + +enum OperationCredentialInjector { + static func injectingPassword( + _ password: String?, + into params: [String: JSONValue] + ) -> [String: JSONValue] { + guard let password, + !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + params["credentials"] == nil else { + return params + } + + var updated = params + updated["credentials"] = .object(["password": .string(password)]) + return updated + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift new file mode 100644 index 00000000..d5af05a3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OperationParams.swift @@ -0,0 +1,253 @@ +import Foundation + +struct RepairXattrsOptions: Equatable { + var recursive: Bool = true + var maxDepth: Int? + var includeHidden: Bool = false + var includeTimeMachine: Bool = false + var fixPermissions: Bool = false + var verbose: Bool = false +} + +enum OperationParams { + enum Readiness { + static func versionCheck(url: String) -> [String: JSONValue] { + var params: [String: JSONValue] = [:] + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedURL.isEmpty { + params["url"] = .string(trimmedURL) + } + return params + } + } + + enum Discovery { + static func discover(timeout: Double) -> [String: JSONValue] { + ["timeout": .number(timeout)] + } + } + + enum Reachability { + static func check(profile: DeviceProfile) -> [String: JSONValue] { + [ + "ssh_host": .string(DeviceEndpointPolicy.rootSSHTarget(profile.host)), + "smb_hosts": .array(SMBAddressPolicy.reachabilityHostCandidates(for: profile).map(JSONValue.string)), + "tcp_timeout": .number(2), + "ssh_timeout": .number(8) + ] + } + } + + enum Configure { + static func save( + host: String = "", + selectedRecord: JSONValue? = nil, + password: String, + debugLogging: Bool, + internalShareUseDiskRoot: Bool? = nil, + anyProtocol: Bool? = nil, + ataIdleSeconds: Int? = nil, + ataStandby: Int? = nil, + includeAtaStandby: Bool = false + ) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "password": .string(password), + "persist_password": .bool(false), + "debug_logging": .bool(debugLogging) + ] + if let selectedRecord { + params["selected_record"] = selectedRecord + } else { + params["host"] = .string(DeviceEndpointPolicy.rootSSHTarget(host)) + } + if let internalShareUseDiskRoot { + params["internal_share_use_disk_root"] = .bool(internalShareUseDiskRoot) + } + if let anyProtocol { + params["any_protocol"] = .bool(anyProtocol) + } + if let ataIdleSeconds { + params["ata_idle_seconds"] = .number(Double(ataIdleSeconds)) + } + if let ataStandby { + params["ata_standby"] = .number(Double(ataStandby)) + } else if includeAtaStandby { + params["ata_standby"] = .string("") + } + return params + } + } + + enum Doctor { + static func run( + skipSSH: Bool = false, + skipBonjour: Bool = false, + skipSMB: Bool = false + ) -> [String: JSONValue] { + [ + "skip_ssh": .bool(skipSSH), + "skip_bonjour": .bool(skipBonjour), + "skip_smb": .bool(skipSMB) + ] + } + } + + enum Deploy { + static func params( + dryRun: Bool, + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + internalShareUseDiskRoot: Bool = false, + anyProtocol: Bool = false, + debugLogging: Bool, + ataIdleSeconds: Int, + ataStandby: Int?, + mountWait: Double + ) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "dry_run": .bool(dryRun), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "nbns_enabled": .bool(nbnsEnabled), + "internal_share_use_disk_root": .bool(internalShareUseDiskRoot), + "any_protocol": .bool(anyProtocol), + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait) + ] + params["ata_idle_seconds"] = .number(Double(ataIdleSeconds)) + if let ataStandby { + params["ata_standby"] = .number(Double(ataStandby)) + } else { + params["ata_standby"] = .string("") + } + return params + } + } + + enum Activation { + static func params(dryRun: Bool) -> [String: JSONValue] { + ["dry_run": .bool(dryRun)] + } + } + + enum Uninstall { + static func params(dryRun: Bool, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(dryRun), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ] + } + } + + enum Fsck { + static func listVolumes(mountWait: Double) -> [String: JSONValue] { + [ + "list_volumes": .bool(true), + "mount_wait": .number(mountWait) + ] + } + + static func run(dryRun: Bool, volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(dryRun), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), + "volume": .string(volume) + ] + } + } + + enum RepairXattrs { + static func params(dryRun: Bool, path: String, options: RepairXattrsOptions = RepairXattrsOptions()) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "path": .string(path), + "dry_run": .bool(dryRun), + "recursive": .bool(options.recursive), + "include_hidden": .bool(options.includeHidden), + "include_time_machine": .bool(options.includeTimeMachine), + "fix_permissions": .bool(options.fixPermissions), + "verbose": .bool(options.verbose) + ] + if let maxDepth = options.maxDepth { + params["max_depth"] = .number(Double(maxDepth)) + } + return params + } + } + + enum Flash { + static func backup() -> [String: JSONValue] { + [ + "action": .string("backup") + ] + } + + static func plan( + backupDir: String, + mode: FlashPlanMode, + force: Bool = false, + firmwareVersion: String = "", + firmwareTemplate: String = "" + ) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "action": .string("plan"), + "backup_dir": .string(backupDir), + "mode": .string(mode.rawValue), + "force": .bool(force) + ] + OperationParams.appendFirmwareSelection( + to: ¶ms, + firmwareVersion: firmwareVersion, + firmwareTemplate: firmwareTemplate + ) + return params + } + + static func write( + backupDir: String, + mode: FlashPlanMode, + force: Bool = false, + firmwareVersion: String = "", + firmwareTemplate: String = "", + rebootAfterWrite: Bool? = nil, + waitAfterReboot: Bool = true + ) -> [String: JSONValue] { + let shouldReboot = rebootAfterWrite ?? (mode == .restore) + var params: [String: JSONValue] = [ + "action": .string("write"), + "backup_dir": .string(backupDir), + "mode": .string(mode.rawValue), + "force": .bool(force), + "reboot_after_write": .bool(shouldReboot) + ] + if shouldReboot { + params["wait_after_reboot"] = .bool(waitAfterReboot) + } + OperationParams.appendFirmwareSelection( + to: ¶ms, + firmwareVersion: firmwareVersion, + firmwareTemplate: firmwareTemplate + ) + return params + } + } + + private static func appendFirmwareSelection( + to params: inout [String: JSONValue], + firmwareVersion: String, + firmwareTemplate: String + ) { + let trimmedVersion = firmwareVersion.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedVersion.isEmpty { + params["firmware_version"] = .string(trimmedVersion) + } + let trimmedTemplate = firmwareTemplate.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedTemplate.isEmpty { + params["firmware_template"] = .string(trimmedTemplate) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/OutputLineParser.swift new file mode 100644 index 00000000..50761c33 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/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/Backend/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift new file mode 100644 index 00000000..687d8566 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Backend/PendingConfirmation.swift @@ -0,0 +1,141 @@ +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 requestID: String + let context: DeviceRuntimeContext? + + init?( + confirmationEvent event: BackendEvent, + originalParams: [String: JSONValue], + requestID: String = UUID().uuidString, + 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 + } + + let presentation = ConfirmationPresentation(details: details) + self.title = presentation?.title + ?? Self.detailString(details, "title") + ?? L10n.string("confirm.backend.title") + self.message = presentation?.message + ?? Self.detailString(details, "message") + ?? event.message + ?? L10n.string("confirm.backend.message") + self.actionTitle = presentation?.actionTitle + ?? Self.detailString(details, "action_title") + ?? L10n.string("action.confirm") + self.operation = event.operation + self.requestID = event.requestId ?? requestID + 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 + } +} + +private struct ConfirmationPresentation { + let title: String + let message: String + let actionTitle: String + + init?(details: [String: JSONValue]) { + guard + let presentationKey = Self.detailString(details, "presentation_id"), + let title = Self.localizedString("confirm.\(presentationKey).title"), + let message = Self.localizedMessage(for: presentationKey, details: details) + else { + return nil + } + self.title = title + self.message = message + self.actionTitle = Self.localizedString("confirm.\(presentationKey).action") + ?? Self.detailString(details, "action_title") + ?? L10n.string("action.confirm") + } + + private static func localizedMessage(for presentationKey: String, details: [String: JSONValue]) -> String? { + let messageKey = "confirm.\(presentationKey).message" + guard let template = localizedString(messageKey) else { + return nil + } + let values = detailObject(details, "presentation_values") + switch presentationKey { + case "configure.enable_ssh_reboot", + "deploy.activate_now", + "deploy.netbsd4", + "deploy.netbsd4_no_wait", + "deploy.no_reboot", + "deploy.reboot", + "deploy.reboot_no_wait": + guard let deviceName = stringValue(values, "device_name") else { + return nil + } + return format(template, deviceName) + case "repair_xattrs": + guard let path = stringValue(values, "path") else { + return nil + } + return format(template, path) + case "flash.patch_write", + "flash.restore_write": + guard let host = stringValue(values, "host") else { + return nil + } + return format(template, host) + default: + return template + } + } + + private static func localizedString(_ key: String) -> String? { + let value = L10n.string(key) + return value == key ? nil : value + } + + private static func format(_ template: String, _ arguments: CVarArg...) -> String { + String(format: template, locale: Locale.current, arguments: arguments) + } + + 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 + } + + private static func detailObject(_ details: [String: JSONValue], _ key: String) -> [String: JSONValue] { + guard case .object(let values)? = details[key] else { + return [:] + } + return values + } + + private static func stringValue(_ values: [String: JSONValue], _ key: String) -> String? { + guard case .string(let value)? = values[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Discovery/AddDeviceTarget.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Discovery/AddDeviceTarget.swift new file mode 100644 index 00000000..04b87e15 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Discovery/AddDeviceTarget.swift @@ -0,0 +1,63 @@ +import Foundation + +struct ManualDeviceTarget: Equatable { + let host: String + + init(host: String) { + self.host = host.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +enum AddDeviceTarget: Equatable { + case discovered(DiscoveredDevice) + case manual(ManualDeviceTarget) + + var targetHost: String { + switch self { + case .discovered(let device): + return device.connectionTarget + case .manual(let target): + return target.host + } + } + + var selectedRecord: JSONValue? { + switch self { + case .discovered(let device): + return device.rawRecord + case .manual: + return nil + } + } + + var discoveredDevice: DiscoveredDevice? { + switch self { + case .discovered(let device): + return device + case .manual: + return nil + } + } + + var isEmpty: Bool { + targetHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func matchingProfile(in registry: DeviceRegistryStore) -> DeviceProfile? { + switch self { + case .discovered(let device): + return registry.matchingProfile(for: device) + case .manual: + return registry.matchingProfile(host: targetHost, bonjourFullname: nil) + } + } + + func setupLaneKey(existingProfileID: DeviceProfile.ID?) -> OperationLaneKey { + if let existingProfileID { + return .deviceWorkflow(existingProfileID, .configure) + } + let normalized = DeviceEndpointPolicy.normalizedHostKey(targetHost) + return .candidateHost(normalized.isEmpty ? targetHost : normalized) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Discovery/DiscoveredDevice.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Discovery/DiscoveredDevice.swift new file mode 100644 index 00000000..30d23aae --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Discovery/DiscoveredDevice.swift @@ -0,0 +1,104 @@ +import Foundation + +struct DiscoveredDevice: Identifiable, Equatable { + let id: String + let name: String + let connectionTarget: String + let sshHost: String? + let hostname: String + let networkAddresses: [DeviceNetworkAddress] + let syap: String? + let model: String? + let rawRecord: JSONValue + + var host: String { connectionTarget } + var addresses: [String] { networkAddresses.map(\.value) } + var addressSummary: String { DeviceEndpointPolicy.addressSummary(networkAddresses) } + + init( + id: String, + name: String, + connectionTarget: String, + sshHost: String?, + hostname: String, + networkAddresses: [DeviceNetworkAddress], + syap: String?, + model: String?, + rawRecord: JSONValue + ) { + self.id = id + self.name = name + self.connectionTarget = connectionTarget + self.sshHost = sshHost + self.hostname = hostname + self.networkAddresses = networkAddresses + self.syap = syap + self.model = model + self.rawRecord = rawRecord + } + + init(payload: DiscoveredDevicePayload, index: Int) { + let addresses = Self.networkAddresses(ipv4: payload.ipv4, ipv6: payload.ipv6, fallback: payload.addresses) + let sshHost = Self.nonEmpty(payload.sshHost) + let backendTarget = sshHost.flatMap(DeviceEndpointPolicy.hostComponent) + ?? DeviceEndpointPolicy.hostComponent(payload.host) + let identity = DeviceNetworkIdentity( + configuredSSHTarget: backendTarget ?? "", + hostname: payload.hostname, + bonjourName: payload.name, + bonjourFullname: payload.fullname, + addresses: addresses + ) + + self.id = payload.id.isEmpty ? "discovered-\(index)" : payload.id + self.name = payload.name.isEmpty ? (payload.hostname.isEmpty ? "AirPort Device" : payload.hostname) : payload.name + self.connectionTarget = backendTarget ?? identity.preferredSetupTarget + self.sshHost = sshHost + self.hostname = payload.hostname + self.networkAddresses = identity.addresses + self.syap = Self.nonEmpty(payload.syap) + self.model = Self.nonEmpty(payload.model) ?? Self.recordProperty(payload.selectedRecord, keys: ["model", "am"]) + self.rawRecord = payload.selectedRecord + } + + 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 + } + + var discoveryModelText: String { + Self.nonEmpty(model) ?? "" + } + + private static func recordProperty(_ record: JSONValue, keys: [String]) -> String? { + guard case .object(let values) = record, case .object(let properties)? = values["properties"] else { + return nil + } + for key in keys { + if case .string(let value)? = properties[key], let trimmed = nonEmpty(value) { + return trimmed + } + } + return nil + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func networkAddresses(ipv4: [String], ipv6: [String], fallback: [String]) -> [DeviceNetworkAddress] { + var addresses = ipv4.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + addresses += ipv6.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + if addresses.isEmpty { + addresses = fallback.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + } + return DeviceEndpointPolicy.uniqueAddresses(addresses) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift new file mode 100644 index 00000000..9fd5c8fe --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DashboardActionPolicy.swift @@ -0,0 +1,109 @@ +import Foundation + +enum DashboardActionPolicy { + static func secondaryActions(for summary: DeviceDashboardSummary) -> [DashboardSecondaryAction] { + var actions: [DashboardSecondaryAction] = [] + if let contextualAction = contextualSecondaryAction(for: summary.primaryAction) { + actions.append(contextualAction) + } + if summary.profile.runtimeState?.state.isInstalled == true && summary.primaryAction != .openSMB { + actions.append(.openFinder) + } + actions.append(.settings) + return removingDuplicates(actions.filter { isAvailable($0, for: summary) }) + } + + static func requiresPasswordReplacement(_ passwordState: DevicePasswordState) -> Bool { + switch passwordState { + case .unknown, .missing, .invalid, .keychainUnavailable: + return true + case .available: + return false + } + } + + static func isEnabled(_ action: DashboardPrimaryAction, for summary: DeviceDashboardSummary) -> Bool { + !blocksMutatingActions(summary.displayStatus) || !action.isMutatingOverviewAction + } + + static func isEnabled(_ action: DashboardSecondaryAction, for summary: DeviceDashboardSummary) -> Bool { + !blocksMutatingActions(summary.displayStatus) || !action.isMutatingOverviewAction + } + + static func checkupAction(for summary: DeviceDashboardSummary) -> DashboardSecondaryAction { + summary.displayStatus == .checking ? .viewCheckup : .runCheckup + } + + private static func contextualSecondaryAction(for primaryAction: DashboardPrimaryAction) -> DashboardSecondaryAction? { + switch primaryAction { + case .replacePassword: + return .runCheckup + case .runCheckup: + return .installUpdate + case .installSMB, .viewCheckup, .openSMB: + return .runCheckup + } + } + + private static func isAvailable(_ action: DashboardSecondaryAction, for summary: DeviceDashboardSummary) -> Bool { + switch action { + case .runCheckup: + return summary.displayStatus != .checking + case .refreshStatus, + .installUpdate, + .openFinder, + .replacePassword, + .viewCheckup, + .startSMB, + .settings: + return true + } + } + + private static func blocksMutatingActions(_ status: DeviceDisplayStatus) -> Bool { + switch status { + case .checking, .installing, .maintaining: + return true + case .unchecked, + .passwordNeeded, + .passwordInvalid, + .keychainUnavailable, + .readyToInstall, + .healthy, + .warning, + .failed, + .activationNeeded, + .removed, + .offline, + .unsupported: + return false + } + } + + private static func removingDuplicates(_ actions: [DashboardSecondaryAction]) -> [DashboardSecondaryAction] { + var seen: Set = [] + return actions.filter { seen.insert($0).inserted } + } +} + +extension DashboardPrimaryAction { + var isMutatingOverviewAction: Bool { + switch self { + case .runCheckup, .installSMB: + return true + case .replacePassword, .viewCheckup, .openSMB: + return false + } + } +} + +extension DashboardSecondaryAction { + var isMutatingOverviewAction: Bool { + switch self { + case .refreshStatus, .runCheckup, .installUpdate: + return true + case .openFinder, .replacePassword, .viewCheckup, .startSMB, .settings: + return false + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift new file mode 100644 index 00000000..161f6955 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceEndpointPolicy.swift @@ -0,0 +1,266 @@ +import Darwin +import Foundation + +enum DeviceEndpointPolicy { + static func rootSSHTarget(_ target: String) -> String { + let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let endpoint = parseEndpoint(trimmed), + !endpoint.host.isEmpty else { + return trimmed + } + if trimmed.contains("@"), endpoint.user.isEmpty { + return trimmed + } + let user = endpoint.user.isEmpty ? "root" : endpoint.user + return "\(user)@\(renderSSHHost(endpoint))" + } + + static func hostComponent(_ value: String?) -> String? { + guard let endpoint = parseEndpoint(value), + !endpoint.host.isEmpty else { + return nil + } + return endpoint.host + } + + static func normalizedHostKey(_ value: String?) -> String { + guard let host = hostComponent(value) else { + return "" + } + if let address = DeviceNetworkAddress(value: host, source: .configured) { + return "\(address.family.rawValue):\(address.normalizedValue)" + } + return "hostname:\(host.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased())" + } + + static func preferredSetupTarget(for identity: DeviceNetworkIdentity) -> String? { + if let address = identity.addresses.first(where: { $0.family == .ipv4 && $0.scope == .regular }) { + return address.value + } + if let address = identity.addresses.first(where: { $0.family == .ipv6 && $0.scope == .regular }) { + return address.value + } + if let address = identity.addresses.first(where: { $0.family == .ipv6 }) { + return address.value + } + if let hostname = normalizedHostname(identity.hostname) { + return hostname + } + if let address = identity.addresses.first(where: { $0.family == .ipv4 }) { + return address.value + } + return hostComponent(identity.configuredSSHTarget) + } + + static func displayTarget(for identity: DeviceNetworkIdentity) -> String { + if let hostname = normalizedHostname(identity.hostname) { + return hostname + } + if let target = preferredSetupTarget(for: identity) { + return target + } + return hostComponent(identity.configuredSSHTarget) + ?? identity.configuredSSHTarget.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func normalizedHostname(_ value: String?) -> String? { + guard let host = hostComponent(value), + addressFamily(for: host) == nil else { + return nil + } + let normalized = host.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalized.isEmpty ? nil : normalized + } + + static func addressFamily(for value: String) -> NetworkAddressFamily? { + if inetPton(AF_INET, value) { + return .ipv4 + } + if inetPton(AF_INET6, ipv6LiteralForParsing(value)) { + return .ipv6 + } + return nil + } + + static func addressScope(value: String, family: NetworkAddressFamily) -> NetworkAddressScope { + switch family { + case .ipv4: + if value.hasPrefix("169.254.") { + return .linkLocal + } + if value.hasPrefix("127.") { + return .loopback + } + return .regular + case .ipv6: + let literal = ipv6LiteralForParsing(value).lowercased() + if literal == "::1" { + return .loopback + } + let firstHextet = literal.split(separator: ":", maxSplits: 1).first.map(String.init) ?? "" + if let value = Int(firstHextet, radix: 16), (value & 0xffc0) == 0xfe80 { + return .linkLocal + } + return .regular + } + } + + static func normalizedAddressValue(_ value: String, family: NetworkAddressFamily) -> String { + switch family { + case .ipv4: + return value + case .ipv6: + return value.lowercased() + } + } + + static func uniqueAddresses(_ addresses: [DeviceNetworkAddress]) -> [DeviceNetworkAddress] { + var seen: Set = [] + var ordered: [DeviceNetworkAddress] = [] + for address in addresses { + if seen.insert(address.identityKey).inserted { + ordered.append(address) + } + } + return ordered + } + + static func addressSummary(_ addresses: [DeviceNetworkAddress]) -> String { + let regular = addresses.filter { $0.scope == .regular } + let prioritized = regular.isEmpty ? addresses : regular + return prioritized + .map { "\($0.family.title) \($0.value)" + ($0.scope == .linkLocal ? " link-local" : "") } + .joined(separator: " ") + } + + static func smbURL(host: String, account: String?) -> URL? { + let renderedHost: String + if addressFamily(for: host) == .ipv6 { + renderedHost = "[\(host)]" + } else if let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { + renderedHost = encodedHost + } else { + return nil + } + + let accountPrefix: String + if let account = account?.trimmingCharacters(in: .whitespacesAndNewlines), + !account.isEmpty, + let encodedAccount = account.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) { + accountPrefix = "\(encodedAccount)@" + } else { + accountPrefix = "" + } + return URL(string: "smb://\(accountPrefix)\(renderedHost)") + } + + private static func ipv6LiteralForParsing(_ value: String) -> String { + value.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + .split(separator: "%", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? value + } + + private struct Endpoint { + var user: String + var host: String + var port: Int? + var invalidPort: String? + } + + private static func parseEndpoint(_ value: String?) -> Endpoint? { + guard var candidate = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !candidate.isEmpty else { + return nil + } + + if let url = URLComponents(string: candidate), let host = url.host, !host.isEmpty { + return Endpoint( + user: url.user ?? "", + host: normalizedEndpointHost(host), + port: url.port, + invalidPort: nil + ) + } + + candidate = candidate.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? candidate + var user = "" + if let at = candidate.lastIndex(of: "@") { + user = String(candidate[.. String { + var candidate = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if addressFamily(for: candidate) == nil { + candidate = candidate.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + } + return candidate + } + + private static func renderSSHHost(_ endpoint: Endpoint) -> String { + guard let portText = endpoint.port.map(String.init) ?? endpoint.invalidPort, + endpoint.port != 22 else { + return endpoint.host + } + let renderedHost = addressFamily(for: endpoint.host) == .ipv6 ? "[\(endpoint.host)]" : endpoint.host + return "\(renderedHost):\(portText)" + } + + private static func inetPton(_ family: Int32, _ value: String) -> Bool { + value.withCString { cString in + switch family { + case AF_INET: + var address = in_addr() + return inet_pton(AF_INET, cString, &address) == 1 + case AF_INET6: + var address = in6_addr() + return inet_pton(AF_INET6, cString, &address) == 1 + default: + return false + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift new file mode 100644 index 00000000..add05454 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DeviceStatusPolicy.swift @@ -0,0 +1,172 @@ +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.down.on.square" + 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 + } + + if let runtimeState = profile.runtimeState { + switch runtimeState.state { + case .unknown: + return .unchecked + case .notInstalled: + return .readyToInstall + case .installing: + return .installing + case .installedVerified: + return .healthy + case .installedUnverified: + return .warning + case .installFailed, .installInterrupted, .unhealthy: + return .failed + case .activationNeeded: + return .activationNeeded + } + } + return .unchecked + } + +} + +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/Policies/DoctorCheckDomainPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DoctorCheckDomainPolicy.swift new file mode 100644 index 00000000..b4aeadce --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/DoctorCheckDomainPolicy.swift @@ -0,0 +1,141 @@ +import Foundation + +enum DoctorCheckDomain: String, CaseIterable, Equatable, Hashable, Identifiable { + case connection + case runtime + case finderBonjour + case smbAuth + case timeMachine + case disk + case metadata + case general + + var id: String { rawValue } + + var title: String { + switch self { + case .connection: + return L10n.string("doctor.domain.connection") + case .runtime: + return L10n.string("doctor.domain.runtime") + case .finderBonjour: + return L10n.string("doctor.domain.finder_bonjour") + case .smbAuth: + return L10n.string("doctor.domain.smb_auth") + case .timeMachine: + return L10n.string("doctor.domain.time_machine") + case .disk: + return L10n.string("doctor.domain.disk") + case .metadata: + return L10n.string("doctor.domain.metadata") + case .general: + return L10n.string("doctor.domain.general") + } + } +} + +enum DoctorCheckSeverity: Int, Equatable, Comparable { + case failed = 0 + case warning = 1 + case passed = 2 + case unknown = 3 + + static func < (left: DoctorCheckSeverity, right: DoctorCheckSeverity) -> Bool { + left.rawValue < right.rawValue + } +} + +struct DoctorDomainSignal: Equatable { + let domain: DoctorCheckDomain + let checks: [DoctorCheckPayload] + let passCount: Int + let warnCount: Int + let failCount: Int + let infoCount: Int + + var severity: DoctorCheckSeverity { + if failCount > 0 { + return .failed + } + if warnCount > 0 { + return .warning + } + if passCount > 0 { + return .passed + } + return .unknown + } + + var countSummary: String { + L10n.format("dashboard.health.check_counts", passCount, warnCount, failCount) + } +} + +enum DoctorCheckDomainPolicy { + static func domain(for rawDomain: String?) -> DoctorCheckDomain { + let normalized = rawDomain? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + switch normalized { + case "connection", "device", "ssh": + return .connection + case "runtime", "process", "service": + return .runtime + case "bonjour", "finder", "advertising", "discovery": + return .finderBonjour + case "smb", "smb auth", "auth": + return .smbAuth + case "time machine", "timemachine": + return .timeMachine + case "disk", "storage", "volume", "fsck": + return .disk + case "metadata", "xattrs", "xattr", "repair-xattrs": + return .metadata + default: + return .general + } + } + + static func domain(for check: DoctorCheckPayload) -> DoctorCheckDomain { + domain(for: check.details.stringValue(for: "domain")) + } + + static func signals(from summary: DoctorSummary) -> [DoctorDomainSignal] { + let grouped = Dictionary(grouping: summary.groups.flatMap(\.checks), by: domain(for:)) + return grouped + .map { signal(domain: $0.key, checks: $0.value) } + .sorted { left, right in + left.severity == right.severity + ? left.domain.title < right.domain.title + : left.severity < right.severity + } + } + + static func signal(for domain: DoctorCheckDomain, summary: DoctorSummary?) -> DoctorDomainSignal? { + guard let summary else { + return nil + } + let checks = summary.groups + .flatMap(\.checks) + .filter { self.domain(for: $0) == domain } + guard !checks.isEmpty else { + return nil + } + return signal(domain: domain, checks: checks) + } + + private static func signal(domain: DoctorCheckDomain, checks: [DoctorCheckPayload]) -> DoctorDomainSignal { + DoctorDomainSignal( + domain: domain, + checks: checks, + passCount: checks.filter { normalizedStatus($0.status) == "PASS" }.count, + warnCount: checks.filter { normalizedStatus($0.status) == "WARN" }.count, + failCount: checks.filter { normalizedStatus($0.status) == "FAIL" }.count, + infoCount: checks.filter { normalizedStatus($0.status) == "INFO" }.count + ) + } + + private static func normalizedStatus(_ status: String) -> String { + status.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift new file mode 100644 index 00000000..56e4f44c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/HostCompatibilityPolicy.swift @@ -0,0 +1,55 @@ +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( + enabled: Bool = true, + for version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion + ) -> HostCompatibilityWarning? { + guard enabled else { + return nil + } + 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/Policies/RecoveryActionMapper.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/RecoveryActionMapper.swift new file mode 100644 index 00000000..c97d9e95 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/RecoveryActionMapper.swift @@ -0,0 +1,133 @@ +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 + } + if allows(kind, for: error) { + actions.append(action(for: kind)) + } + } + + if let suggested = error.recovery?.suggestedOperation, suggested != error.operation { + let suggestedAction = action(forSuggestedOperation: suggested) + if allows(suggestedAction.kind, for: error) { + actions.append(suggestedAction) + } + } + + 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 allows(_ kind: RecoveryActionKind, for error: BackendErrorViewModel) -> Bool { + if error.operation == "deploy" { + switch kind { + case .openFinder, .installSMB: + return false + default: + break + } + } + return true + } + + 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/Policies/SMBAddressPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift new file mode 100644 index 00000000..da5ecae8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/SMBAddressPolicy.swift @@ -0,0 +1,84 @@ +import Foundation + +enum SMBAddressPolicy { + static func url(for profile: DeviceProfile, account: String? = nil) -> URL? { + guard let host = preferredHost(for: profile) else { + return nil + } + return url(host: host, account: account) + } + + static func preferredHost(for profile: DeviceProfile) -> String? { + if let serviceHost = bonjourSMBServiceHost(for: profile) { + return serviceHost + } + if let hostname = normalizedAddressHost(profile.hostname) { + return hostname + } + if let address = profile.network.addresses.first(where: { $0.scope == .regular }) { + return address.value + } + if let address = profile.network.addresses.first { + return address.value + } + return normalizedAddressHost(profile.host) + } + + static func credentialServerCandidates(for profile: DeviceProfile) -> [String] { + unique([ + normalizedAddressHost(profile.hostname), + normalizedAddressHost(profile.host) + ] + profile.network.addresses.map { normalizedAddressHost($0.value) }) + } + + static func reachabilityHostCandidates(for profile: DeviceProfile) -> [String] { + unique([ + preferredHost(for: profile), + normalizedAddressHost(profile.hostname), + normalizedAddressHost(profile.host) + ] + profile.network.addresses.map { normalizedAddressHost($0.value) }) + } + + private static func bonjourSMBServiceHost(for profile: DeviceProfile) -> String? { + if let fullname = profile.bonjourFullname?.trimmingCharacters(in: .whitespacesAndNewlines), + !fullname.isEmpty { + let trimmed = fullname.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let lowercased = trimmed.lowercased() + if lowercased.hasSuffix("._smb._tcp.local") { + return trimmed + } + for service in ["._airport._tcp.local", "._adisk._tcp.local", "._device-info._tcp.local"] { + if lowercased.hasSuffix(service) { + return String(trimmed.dropLast(service.count)) + "._smb._tcp.local" + } + } + } + + guard let bonjourName = profile.bonjourName?.trimmingCharacters(in: .whitespacesAndNewlines), + !bonjourName.isEmpty else { + return nil + } + return "\(bonjourName)._smb._tcp.local" + } + + private static func url(host: String, account: String?) -> URL? { + DeviceEndpointPolicy.smbURL(host: host, account: account) + } + + private static func normalizedAddressHost(_ value: String?) -> String? { + DeviceEndpointPolicy.hostComponent(value) + } + + private static func unique(_ values: [String?]) -> [String] { + var seen: Set = [] + var ordered: [String] = [] + for value in values { + guard let value else { continue } + let key = value.lowercased() + if seen.insert(key).inserted { + ordered.append(value) + } + } + return ordered + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift new file mode 100644 index 00000000..cc0a5062 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Policies/ValueParsers.swift @@ -0,0 +1,19 @@ +import Foundation + +enum ValueParsers { + static func nonNegativeInteger(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), value >= 0 else { + return nil + } + return value + } + + static 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/Profiles/ConfiguredDeviceModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift new file mode 100644 index 00000000..75e0ff10 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/ConfiguredDeviceModels.swift @@ -0,0 +1,21 @@ +import Foundation + +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 + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceNetworkIdentity.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceNetworkIdentity.swift new file mode 100644 index 00000000..a02e53fe --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceNetworkIdentity.swift @@ -0,0 +1,181 @@ +import Foundation + +enum NetworkAddressFamily: String, Codable, Equatable { + case ipv4 + case ipv6 + + var title: String { + switch self { + case .ipv4: + return "IPv4" + case .ipv6: + return "IPv6" + } + } +} + +enum NetworkAddressScope: String, Codable, Equatable { + case regular + case linkLocal + case loopback +} + +enum DeviceAddressSource: String, Codable, Equatable { + case bonjour + case configured + case manual +} + +struct DeviceNetworkAddress: Codable, Equatable, Identifiable { + var id: String { identityKey } + + var value: String + var family: NetworkAddressFamily + var scope: NetworkAddressScope + var source: DeviceAddressSource + + var normalizedValue: String { + DeviceEndpointPolicy.normalizedAddressValue(value, family: family) + } + + var identityKey: String { + "\(family.rawValue):\(normalizedValue)" + } + + init?(value: String, source: DeviceAddressSource) { + guard let host = DeviceEndpointPolicy.hostComponent(value), + let family = DeviceEndpointPolicy.addressFamily(for: host) else { + return nil + } + self.value = DeviceEndpointPolicy.normalizedAddressValue(host, family: family) + self.family = family + self.scope = DeviceEndpointPolicy.addressScope(value: self.value, family: family) + self.source = source + } +} + +struct DeviceNetworkIdentity: Codable, Equatable { + var configuredSSHTarget: String + var hostname: String? + var bonjourName: String? + var bonjourFullname: String? + var addresses: [DeviceNetworkAddress] + + init( + configuredSSHTarget: String, + hostname: String? = nil, + bonjourName: String? = nil, + bonjourFullname: String? = nil, + addresses: [DeviceNetworkAddress] = [] + ) { + self.configuredSSHTarget = configuredSSHTarget + self.hostname = Self.normalizedOptional(hostname) + self.bonjourName = Self.normalizedOptional(bonjourName) + self.bonjourFullname = Self.normalizedOptional(bonjourFullname) + self.addresses = DeviceEndpointPolicy.uniqueAddresses(addresses) + appendConfiguredTargetAddress() + } + + var configuredHost: String { + DeviceEndpointPolicy.hostComponent(configuredSSHTarget) + ?? configuredSSHTarget.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedConfiguredHost: String { + DeviceEndpointPolicy.normalizedHostKey(configuredSSHTarget) + } + + var preferredSetupTarget: String { + DeviceEndpointPolicy.preferredSetupTarget(for: self) ?? configuredHost + } + + var displayTarget: String { + DeviceEndpointPolicy.displayTarget(for: self) + } + + var addressValues: [String] { + addresses.map(\.value) + } + + var addressSummary: String { + DeviceEndpointPolicy.addressSummary(addresses) + } + + var normalizedHostname: String { + DeviceEndpointPolicy.normalizedHostname(hostname)?.lowercased() ?? "" + } + + var addressKeys: Set { + Set(addresses.map(\.identityKey)) + } + + var matchableAddressKeys: Set { + Set(addresses.filter { $0.scope == .regular }.map(\.identityKey)) + } + + func matches(_ other: DeviceNetworkIdentity) -> Bool { + if let leftFullname = Self.normalizedOptional(bonjourFullname)?.lowercased(), + let rightFullname = Self.normalizedOptional(other.bonjourFullname)?.lowercased(), + leftFullname == rightFullname { + return true + } + if !normalizedConfiguredHost.isEmpty && normalizedConfiguredHost == other.normalizedConfiguredHost { + return true + } + if !normalizedHostname.isEmpty && normalizedHostname == other.normalizedHostname { + return true + } + let leftKeys = matchableAddressKeys + let rightKeys = other.matchableAddressKeys + return !leftKeys.isEmpty && !rightKeys.isEmpty && !leftKeys.isDisjoint(with: rightKeys) + } + + mutating func setConfiguredSSHTarget(_ target: String) { + configuredSSHTarget = target + appendConfiguredTargetAddress() + } + + mutating func setAddressValues(_ values: [String], source: DeviceAddressSource = .bonjour) { + addresses = DeviceEndpointPolicy.uniqueAddresses(values.compactMap { DeviceNetworkAddress(value: $0, source: source) }) + appendConfiguredTargetAddress() + } + + mutating func mergeAddresses(_ newAddresses: [DeviceNetworkAddress]) { + addresses = DeviceEndpointPolicy.uniqueAddresses(addresses + newAddresses) + } + + private mutating func appendConfiguredTargetAddress() { + addresses.removeAll { $0.source == .configured } + guard let address = DeviceNetworkAddress(value: configuredSSHTarget, source: .configured) else { + addresses = DeviceEndpointPolicy.uniqueAddresses(addresses) + return + } + addresses = DeviceEndpointPolicy.uniqueAddresses(addresses + [address]) + } + + static func make( + configuredSSHTarget: String, + discoveredDevice: DiscoveredDevice?, + existing: DeviceNetworkIdentity? = nil + ) -> DeviceNetworkIdentity { + var identity = DeviceNetworkIdentity( + configuredSSHTarget: configuredSSHTarget, + hostname: discoveredDevice?.hostname ?? existing?.hostname, + bonjourName: discoveredDevice?.name ?? existing?.bonjourName, + bonjourFullname: discoveredDevice?.fullname ?? existing?.bonjourFullname, + addresses: existing?.addresses ?? [] + ) + if let discoveredDevice { + identity.mergeAddresses(discoveredDevice.networkAddresses) + } + return identity + } + + private static func normalizedOptional(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift new file mode 100644 index 00000000..a33fedb8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfile.swift @@ -0,0 +1,697 @@ +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 internalShareUseDiskRoot: Bool + var anyProtocol: Bool + var debugLogging: Bool + var mountWaitSeconds: Int + var ataIdleSeconds: Int + var ataStandby: Int? + + static let `default` = DeviceProfileSettings( + nbnsEnabled: true, + internalShareUseDiskRoot: false, + anyProtocol: false, + debugLogging: false, + mountWaitSeconds: 30, + ataIdleSeconds: 300, + ataStandby: nil + ) + + init( + nbnsEnabled: Bool, + internalShareUseDiskRoot: Bool = false, + anyProtocol: Bool = false, + debugLogging: Bool, + mountWaitSeconds: Int, + ataIdleSeconds: Int = 300, + ataStandby: Int? = nil + ) { + self.nbnsEnabled = nbnsEnabled + self.internalShareUseDiskRoot = internalShareUseDiskRoot + self.anyProtocol = anyProtocol + self.debugLogging = debugLogging + self.mountWaitSeconds = mountWaitSeconds + self.ataIdleSeconds = ataIdleSeconds + self.ataStandby = ataStandby + } + + private enum CodingKeys: String, CodingKey { + case nbnsEnabled + case internalShareUseDiskRoot + case anyProtocol + case debugLogging + case mountWaitSeconds + case ataIdleSeconds + case ataStandby + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + nbnsEnabled = try container.decodeIfPresent(Bool.self, forKey: .nbnsEnabled) ?? Self.default.nbnsEnabled + internalShareUseDiskRoot = try container.decodeIfPresent(Bool.self, forKey: .internalShareUseDiskRoot) ?? Self.default.internalShareUseDiskRoot + anyProtocol = try container.decodeIfPresent(Bool.self, forKey: .anyProtocol) ?? Self.default.anyProtocol + debugLogging = try container.decodeIfPresent(Bool.self, forKey: .debugLogging) ?? Self.default.debugLogging + mountWaitSeconds = try container.decodeIfPresent(Int.self, forKey: .mountWaitSeconds) ?? Self.default.mountWaitSeconds + ataIdleSeconds = Self.decodeNonNegativeInteger( + from: container, + forKey: .ataIdleSeconds, + defaultValue: Self.default.ataIdleSeconds + ) + ataStandby = Self.decodeOptionalNonNegativeInteger(from: container, forKey: .ataStandby) + } + + private static func decodeNonNegativeInteger( + from container: KeyedDecodingContainer, + forKey key: CodingKeys, + defaultValue: Int + ) -> Int { + if let value = try? container.decodeIfPresent(Int.self, forKey: key), value >= 0 { + return value + } + if let text = try? container.decodeIfPresent(String.self, forKey: key), + let parsed = ValueParsers.nonNegativeInteger(text) { + return parsed + } + return defaultValue + } + + private static func decodeOptionalNonNegativeInteger( + from container: KeyedDecodingContainer, + forKey key: CodingKeys + ) -> Int? { + if let value = try? container.decodeIfPresent(Int.self, forKey: key), value >= 0 { + return value + } + if let text = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return ValueParsers.nonNegativeInteger(trimmed) + } + return nil + } +} + +struct DeviceCheckupSnapshot: Codable, Equatable { + var checkedAt: Date + var state: DoctorWorkflowState + var passCount: Int + var warnCount: Int + var failCount: Int + var summary: String + + var localizedSummary: String { + L10n.format("summary.checkup_counts", passCount, warnCount, failCount) + } +} + +struct DeviceRecoverySnapshot: Codable, Equatable { + var title: String + var message: String? + var actions: [String] + var actionIDs: [String] + var retryable: Bool + var suggestedOperation: String? + var docsAnchor: String? + var localizationKey: String? + + init( + title: String, + message: String?, + actions: [String], + actionIDs: [String], + retryable: Bool, + suggestedOperation: String?, + docsAnchor: String?, + localizationKey: String? = nil + ) { + self.title = title + self.message = message + self.actions = actions + self.actionIDs = actionIDs + self.retryable = retryable + self.suggestedOperation = suggestedOperation + self.docsAnchor = docsAnchor + self.localizationKey = localizationKey + } + + init(_ recovery: BackendRecoveryPayload) { + self.init( + title: recovery.title, + message: recovery.message, + actions: recovery.actions, + actionIDs: recovery.actionIDs, + retryable: recovery.retryable, + suggestedOperation: recovery.suggestedOperation, + docsAnchor: recovery.docsAnchor, + localizationKey: recovery.localizationKey + ) + } +} + +enum DeviceDeployStateStatus: String, Codable, Equatable, CaseIterable { + case deploying + case awaitingConfirmation + case succeeded + case failed + case interrupted + + var isInProgress: Bool { + switch self { + case .deploying, .awaitingConfirmation: + return true + case .succeeded, .failed, .interrupted: + return false + } + } + + var isFailure: Bool { + switch self { + case .failed, .interrupted: + return true + case .deploying, .awaitingConfirmation, .succeeded: + return false + } + } +} + +struct DeviceDeployStateSnapshot: Codable, Equatable { + var operationID: String? + var startedAt: Date + var updatedAt: Date + var finishedAt: Date? + var status: DeviceDeployStateStatus + var stage: String? + var payloadFamily: String? + var rebootRequested: Bool? + var verified: Bool? + var summary: String + var errorCode: String? + var errorMessage: String? + var recovery: DeviceRecoverySnapshot? + + var localizedSummary: String { + switch status { + case .succeeded: + let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return BackendSummaryLocalization.localized(trimmed, operation: "deploy") + } + return L10n.string("deploy.result.default_message") + case .failed: + let trimmed = (errorMessage ?? summary).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? L10n.string("install.state.deploy_failed") : trimmed + case .interrupted: + let trimmed = (errorMessage ?? summary).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? L10n.string("install.state.deploy_interrupted") : trimmed + case .deploying: + return L10n.string("install.state.deploying") + case .awaitingConfirmation: + return L10n.string("install.state.awaiting_confirmation") + } + } + + init( + operationID: String?, + startedAt: Date, + updatedAt: Date, + finishedAt: Date?, + status: DeviceDeployStateStatus, + stage: String?, + payloadFamily: String?, + rebootRequested: Bool?, + verified: Bool?, + summary: String, + errorCode: String?, + errorMessage: String?, + recovery: DeviceRecoverySnapshot? + ) { + self.operationID = operationID + self.startedAt = startedAt + self.updatedAt = updatedAt + self.finishedAt = finishedAt + self.status = status + self.stage = stage + self.payloadFamily = payloadFamily + self.rebootRequested = rebootRequested + self.verified = verified + self.summary = summary + self.errorCode = errorCode + self.errorMessage = errorMessage + self.recovery = recovery + } +} + +enum DeviceRuntimeState: String, Codable, Equatable, CaseIterable { + case unknown + case notInstalled + case installing + case installedUnverified + case installedVerified + case installFailed + case installInterrupted + case activationNeeded + case unhealthy + + var isInstalled: Bool { + switch self { + case .installedUnverified, .installedVerified, .activationNeeded: + return true + case .unknown, .notInstalled, .installing, .installFailed, .installInterrupted, .unhealthy: + return false + } + } + + var isFailure: Bool { + switch self { + case .installFailed, .installInterrupted, .unhealthy: + return true + case .unknown, .notInstalled, .installing, .installedUnverified, .installedVerified, .activationNeeded: + return false + } + } +} + +enum DeviceRuntimeEvidenceSource: String, Codable, Equatable, CaseIterable { + case deploy + case doctor + case appRecovery +} + +struct DeviceRuntimeStateSnapshot: Codable, Equatable { + var state: DeviceRuntimeState + var source: DeviceRuntimeEvidenceSource + var stage: String? + var payloadFamily: String? + var verified: Bool? + var summary: String + var errorCode: String? + var errorMessage: String? + var recovery: DeviceRecoverySnapshot? + + var localizedSummary: String { + switch state { + case .unknown: + return L10n.string("runtime.state.unknown") + case .notInstalled: + return L10n.string("runtime.state.not_installed") + case .installing: + return L10n.string("install.state.deploying") + case .installedVerified: + let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return source == .doctor + ? BackendSummaryLocalization.localized(trimmed, operation: "doctor") + : BackendSummaryLocalization.localized(trimmed, operation: "deploy") + } + return source == .doctor + ? L10n.string("summary.install_verified_by_checkup") + : L10n.string("deploy.result.default_message") + case .installedUnverified: + let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + ? L10n.string("deploy.result.default_message") + : BackendSummaryLocalization.localized(trimmed, operation: "deploy") + case .installFailed: + let trimmed = (errorMessage ?? summary).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? L10n.string("install.state.deploy_failed") : trimmed + case .installInterrupted: + let trimmed = (errorMessage ?? summary).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? L10n.string("install.state.deploy_interrupted") : trimmed + case .activationNeeded: + return L10n.string("dashboard.health.runtime.activation_needed") + case .unhealthy: + let trimmed = (errorMessage ?? summary).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? L10n.string("runtime.state.unhealthy") : trimmed + } + } + + init( + state: DeviceRuntimeState, + source: DeviceRuntimeEvidenceSource, + stage: String?, + payloadFamily: String?, + verified: Bool?, + summary: String, + errorCode: String?, + errorMessage: String?, + recovery: DeviceRecoverySnapshot? + ) { + self.state = state + self.source = source + self.stage = stage + self.payloadFamily = payloadFamily + self.verified = verified + self.summary = summary + self.errorCode = errorCode + self.errorMessage = errorMessage + self.recovery = recovery + } +} + +struct DeviceProfile: Codable, Equatable, Identifiable { + typealias ID = String + + var id: ID + var displayName: String + var network: DeviceNetworkIdentity + 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 lastDeployState: DeviceDeployStateSnapshot? + var runtimeState: DeviceRuntimeStateSnapshot? + var settings: DeviceProfileSettings + var passwordState: DevicePasswordState + + var host: String { + get { network.configuredSSHTarget } + set { network.setConfiguredSSHTarget(newValue) } + } + + var bonjourName: String? { + get { network.bonjourName } + set { network.bonjourName = newValue } + } + + var bonjourFullname: String? { + get { network.bonjourFullname } + set { network.bonjourFullname = newValue } + } + + var hostname: String? { + get { network.hostname } + set { network.hostname = newValue } + } + + var addresses: [String] { + get { network.addressValues } + set { network.setAddressValues(newValue) } + } + + var connectionTarget: String { + network.preferredSetupTarget + } + + var displayTarget: String { + network.displayTarget + } + + var addressSummary: String { + network.addressSummary + } + + 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 displayTarget.isEmpty ? "Time Capsule" : displayTarget + } + + var normalizedHost: String { + Self.normalizedHost(host) + } + + var runtimeContext: DeviceRuntimeContext { + DeviceRuntimeContext(profileID: id, configURL: URL(fileURLWithPath: configPath)) + } + + var configURL: URL { + 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 { + DeviceEndpointPolicy.normalizedHostKey(host) + } + + static func matches(_ left: DeviceProfile, _ right: DeviceProfile) -> Bool { + left.network.matches(right.network) + } + + 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", + network: DeviceNetworkIdentity.make( + configuredSSHTarget: configuredDevice.host, + discoveredDevice: discoveredDevice, + existing: existing?.network + ), + 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, + lastDeployState: existing?.lastDeployState, + runtimeState: existing?.runtimeState, + settings: existing?.settings ?? .default, + passwordState: existing?.passwordState ?? .unknown + ) + } + + private enum CodingKeys: String, CodingKey { + case id + case displayName + case network + case syap + case model + case osName + case osRelease + case arch + case elfEndianness + case payloadFamily + case deviceGeneration + case configPath + case keychainAccount + case createdAt + case updatedAt + case lastCheckup + case lastDeployState + case runtimeState + case settings + case passwordState + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(ID.self, forKey: .id) + displayName = try container.decode(String.self, forKey: .displayName) + network = try container.decode(DeviceNetworkIdentity.self, forKey: .network) + syap = try container.decodeIfPresent(String.self, forKey: .syap) + model = try container.decodeIfPresent(String.self, forKey: .model) + osName = try container.decodeIfPresent(String.self, forKey: .osName) + osRelease = try container.decodeIfPresent(String.self, forKey: .osRelease) + arch = try container.decodeIfPresent(String.self, forKey: .arch) + elfEndianness = try container.decodeIfPresent(String.self, forKey: .elfEndianness) + payloadFamily = try container.decodeIfPresent(String.self, forKey: .payloadFamily) + deviceGeneration = try container.decodeIfPresent(String.self, forKey: .deviceGeneration) + configPath = try container.decodeIfPresent(String.self, forKey: .configPath) ?? "" + keychainAccount = try container.decodeIfPresent(String.self, forKey: .keychainAccount) ?? id + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + lastCheckup = try container.decodeIfPresent(DeviceCheckupSnapshot.self, forKey: .lastCheckup) + lastDeployState = try container.decodeIfPresent(DeviceDeployStateSnapshot.self, forKey: .lastDeployState) + runtimeState = try container.decodeIfPresent(DeviceRuntimeStateSnapshot.self, forKey: .runtimeState) + settings = try container.decodeIfPresent(DeviceProfileSettings.self, forKey: .settings) ?? .default + passwordState = try container.decodeIfPresent(DevicePasswordState.self, forKey: .passwordState) ?? .unknown + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(displayName, forKey: .displayName) + try container.encode(network, forKey: .network) + try container.encodeIfPresent(syap, forKey: .syap) + try container.encodeIfPresent(model, forKey: .model) + try container.encodeIfPresent(osName, forKey: .osName) + try container.encodeIfPresent(osRelease, forKey: .osRelease) + try container.encodeIfPresent(arch, forKey: .arch) + try container.encodeIfPresent(elfEndianness, forKey: .elfEndianness) + try container.encodeIfPresent(payloadFamily, forKey: .payloadFamily) + try container.encodeIfPresent(deviceGeneration, forKey: .deviceGeneration) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(lastCheckup, forKey: .lastCheckup) + try container.encodeIfPresent(lastDeployState, forKey: .lastDeployState) + try container.encodeIfPresent(runtimeState, forKey: .runtimeState) + try container.encode(settings, forKey: .settings) + try container.encode(passwordState, forKey: .passwordState) + } + + init( + id: ID, + displayName: String, + network: DeviceNetworkIdentity, + syap: String?, + model: String?, + osName: String?, + osRelease: String?, + arch: String?, + elfEndianness: String?, + payloadFamily: String?, + deviceGeneration: String?, + configPath: String, + keychainAccount: String, + createdAt: Date, + updatedAt: Date, + lastCheckup: DeviceCheckupSnapshot?, + lastDeployState: DeviceDeployStateSnapshot? = nil, + runtimeState: DeviceRuntimeStateSnapshot? = nil, + settings: DeviceProfileSettings, + passwordState: DevicePasswordState + ) { + self.id = id + self.displayName = displayName + self.network = network + self.syap = syap + self.model = model + self.osName = osName + self.osRelease = osRelease + self.arch = arch + self.elfEndianness = elfEndianness + self.payloadFamily = payloadFamily + self.deviceGeneration = deviceGeneration + self.configPath = configPath + self.keychainAccount = keychainAccount + self.createdAt = createdAt + self.updatedAt = updatedAt + self.lastCheckup = lastCheckup + self.lastDeployState = lastDeployState + self.runtimeState = runtimeState + self.settings = settings + self.passwordState = passwordState + } + + init( + id: ID, + displayName: String, + host: String, + bonjourName: String?, + bonjourFullname: String?, + hostname: String?, + addresses: [String], + syap: String?, + model: String?, + osName: String?, + osRelease: String?, + arch: String?, + elfEndianness: String?, + payloadFamily: String?, + deviceGeneration: String?, + configPath: String, + keychainAccount: String, + createdAt: Date, + updatedAt: Date, + lastCheckup: DeviceCheckupSnapshot?, + lastDeployState: DeviceDeployStateSnapshot? = nil, + runtimeState: DeviceRuntimeStateSnapshot? = nil, + settings: DeviceProfileSettings, + passwordState: DevicePasswordState + ) { + self.init( + id: id, + displayName: displayName, + network: DeviceNetworkIdentity( + configuredSSHTarget: host, + hostname: hostname, + bonjourName: bonjourName, + bonjourFullname: bonjourFullname, + addresses: addresses.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + ), + syap: syap, + model: model, + osName: osName, + osRelease: osRelease, + arch: arch, + elfEndianness: elfEndianness, + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration, + configPath: configPath, + keychainAccount: keychainAccount, + createdAt: createdAt, + updatedAt: updatedAt, + lastCheckup: lastCheckup, + lastDeployState: lastDeployState, + runtimeState: runtimeState, + settings: settings, + passwordState: passwordState + ) + } + +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditableFields.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditableFields.swift new file mode 100644 index 00000000..fe89caba --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditableFields.swift @@ -0,0 +1,6 @@ +import Foundation + +struct DeviceProfileEditableFields: Equatable { + let displayName: String + let settings: DeviceProfileSettings +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift new file mode 100644 index 00000000..f67ffd29 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileEditorStore.swift @@ -0,0 +1,588 @@ +import Combine +import Foundation + +enum DeviceProfileEditorState: String, CaseIterable, Equatable { + case clean + case dirty + case invalid + case saving + case reconfiguring + case saved + case authFailed + case unsupported + case failed + + var title: String { + switch self { + case .clean: + return L10n.string("profile_editor.state.clean") + case .dirty: + return L10n.string("profile_editor.state.dirty") + case .invalid: + return L10n.string("profile_editor.state.invalid") + case .saving: + return L10n.string("profile_editor.state.saving") + case .reconfiguring: + return L10n.string("profile_editor.state.reconfiguring") + case .saved: + return L10n.string("profile_editor.state.saved") + case .authFailed: + return L10n.string("profile_editor.state.auth_failed") + case .unsupported: + return L10n.string("profile_editor.state.unsupported") + case .failed: + return L10n.string("profile_editor.state.failed") + } + } +} + +enum DeviceProfileEditorValidationError: String, CaseIterable, Equatable, LocalizedError { + case hostRequired + case duplicateHost + case mountWaitInvalid + case ataIdleSecondsInvalid + case ataStandbyInvalid + case passwordRequired + + var errorDescription: String? { + switch self { + case .hostRequired: + return L10n.string("profile_editor.error.host_required") + case .duplicateHost: + return L10n.string("profile_editor.error.duplicate_host") + case .mountWaitInvalid: + return L10n.string("profile_editor.error.mount_wait_invalid") + case .ataIdleSecondsInvalid: + return L10n.string("profile_editor.error.ata_idle_seconds_invalid") + case .ataStandbyInvalid: + return L10n.string("profile_editor.error.ata_standby_invalid") + case .passwordRequired: + return L10n.string("profile_editor.error.password_required") + } + } +} + +fileprivate struct DeviceProfileEditorSettingsValidation { + let settings: DeviceProfileSettings? + let errors: [DeviceProfileEditorValidationError] +} + +fileprivate struct DeviceProfileEditorDraftValidation { + let settings: DeviceProfileSettings? + let errors: [DeviceProfileEditorValidationError] +} + +struct DeviceProfileEditorDraft: Equatable { + var displayName: String + var host: String + var nbnsEnabled: Bool + var internalShareUseDiskRoot: Bool + var anyProtocol: Bool + var debugLogging: Bool + var mountWaitSeconds: String + var ataIdleSeconds: String + var ataStandby: String + + init( + displayName: String, + host: String, + nbnsEnabled: Bool, + internalShareUseDiskRoot: Bool = false, + anyProtocol: Bool = false, + debugLogging: Bool, + mountWaitSeconds: String, + ataIdleSeconds: String = String(DeviceProfileSettings.default.ataIdleSeconds), + ataStandby: String = DeviceProfileSettings.default.ataStandby.map { String($0) } ?? "" + ) { + self.displayName = displayName + self.host = host + self.nbnsEnabled = nbnsEnabled + self.internalShareUseDiskRoot = internalShareUseDiskRoot + self.anyProtocol = anyProtocol + self.debugLogging = debugLogging + self.mountWaitSeconds = mountWaitSeconds + self.ataIdleSeconds = ataIdleSeconds + self.ataStandby = ataStandby + } + + init(profile: DeviceProfile) { + self.init( + displayName: profile.displayName, + host: profile.host, + nbnsEnabled: profile.settings.nbnsEnabled, + internalShareUseDiskRoot: profile.settings.internalShareUseDiskRoot, + anyProtocol: profile.settings.anyProtocol, + debugLogging: profile.settings.debugLogging, + mountWaitSeconds: String(profile.settings.mountWaitSeconds), + ataIdleSeconds: String(profile.settings.ataIdleSeconds), + ataStandby: profile.settings.ataStandby.map { String($0) } ?? "" + ) + } + + var trimmedHost: String { + host.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func hostChanged(from profile: DeviceProfile) -> Bool { + DeviceEndpointPolicy.normalizedHostKey(trimmedHost) != DeviceEndpointPolicy.normalizedHostKey(profile.host) + } + + func validatedSettings() throws -> DeviceProfileSettings { + let validation = settingsValidation() + if let settings = validation.settings { + return settings + } + throw validation.errors.first ?? DeviceProfileEditorValidationError.mountWaitInvalid + } + + fileprivate func settingsValidation() -> DeviceProfileEditorSettingsValidation { + var errors: [DeviceProfileEditorValidationError] = [] + let mountWait = ValueParsers.nonNegativeInteger(mountWaitSeconds) + if mountWait == nil { + errors.append(.mountWaitInvalid) + } + let ataIdle = ValueParsers.nonNegativeInteger(ataIdleSeconds) + if ataIdle == nil { + errors.append(.ataIdleSecondsInvalid) + } + let trimmedAtaStandby = ataStandby.trimmingCharacters(in: .whitespacesAndNewlines) + let parsedAtaStandby: Int? + if trimmedAtaStandby.isEmpty { + parsedAtaStandby = nil + } else if let value = ValueParsers.nonNegativeInteger(trimmedAtaStandby) { + parsedAtaStandby = value + } else { + errors.append(.ataStandbyInvalid) + parsedAtaStandby = nil + } + guard errors.isEmpty, let mountWait, let ataIdle else { + return DeviceProfileEditorSettingsValidation(settings: nil, errors: errors) + } + let settings = DeviceProfileSettings( + nbnsEnabled: nbnsEnabled, + internalShareUseDiskRoot: internalShareUseDiskRoot, + anyProtocol: anyProtocol, + debugLogging: debugLogging, + mountWaitSeconds: mountWait, + ataIdleSeconds: ataIdle, + ataStandby: parsedAtaStandby + ) + return DeviceProfileEditorSettingsValidation(settings: settings, errors: []) + } + + func editableFields() throws -> DeviceProfileEditableFields { + DeviceProfileEditableFields(displayName: displayName, settings: try validatedSettings()) + } +} + +@MainActor +final class DeviceProfileEditorStore: ObservableObject { + @Published var draft: DeviceProfileEditorDraft { + didSet { markDirtyAfterDraftChange() } + } + @Published private(set) var state: DeviceProfileEditorState = .clean + @Published private(set) var validationErrors: [DeviceProfileEditorValidationError] = [] + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var savedProfile: DeviceProfile? + @Published var replacementPassword = "" { + didSet { markDirtyAfterPasswordChange() } + } + @Published private(set) var passwordError: String? + + private let appStore: AppStore + private let coordinator: OperationCoordinator + private let lane: OperationLane + private let profilePersistence: DeviceProfilePersistenceService + private var baselineDraft: DeviceProfileEditorDraft + private let operationObserver = BackendOperationObserver() + private var pendingProfile: DeviceProfile? + private var pendingEditableFields: DeviceProfileEditableFields? + private var pendingPassword: String? + private var pendingConfigureDraft: ConfigureProfileDraft? + private var isApplyingDraft = false + private var isApplyingPasswordDraft = false + private var cancellables: Set = [] + + init( + profile: DeviceProfile, + appStore: AppStore, + profilePersistence: DeviceProfilePersistenceService? = nil + ) { + let initialDraft = DeviceProfileEditorDraft(profile: profile) + self.draft = initialDraft + self.baselineDraft = initialDraft + self.appStore = appStore + self.coordinator = appStore.operationCoordinator + self.lane = appStore.operationCoordinator.lane(for: .deviceWorkflow(profile.id, .configure)) + self.profilePersistence = profilePersistence ?? appStore.profilePersistence + observeBackend() + } + + var isRunning: Bool { + state == .saving || state == .reconfiguring + } + + var canSave: Bool { + !isRunning && hasPendingChanges + } + + private var hasPendingPasswordChange: Bool { + !replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var hasPendingChanges: Bool { + draft != baselineDraft || hasPendingPasswordChange + } + + func sync(to profile: DeviceProfile) { + let profileDraft = DeviceProfileEditorDraft(profile: profile) + guard profileDraft != baselineDraft else { + return + } + + let wasClean = !hasPendingChanges + baselineDraft = profileDraft + guard !isRunning else { + return + } + + if wasClean { + applyDraft(profileDraft) + validationErrors = [] + error = nil + currentStage = nil + savedProfile = nil + state = .clean + } else { + updateDraftChangeState() + } + } + + func reset(to profile: DeviceProfile) { + let profileDraft = DeviceProfileEditorDraft(profile: profile) + baselineDraft = profileDraft + applyDraft(profileDraft) + applyPasswordDraft("") + passwordError = nil + validationErrors = [] + error = nil + currentStage = nil + savedProfile = nil + state = .clean + clearPendingOperation() + } + + func save(profile: DeviceProfile) async { + let validation = validationResult(for: profile) + guard validation.errors.isEmpty, let settings = validation.settings else { + self.validationErrors = validation.errors + self.error = nil + self.state = .invalid + return + } + + let pendingReplacementPassword = hasPendingPasswordChange ? replacementPassword : nil + + if draft.hostChanged(from: profile) { + guard let password = pendingReplacementPassword ?? appStore.password(for: profile) else { + self.validationErrors = [.passwordRequired] + passwordError = L10n.string("password.error.required") + error = nil + state = .invalid + return + } + startReconfigure(profile: profile, password: password, settings: settings) + } else { + await saveRegistryOnly(profile: profile, settings: settings, replacementPassword: pendingReplacementPassword) + } + } + + func requestPasswordReplacement(error: String?) { + if !hasPendingPasswordChange { + applyPasswordDraft("") + } + passwordError = error + if error != nil { + validationErrors = [] + self.error = nil + state = .invalid + } else { + updateDraftChangeState() + } + } + + func clearPasswordAttention() { + passwordError = nil + if state == .invalid && validationErrors.isEmpty { + updateDraftChangeState() + } + } + + private func observeBackend() { + lane.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + private func validationResult(for profile: DeviceProfile) -> DeviceProfileEditorDraftValidation { + var errors: [DeviceProfileEditorValidationError] = [] + if draft.trimmedHost.isEmpty { + errors.append(.hostRequired) + } else if let duplicate = appStore.deviceRegistry.matchingProfile(host: draft.trimmedHost, bonjourFullname: nil), + duplicate.id != profile.id { + errors.append(.duplicateHost) + } + let settingsValidation = draft.settingsValidation() + errors.append(contentsOf: settingsValidation.errors) + return DeviceProfileEditorDraftValidation( + settings: errors.isEmpty ? settingsValidation.settings : nil, + errors: errors + ) + } + + private func saveRegistryOnly(profile: DeviceProfile, settings: DeviceProfileSettings, replacementPassword: String?) async { + state = .saving + validationErrors = [] + error = nil + currentStage = nil + do { + let saved = try await appStore.saveProfileEdits( + profile: profile, + fields: DeviceProfileEditableFields(displayName: draft.displayName, settings: settings), + replacementPassword: replacementPassword + ) + savedProfile = saved + let savedDraft = DeviceProfileEditorDraft(profile: saved) + baselineDraft = savedDraft + applyDraft(savedDraft) + applyPasswordDraft("") + passwordError = nil + state = .saved + } catch { + if replacementPassword != nil { + passwordError = error.localizedDescription + } + failSave(error) + } + } + + private func startReconfigure(profile: DeviceProfile, password: String, settings: DeviceProfileSettings) { + let configureDraft: ConfigureProfileDraft + do { + configureDraft = try profilePersistence.prepareConfigureTarget( + targetHost: draft.trimmedHost, + discoveredDevice: nil, + existingProfile: profile, + preferredID: profile.id, + settings: settings + ) + } catch { + failSave(error) + return + } + let params = OperationParams.Configure.save( + host: draft.trimmedHost, + password: password, + debugLogging: draft.debugLogging, + internalShareUseDiskRoot: draft.internalShareUseDiskRoot, + anyProtocol: draft.anyProtocol, + ataIdleSeconds: settings.ataIdleSeconds, + ataStandby: settings.ataStandby, + includeAtaStandby: true + ) + let start = coordinator.run( + operation: "configure", + params: params, + context: configureDraft.context, + activeDeviceID: profile.id, + laneKey: .deviceWorkflow(profile.id, .configure) + ) + guard case .started(let operation) = start else { + error = BackendErrorViewModel( + operation: "configure", + code: "operation_rejected", + message: start.rejectionMessage ?? L10n.string("operation.error.already_running") + ) + state = .failed + return + } + operationObserver.start(operation) + pendingProfile = profile + pendingEditableFields = DeviceProfileEditableFields(displayName: draft.displayName, settings: settings) + pendingPassword = password + pendingConfigureDraft = configureDraft + validationErrors = [] + error = nil + currentStage = nil + savedProfile = nil + state = .reconfiguring + process(lane.backend.events) + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, operation in + handle(event, activeOperation: operation) + } + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard 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 { + failFromResult(event) + return + } + applyConfigureResult(event, activeOperation: activeOperation) + } + + private func applyConfigureResult(_ event: BackendEvent, activeOperation: ActiveOperation) { + let configured: ConfiguredDeviceState + do { + configured = ConfiguredDeviceState(payload: try event.decodePayload(ConfigurePayload.self)) + } catch { + failContract(error) + return + } + guard pendingProfile != nil, + let editableFields = pendingEditableFields, + let password = pendingPassword, + let configureDraft = pendingConfigureDraft else { + failContract(DeviceRegistryError.profileNotFound(activeOperation.profileID ?? "unknown")) + return + } + + state = .saving + Task { @MainActor in + do { + let saved = try await profilePersistence.commitConfiguredProfile( + configuredDevice: configured, + draft: configureDraft, + password: password, + overrides: ConfiguredDeviceProfileOverrides( + displayName: editableFields.displayName, + settings: editableFields.settings + ) + ) + savedProfile = saved + let savedDraft = DeviceProfileEditorDraft(profile: saved) + baselineDraft = savedDraft + applyDraft(savedDraft) + applyPasswordDraft("") + passwordError = nil + error = nil + validationErrors = [] + currentStage = nil + state = .saved + clearPendingOperation() + } catch { + failSave(error) + } + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + switch event.code { + case "auth_failed": + if let profileID = operationObserver.activeOperation?.profileID { + Task { await appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) } + } + state = .authFailed + case "unsupported_device": + state = .unsupported + default: + state = .failed + } + clearPendingOperation() + } + + private func failFromResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.localizedPayloadSummaryText ?? event.localizedSummary + ) + state = .failed + clearPendingOperation() + } + + private func failContract(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "configure", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + clearPendingOperation() + } + + private func failSave(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "device-profile", + code: "profile_save_failed", + message: error.localizedDescription + ) + state = .failed + clearPendingOperation() + } + + private func clearPendingOperation() { + operationObserver.finish() + profilePersistence.discardConfigureDraft(pendingConfigureDraft) + pendingProfile = nil + pendingEditableFields = nil + pendingPassword = nil + pendingConfigureDraft = nil + } + + private func applyDraft(_ draft: DeviceProfileEditorDraft) { + isApplyingDraft = true + self.draft = draft + isApplyingDraft = false + } + + private func applyPasswordDraft(_ password: String) { + isApplyingPasswordDraft = true + replacementPassword = password + isApplyingPasswordDraft = false + } + + private func markDirtyAfterDraftChange() { + guard !isApplyingDraft, !isRunning else { + return + } + updateDraftChangeState() + } + + private func markDirtyAfterPasswordChange() { + guard !isApplyingPasswordDraft, !isRunning else { + return + } + passwordError = nil + updateDraftChangeState() + } + + private func updateDraftChangeState() { + error = nil + validationErrors = [] + savedProfile = nil + state = hasPendingChanges ? .dirty : .clean + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfilePersistenceService.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfilePersistenceService.swift new file mode 100644 index 00000000..267ae690 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfilePersistenceService.swift @@ -0,0 +1,317 @@ +import Foundation + +enum CredentialResolution: Equatable { + case available(String) + case missing + case invalid + case unavailable(String) + + var password: String? { + guard case .available(let password) = self else { + return nil + } + return password + } +} + +struct ConfigureProfileDraft: Equatable { + let profileID: DeviceProfile.ID + let existingProfileID: DeviceProfile.ID? + let discoveredDevice: DiscoveredDevice? + let targetHost: String + let settings: DeviceProfileSettings + let context: DeviceRuntimeContext +} + +struct ConfiguredDeviceProfileOverrides: Equatable { + var displayName: String? + var settings: DeviceProfileSettings? + + static let empty = ConfiguredDeviceProfileOverrides() +} + +@MainActor +final class DeviceProfilePersistenceService { + private enum PasswordRollback { + case delete + case restore(String) + } + + private let registry: DeviceRegistryStore + private let passwordStore: PasswordStore + private let artifacts: DeviceProfileArtifacts + + init( + registry: DeviceRegistryStore, + passwordStore: PasswordStore, + fileManager: FileManager = .default + ) { + self.registry = registry + self.passwordStore = passwordStore + self.artifacts = DeviceProfileArtifacts(applicationSupportURL: registry.applicationSupportURL, fileManager: fileManager) + } + + func prepareConfigureTarget( + targetHost: String, + discoveredDevice: DiscoveredDevice?, + existingProfile: DeviceProfile?, + preferredID: DeviceProfile.ID, + settings: DeviceProfileSettings + ) throws -> ConfigureProfileDraft { + let profileID = existingProfile?.id ?? preferredID + let stagedConfigURL = try artifacts.stageConfig(for: profileID, sourceProfile: existingProfile) + return ConfigureProfileDraft( + profileID: profileID, + existingProfileID: existingProfile?.id, + discoveredDevice: discoveredDevice, + targetHost: targetHost, + settings: settings, + context: DeviceRuntimeContext(profileID: profileID, configURL: stagedConfigURL) + ) + } + + func discardConfigureDraft(_ draft: ConfigureProfileDraft?) { + guard let draft else { + return + } + artifacts.discardStagedConfig(at: draft.context.configURL) + } + + @discardableResult + func commitConfiguredProfile( + configuredDevice: ConfiguredDeviceState, + draft: ConfigureProfileDraft, + password: String, + overrides: ConfiguredDeviceProfileOverrides = .empty + ) async throws -> DeviceProfile { + var profile = await registry.makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: draft.discoveredDevice, + passwordState: .available, + preferredID: draft.profileID, + existingProfileID: draft.existingProfileID + ) + if let displayName = overrides.displayName { + profile.displayName = displayName + } + if let settings = overrides.settings { + profile.settings = settings + } + + let rollback = try passwordRollback(for: profile.keychainAccount) + do { + try passwordStore.save(password, for: profile.keychainAccount) + } catch { + artifacts.discardStagedConfig(at: draft.context.configURL) + throw error + } + + let artifactRollback: DeviceProfileArtifacts.CommitRollback + do { + artifactRollback = try artifacts.commitStagedConfig(at: draft.context.configURL, to: profile.configURL) + } catch { + rollbackPassword(rollback, account: profile.keychainAccount) + artifacts.discardStagedConfig(at: draft.context.configURL) + throw error + } + + do { + let saved = try await registry.saveProfileMergingDuplicates(profile) + artifactRollback.discardBackup() + return saved + } catch { + artifactRollback.rollback() + rollbackPassword(rollback, account: profile.keychainAccount) + throw error + } + } + + @discardableResult + func saveProfileEdits( + profile: DeviceProfile, + fields: DeviceProfileEditableFields, + replacementPassword: String? = nil + ) async throws -> DeviceProfile { + var updated = profile + updated.displayName = fields.displayName + updated.settings = fields.settings + + let rollback: PasswordRollback? + if let replacementPassword { + rollback = try passwordRollback(for: profile.keychainAccount) + try passwordStore.save(replacementPassword, for: profile.keychainAccount) + updated.passwordState = .available + } else { + rollback = nil + } + + do { + return try await registry.updateProfile(updated) + } catch { + if let rollback { + rollbackPassword(rollback, account: profile.keychainAccount) + } + throw error + } + } + + func forget(_ profile: DeviceProfile) async throws { + let rollback = try passwordRollback(for: profile.keychainAccount) + try passwordStore.deletePassword(for: profile.keychainAccount) + do { + try await registry.delete(profile) + } catch { + rollbackPassword(rollback, account: profile.keychainAccount) + throw error + } + } + + func credential(for profile: DeviceProfile) -> CredentialResolution { + if profile.passwordState == .invalid { + return .invalid + } + do { + return .available(try passwordStore.password(for: profile.keychainAccount)) + } catch PasswordStoreError.missing { + Task { await registry.updatePasswordState(.missing, for: profile.id) } + return .missing + } catch PasswordStoreError.unavailable(let message) { + Task { await registry.updatePasswordState(.keychainUnavailable, for: profile.id) } + return .unavailable(message) + } catch { + Task { await registry.updatePasswordState(.keychainUnavailable, for: profile.id) } + return .unavailable(error.localizedDescription) + } + } + + func refreshCredentialStates() async { + for profile in registry.profiles { + await registry.updatePasswordState(effectivePasswordState(for: profile), for: profile.id) + } + } + + func markCredentialInvalid(profileID: DeviceProfile.ID) async { + await registry.updatePasswordState(.invalid, for: profileID) + } + + private func effectivePasswordState(for profile: DeviceProfile) -> DevicePasswordState { + if profile.passwordState == .invalid { + return .invalid + } + switch passwordStore.credentialAvailability(for: profile.keychainAccount) { + case .available: + return .available + case .missing: + return .missing + case .unavailable: + return .keychainUnavailable + } + } + + 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) + } + } +} + +private struct DeviceProfileArtifacts { + struct CommitRollback { + fileprivate let fileManager: FileManager + fileprivate let finalURL: URL + fileprivate let backupURL: URL? + + func rollback() { + if fileManager.fileExists(atPath: finalURL.path) { + try? fileManager.removeItem(at: finalURL) + } + if let backupURL, fileManager.fileExists(atPath: backupURL.path) { + try? fileManager.createDirectory(at: finalURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? fileManager.moveItem(at: backupURL, to: finalURL) + } + } + + func discardBackup() { + guard let backupURL, fileManager.fileExists(atPath: backupURL.path) else { + return + } + try? fileManager.removeItem(at: backupURL) + } + } + + private let applicationSupportURL: URL + private let fileManager: FileManager + + init(applicationSupportURL: URL, fileManager: FileManager) { + self.applicationSupportURL = applicationSupportURL + self.fileManager = fileManager + } + + func stageConfig(for profileID: DeviceProfile.ID, sourceProfile: DeviceProfile?) throws -> URL { + let stagingDirectory = applicationSupportURL + .appendingPathComponent("Devices", isDirectory: true) + .appendingPathComponent(".Staging", isDirectory: true) + try fileManager.createDirectory(at: stagingDirectory, withIntermediateDirectories: true) + let stagedURL = stagingDirectory.appendingPathComponent("\(profileID)-\(UUID().uuidString.lowercased()).env") + if let sourceProfile { + let sourceURL = sourceProfile.configURL + if fileManager.fileExists(atPath: sourceURL.path) { + try fileManager.copyItem(at: sourceURL, to: stagedURL) + } + } + return stagedURL + } + + func commitStagedConfig(at stagedURL: URL, to finalURL: URL) throws -> CommitRollback { + guard fileManager.fileExists(atPath: stagedURL.path) else { + throw DeviceRegistryError.io("Configured device artifact was not written.") + } + + let finalDirectory = finalURL.deletingLastPathComponent() + try fileManager.createDirectory(at: finalDirectory, withIntermediateDirectories: true) + + let backupURL: URL? + if fileManager.fileExists(atPath: finalURL.path) { + let backupDirectory = applicationSupportURL + .appendingPathComponent("Devices", isDirectory: true) + .appendingPathComponent(".Staging", isDirectory: true) + try fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true) + let candidate = backupDirectory.appendingPathComponent("\(finalDirectory.lastPathComponent)-rollback-\(UUID().uuidString.lowercased()).env") + try fileManager.moveItem(at: finalURL, to: candidate) + backupURL = candidate + } else { + backupURL = nil + } + + do { + try fileManager.moveItem(at: stagedURL, to: finalURL) + } catch { + if let backupURL, fileManager.fileExists(atPath: backupURL.path) { + try? fileManager.moveItem(at: backupURL, to: finalURL) + } + throw error + } + + return CommitRollback(fileManager: fileManager, finalURL: finalURL, backupURL: backupURL) + } + + func discardStagedConfig(at stagedURL: URL) { + guard stagedURL.path.contains("/.Staging/"), fileManager.fileExists(atPath: stagedURL.path) else { + return + } + try? fileManager.removeItem(at: stagedURL) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileTraits.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceProfileTraits.swift new file mode 100644 index 00000000..b67194fb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/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/Profiles/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift new file mode 100644 index 00000000..e6842a0f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/DeviceRegistryStore.swift @@ -0,0 +1,740 @@ +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 repository: DeviceRegistryRepository + + 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.repository = DeviceRegistryRepository( + applicationSupportURL: applicationSupportURL, + fileManager: fileManager, + now: now + ) + } + + var isEmpty: Bool { + profiles.isEmpty + } + + func load() async { + state = .loading + error = nil + do { + profiles = try await repository.load() + state = profiles.isEmpty ? .empty : .loaded + } catch { + fail(error, clearProfiles: true) + } + } + + @discardableResult + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + ) async throws -> DeviceProfile { + state = .saving + error = nil + do { + let result = try await repository.saveConfiguredDevice( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID + ) + await refreshProfilesFromRepository() + return result.profile + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func makeConfiguredDeviceProfile( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased(), + existingProfileID: DeviceProfile.ID? = nil + ) async -> DeviceProfile { + await repository.makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID, + existingProfileID: existingProfileID + ) + } + + @discardableResult + func saveProfileMergingDuplicates(_ profile: DeviceProfile) async throws -> DeviceProfile { + state = .saving + error = nil + do { + let result = try await repository.saveProfileMergingDuplicates(profile) + await refreshProfilesFromRepository() + return result.profile + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func discardArtifacts(for profile: DeviceProfile) async { + await repository.discardArtifacts(for: profile) + } + + @discardableResult + func updateProfile(_ profile: DeviceProfile) async throws -> DeviceProfile { + state = .saving + error = nil + do { + let result = try await repository.updateProfile(profile) + await refreshProfilesFromRepository() + return result.profile + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func delete(_ profile: DeviceProfile) async throws { + state = .saving + error = nil + do { + _ = try await repository.delete(profile) + await refreshProfilesFromRepository() + } catch { + fail(error, clearProfiles: false) + throw error + } + } + + func updatePasswordState(_ state: DevicePasswordState, for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.updatePasswordState(state, for: profileID) + } + } + + func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.updateCheckup(snapshot, for: profileID) + } + } + + func updateCheckup( + _ snapshot: DeviceCheckupSnapshot, + runtimeState: DeviceRuntimeStateSnapshot?, + for profileID: DeviceProfile.ID + ) async { + await applyBackgroundMutation { + try await repository.updateCheckup(snapshot, runtimeState: runtimeState, for: profileID) + } + } + + func clearCheckup(for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.clearCheckup(for: profileID) + } + } + + func updateDeployState(_ snapshot: DeviceDeployStateSnapshot, for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.updateDeployState(snapshot, for: profileID) + } + } + + func updateInstallOperationState( + deployState: DeviceDeployStateSnapshot, + runtimeState: DeviceRuntimeStateSnapshot, + for profileID: DeviceProfile.ID + ) async { + await applyBackgroundMutation { + try await repository.updateInstallOperationState( + deployState: deployState, + runtimeState: runtimeState, + for: profileID + ) + } + } + + func updateRuntimeState(_ snapshot: DeviceRuntimeStateSnapshot, for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.updateRuntimeState(snapshot, for: profileID) + } + } + + func clearInstallState(for profileID: DeviceProfile.ID) async { + await applyBackgroundMutation { + try await repository.clearInstallState(for: profileID) + } + } + + 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 identity = DeviceNetworkIdentity(configuredSSHTarget: host, bonjourFullname: bonjourFullname) + return profiles.first { $0.network.matches(identity) } + } + + func matchingProfile(for device: DiscoveredDevice) -> DeviceProfile? { + let identity = DeviceNetworkIdentity( + configuredSSHTarget: device.connectionTarget, + hostname: device.hostname, + bonjourName: device.name, + bonjourFullname: device.fullname, + addresses: device.networkAddresses + ) + return profiles.first { $0.network.matches(identity) } + } + + private func applyBackgroundMutation(_ mutate: () async throws -> [DeviceProfile]?) async { + do { + guard try await mutate() != nil else { + return + } + await refreshProfilesFromRepository() + } catch { + fail(error, clearProfiles: false) + } + } + + private func refreshProfilesFromRepository() async { + profiles = await repository.profilesSnapshot() + state = profiles.isEmpty ? .empty : .loaded + } + + private func fail(_ error: Error, clearProfiles: Bool) { + if clearProfiles { + profiles = [] + } + if let registryError = error as? DeviceRegistryError { + self.error = registryError + switch registryError { + case .profileNotFound, .duplicateProfile: + state = profiles.isEmpty ? .empty : .loaded + return + case .applicationSupportUnavailable, .corruptRegistry, .io: + break + } + } else { + self.error = .io(error.localizedDescription) + } + state = .failed + } +} + +private struct DeviceRegistryMutationResult: Sendable { + let profile: DeviceProfile +} + +private actor DeviceRegistryRepository { + private let applicationSupportURL: URL + private let registryURL: URL + private let devicesDirectoryURL: URL + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let now: () -> Date + private var profiles: [DeviceProfile] = [] + + init( + applicationSupportURL: URL, + fileManager: FileManager, + now: @escaping () -> Date + ) { + 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 + } + + func load() throws -> [DeviceProfile] { + do { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + guard fileManager.fileExists(atPath: registryURL.path) else { + profiles = [] + return profiles + } + let data = try Data(contentsOf: registryURL) + let loadedProfiles = try decoder.decode([DeviceProfile].self, from: data) + .map(profileWithStorageFields) + .sorted { $0.updatedAt > $1.updatedAt } + profiles = loadedProfiles + .map(profileWithInterruptedRuntimeState) + .sorted { $0.updatedAt > $1.updatedAt } + if profiles != loadedProfiles { + try persist(profiles) + } + return profiles + } catch let decoding as DecodingError { + profiles = [] + throw DeviceRegistryError.corruptRegistry(String(describing: decoding)) + } catch let registryError as DeviceRegistryError { + profiles = [] + throw registryError + } catch { + profiles = [] + throw DeviceRegistryError.io(error.localizedDescription) + } + } + + func profilesSnapshot() -> [DeviceProfile] { + profiles + } + + func makeConfiguredDeviceProfile( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID, + existingProfileID: DeviceProfile.ID? = nil + ) -> DeviceProfile { + let existing = existingProfileID.flatMap { id in profiles.first { $0.id == id } } + ?? matchingProfile(configuredHost: configuredDevice.host, discoveredDevice: discoveredDevice) + var profile = DeviceProfile.make( + id: preferredID, + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + applicationSupportURL: applicationSupportURL, + existing: existing, + date: now() + ) + profile.passwordState = passwordState + return profile + } + + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID + ) throws -> DeviceRegistryMutationResult { + let profile = makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID, + existingProfileID: nil + ) + return try saveProfileMergingDuplicates(profile) + } + + func saveProfileMergingDuplicates(_ profile: DeviceProfile) throws -> DeviceRegistryMutationResult { + let storedProfile = profileWithStorageFields(profile) + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: storedProfile.configURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var updated = profiles.filter { !DeviceProfile.matches($0, storedProfile) && $0.id != storedProfile.id } + updated.append(storedProfile) + updated = sorted(updated) + try persist(updated) + profiles = updated + return DeviceRegistryMutationResult(profile: storedProfile) + } + + func discardArtifacts(for profile: DeviceProfile) { + let configDirectory = profileWithStorageFields(profile).configURL.deletingLastPathComponent() + let configDirectoryPath = configDirectory.standardizedFileURL.path + let devicesDirectoryPath = devicesDirectoryURL.standardizedFileURL.path + guard configDirectoryPath.hasPrefix(devicesDirectoryPath + "/") else { + return + } + try? fileManager.removeItem(at: configDirectory) + } + + func updateProfile(_ profile: DeviceProfile) throws -> DeviceRegistryMutationResult { + let storedProfile = profileWithStorageFields(profile) + guard let index = profiles.firstIndex(where: { $0.id == storedProfile.id }) else { + throw DeviceRegistryError.profileNotFound(profile.id) + } + if let conflict = duplicateConflict(for: storedProfile, excluding: storedProfile.id) { + throw conflict + } + + var updated = storedProfile + updated.updatedAt = now() + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: updated.configURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var updatedProfiles = profiles + updatedProfiles[index] = updated + updatedProfiles = sorted(updatedProfiles) + try persist(updatedProfiles) + profiles = updatedProfiles + return DeviceRegistryMutationResult(profile: updated) + } + + func delete(_ profile: DeviceProfile) throws -> [DeviceProfile] { + let storedProfile = profileWithStorageFields(profile) + let updatedProfiles = profiles.filter { $0.id != storedProfile.id } + let configDirectory = storedProfile.configURL.deletingLastPathComponent() + let stagedDirectory = try stageConfigDirectoryDelete(configDirectory, profileID: storedProfile.id) + do { + try persist(updatedProfiles) + } catch { + restoreStagedConfigDirectory(stagedDirectory, to: configDirectory) + throw error + } + profiles = updatedProfiles + removeStagedConfigDirectory(stagedDirectory) + return updatedProfiles + } + + func updatePasswordState(_ state: DevicePasswordState, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + guard profiles[index].passwordState != state else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].passwordState = state + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + + func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + try updateCheckup(snapshot, runtimeState: nil, for: profileID) + } + + func updateCheckup( + _ snapshot: DeviceCheckupSnapshot, + runtimeState: DeviceRuntimeStateSnapshot?, + for profileID: DeviceProfile.ID + ) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].lastCheckup = snapshot + if let runtimeState { + updatedProfiles[index].runtimeState = runtimeState + } + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + + func clearCheckup(for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + guard profiles[index].lastCheckup != nil else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].lastCheckup = nil + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + + func updateDeployState(_ snapshot: DeviceDeployStateSnapshot, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].lastDeployState = snapshot + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + + func updateInstallOperationState( + deployState: DeviceDeployStateSnapshot, + runtimeState: DeviceRuntimeStateSnapshot, + for profileID: DeviceProfile.ID + ) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].lastDeployState = deployState + updatedProfiles[index].runtimeState = runtimeState + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + + func updateRuntimeState(_ snapshot: DeviceRuntimeStateSnapshot, for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].runtimeState = snapshot + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + + func clearInstallState(for profileID: DeviceProfile.ID) throws -> [DeviceProfile]? { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return nil + } + guard profiles[index].lastDeployState != nil || profiles[index].runtimeState != nil || profiles[index].lastCheckup != nil else { + return nil + } + var updatedProfiles = profiles + updatedProfiles[index].lastDeployState = nil + updatedProfiles[index].runtimeState = nil + updatedProfiles[index].lastCheckup = nil + updatedProfiles[index].updatedAt = now() + try persist(updatedProfiles) + profiles = updatedProfiles + return updatedProfiles + } + + private func matchingProfile(host: String, bonjourFullname: String?) -> DeviceProfile? { + let identity = DeviceNetworkIdentity(configuredSSHTarget: host, bonjourFullname: bonjourFullname) + return profiles.first { $0.network.matches(identity) } + } + + private func matchingProfile(configuredHost: String, discoveredDevice: DiscoveredDevice?) -> DeviceProfile? { + let identity = DeviceNetworkIdentity.make(configuredSSHTarget: configuredHost, discoveredDevice: discoveredDevice) + return profiles.first { $0.network.matches(identity) } + } + + 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: DeviceEndpointPolicy.hostComponent(profile.host) ?? normalizedHost, + conflictingProfileID: conflicting.id + ) + } + let normalizedHostname = profile.network.normalizedHostname + if !normalizedHostname.isEmpty, + let conflicting = profiles.first(where: { + $0.id != profileID && $0.network.normalizedHostname == normalizedHostname + }) { + return .duplicateProfile( + field: "hostname", + value: normalizedHostname, + conflictingProfileID: conflicting.id + ) + } + if let conflict = addressConflict(for: profile, excluding: profileID) { + return conflict + } + return nil + } + + private func addressConflict(for profile: DeviceProfile, excluding profileID: DeviceProfile.ID) -> DeviceRegistryError? { + let keys = profile.network.matchableAddressKeys + guard !keys.isEmpty else { + return nil + } + for existing in profiles where existing.id != profileID { + let overlap = keys.intersection(existing.network.matchableAddressKeys) + guard let key = overlap.first else { + continue + } + let value = profile.network.addresses.first { $0.identityKey == key }?.value ?? key + return .duplicateProfile(field: "address", value: value, conflictingProfileID: existing.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.map(profileWithStorageFields)) + try data.write(to: registryURL, options: [.atomic]) + } + + private func stageConfigDirectoryDelete(_ configDirectory: URL, profileID: DeviceProfile.ID) throws -> URL? { + guard fileManager.fileExists(atPath: configDirectory.path) else { + return nil + } + let configDirectoryPath = configDirectory.standardizedFileURL.path + let devicesDirectoryPath = devicesDirectoryURL.standardizedFileURL.path + guard configDirectoryPath.hasPrefix(devicesDirectoryPath + "/") else { + throw DeviceRegistryError.io("Refusing to delete profile artifacts outside the devices directory.") + } + + let stagingDirectory = devicesDirectoryURL.appendingPathComponent(".Staging", isDirectory: true) + try fileManager.createDirectory(at: stagingDirectory, withIntermediateDirectories: true) + let stagedDirectory = stagingDirectory.appendingPathComponent("\(profileID)-delete-\(UUID().uuidString.lowercased())", isDirectory: true) + try fileManager.moveItem(at: configDirectory, to: stagedDirectory) + return stagedDirectory + } + + private func restoreStagedConfigDirectory(_ stagedDirectory: URL?, to configDirectory: URL) { + guard let stagedDirectory, fileManager.fileExists(atPath: stagedDirectory.path) else { + return + } + try? fileManager.createDirectory(at: configDirectory.deletingLastPathComponent(), withIntermediateDirectories: true) + try? fileManager.moveItem(at: stagedDirectory, to: configDirectory) + } + + private func removeStagedConfigDirectory(_ stagedDirectory: URL?) { + guard let stagedDirectory, fileManager.fileExists(atPath: stagedDirectory.path) else { + return + } + try? fileManager.removeItem(at: stagedDirectory) + } + + private func sorted(_ profiles: [DeviceProfile]) -> [DeviceProfile] { + profiles.sorted { $0.updatedAt > $1.updatedAt } + } + + private func profileWithStorageFields(_ profile: DeviceProfile) -> DeviceProfile { + var updated = profile + updated.configPath = DeviceProfile.configURL(for: profile.id, applicationSupportURL: applicationSupportURL).path + updated.keychainAccount = profile.id + return updated + } + + private func profileWithInterruptedRuntimeState(_ profile: DeviceProfile) -> DeviceProfile { + guard profile.lastDeployState?.status.isInProgress == true || profile.runtimeState?.state == .installing else { + return profile + } + let interruptedAt = now() + var updated = profile + if let deployState = profile.lastDeployState, deployState.status.isInProgress { + updated.lastDeployState = DeviceDeployStateSnapshot( + operationID: deployState.operationID, + startedAt: deployState.startedAt, + updatedAt: interruptedAt, + finishedAt: interruptedAt, + status: .interrupted, + stage: deployState.stage, + payloadFamily: deployState.payloadFamily, + rebootRequested: deployState.rebootRequested, + verified: deployState.verified, + summary: "", + errorCode: "operation_interrupted", + errorMessage: nil, + recovery: deployState.recovery + ) + } + if let runtimeState = profile.runtimeState, runtimeState.state == .installing { + updated.runtimeState = DeviceRuntimeStateSnapshot( + state: .installInterrupted, + source: .appRecovery, + stage: runtimeState.stage, + payloadFamily: runtimeState.payloadFamily, + verified: runtimeState.verified, + summary: "", + errorCode: "operation_interrupted", + errorMessage: nil, + recovery: runtimeState.recovery + ) + } else if let deployState = profile.lastDeployState, deployState.status.isInProgress { + updated.runtimeState = DeviceRuntimeStateSnapshot( + state: .installInterrupted, + source: .appRecovery, + stage: deployState.stage, + payloadFamily: deployState.payloadFamily, + verified: deployState.verified, + summary: "", + errorCode: "operation_interrupted", + errorMessage: nil, + recovery: deployState.recovery + ) + } + updated.updatedAt = interruptedAt + return updated + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/PasswordStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/PasswordStore.swift new file mode 100644 index 00000000..89084221 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/PasswordStore.swift @@ -0,0 +1,155 @@ +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 + } + } +} + +enum CredentialAvailability: Equatable { + case available + case missing + case unavailable(String) +} + +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 credentialAvailability(for account: String) -> CredentialAvailability +} + +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 credentialAvailability(for account: String) -> CredentialAvailability { + do { + _ = try password(for: account) + return .available + } catch PasswordStoreError.missing { + return .missing + } catch PasswordStoreError.unavailable(let message) { + return .unavailable(message) + } catch { + return .unavailable(error.localizedDescription) + } + } + + 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) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/SMBAccountResolver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/SMBAccountResolver.swift new file mode 100644 index 00000000..07eb6bee --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Profiles/SMBAccountResolver.swift @@ -0,0 +1,54 @@ +import Foundation +import Security + +protocol SMBAccountResolving { + func account(for profile: DeviceProfile) -> String? +} + +struct KeychainSMBAccountResolver: SMBAccountResolving { + private let keychainClient: KeychainClient + + init(keychainClient: KeychainClient = SystemKeychainClient()) { + self.keychainClient = keychainClient + } + + func account(for profile: DeviceProfile) -> String? { + for server in serverCandidates(for: profile) { + if let account = account(forServer: server) { + return account + } + } + return nil + } + + private func serverCandidates(for profile: DeviceProfile) -> [String] { + var candidates = SMBAddressPolicy.credentialServerCandidates(for: profile) + for server in Array(candidates) { + let lowercased = server.lowercased() + if lowercased != server && !candidates.contains(lowercased) { + candidates.append(lowercased) + } + } + return candidates + } + + private func account(forServer server: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrProtocol as String: kSecAttrProtocolSMB, + kSecAttrServer as String: server, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = keychainClient.copyMatching(query, result: &result) + guard status == errSecSuccess, + let attributes = result as? [String: Any], + let account = attributes[kSecAttrAccount as String] as? String else { + return nil + } + let trimmed = account.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} 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 00000000..fb93af94 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -0,0 +1,850 @@ +"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" = "Device password is required."; +"add_device.host_or_ip" = "Hostname or IP address"; +"add_device.password" = "Device password"; +"add_device.progress.configuring.message" = "Verifying access and preparing this device. This can take a few minutes..."; +"add_device.progress.configuring.title" = "Connecting to Apple AirPort device"; +"add_device.progress.discovering.message" = "Browsing for nearby AirPort Bonjour services..."; +"add_device.progress.discovering.title" = "Discovering Apple AirPort devices"; +"add_device.progress.saving.message" = "Writing the saved device profile and Keychain password."; +"add_device.progress.saving.title" = "Saving Device"; +"add_device.reset" = "Reset"; +"add_device.save_device" = "Save Device"; +"add_device.saved" = "Saved %@"; +"add_device.setup_target" = "Setup target: %@"; +"add_device.state.auth_failed" = "Password Rejected"; +"add_device.state.awaiting_confirmation" = "Waiting for Confirmation"; +"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 Apple AirPort Time Capsule or AirPort Extreme"; +"advanced.config" = "Config"; +"advanced.helper" = "Helper"; +"advanced.profile_id" = "Profile ID"; +"app_readiness.state.blocked" = "Blocked"; +"app_readiness.state.checking_capabilities" = "Checking helper"; +"app_readiness.state.checking_version" = "Checking version"; +"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."; +"app_readiness.recovery.update_required" = "Download the latest version from %@."; +"app_readiness.recovery.version_metadata_unavailable" = "Check your network connection or try again later."; +"app_settings.blank_uses_device_default" = "Blank"; +"app_settings.appearance" = "Appearance"; +"app_settings.check_now" = "Check Now"; +"app_settings.check_updates_on_launch" = "Check for updates on launch"; +"app_settings.default_bonjour_timeout" = "Discovery timeout seconds"; +"app_settings.error.ata_idle" = "ATA idle seconds must be a non-negative integer."; +"app_settings.error.ata_standby" = "ATA standby seconds must be blank or a non-negative integer."; +"app_settings.error.bonjour_timeout" = "Bonjour timeout must be a non-negative number."; +"app_settings.error.corrupt" = "App settings could not be read: %@"; +"app_settings.error.mount_wait" = "Mount wait must be a non-negative integer."; +"app_settings.error.version_url" = "Version check URL must be blank or an HTTP/HTTPS URL."; +"app_settings.helper_path" = "Helper path"; +"app_settings.language" = "Language"; +"app_settings.restore_defaults" = "Restore Defaults"; +"app_settings.reset_saved" = "Reset to Saved"; +"app_settings.save" = "Save Settings"; +"app_settings.section.defaults" = "New Device Defaults"; +"app_settings.section.diagnostics" = "Helper and Diagnostics"; +"app_settings.section.general" = "General"; +"app_settings.section.privacy" = "Privacy"; +"app_settings.section.time_machine" = "Time Machine"; +"app_settings.section.updates" = "Updates"; +"app_settings.show_raw_events" = "Show raw backend events in Diagnostics"; +"app_settings.subtitle" = "Defaults for new devices and app-level behavior."; +"app_settings.telemetry_enabled" = "Share anonymous telemetry"; +"app_settings.time_machine_warnings" = "Show macOS Time Machine compatibility warnings"; +"app_settings.title" = "Settings"; +"app_settings.version_url" = "Version metadata URL"; +"app_appearance.dark" = "Dark"; +"app_appearance.light" = "Light"; +"app_appearance.system" = "System"; +"app_language.english" = "English"; +"app_language.simplified_chinese" = "简体中文"; +"app_language.system" = "System Default"; +"app_update.state.checking" = "Checking for updates"; +"app_update.state.current" = "TimeCapsuleSMB is up to date."; +"app_update.state.failed" = "Update check failed."; +"app_update.state.idle" = "Not checked yet."; +"app_update.state.unavailable" = "Version metadata unavailable."; +"app_update.state.update_available" = "Update available."; +"button.discover" = "Discover"; +"checkup.presentation.headline.failed" = "Checkup failed."; +"checkup.presentation.headline.idle" = "Run a checkup to inspect this Apple AirPort Time Capsule or AirPort Extreme."; +"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.progress.running.message" = "Running local and remote diagnostic checks.\nThis can take a few minutes..."; +"checkup.progress.running.title" = "Running Checkup"; +"checkup.presentation.row.fail" = "Fail"; +"checkup.presentation.row.info" = "Info"; +"checkup.presentation.row.pass" = "Pass"; +"checkup.presentation.row.warning" = "Warning"; +"close_guard.close_anyway" = "Close Anyway"; +"close_guard.keep_open" = "Keep Open"; +"close_guard.message" = "TimeCapsuleSMB has an operation in progress. Closing now may interrupt work on the device."; +"close_guard.title" = "Close TimeCapsuleSMB?"; +"confirm.activate.netbsd4.action" = "Activate"; +"confirm.activate.netbsd4.message" = "Activate the deployed NetBSD4 payload and restart managed services?"; +"confirm.activate.netbsd4.title" = "Activate NetBSD4 Runtime?"; +"confirm.backend.message" = "Continue with this operation?"; +"confirm.backend.title" = "Confirm Operation?"; +"confirm.configure.enable_ssh_reboot.action" = "Enable SSH and Reboot"; +"confirm.configure.enable_ssh_reboot.message" = "SSH is closed on %@. Enable SSH using AirPort ACP and reboot this AirPort device?"; +"confirm.configure.enable_ssh_reboot.title" = "Enable SSH And Reboot?"; +"confirm.deploy.activate_now.action" = "Deploy and Start SMB"; +"confirm.deploy.activate_now.message" = "Deploy TimeCapsuleSMB to this %@ and start SMB without rebooting it?"; +"confirm.deploy.activate_now.title" = "Deploy And Start SMB?"; +"confirm.deploy.netbsd4.action" = "Deploy, Reboot, and Activate"; +"confirm.deploy.netbsd4.message" = "Deploy TimeCapsuleSMB to this %@, reboot it, then activate Samba after SSH returns?"; +"confirm.deploy.netbsd4.title" = "Deploy, Reboot, And Activate NetBSD4?"; +"confirm.deploy.netbsd4_no_wait.action" = "Deploy and Request Reboot"; +"confirm.deploy.netbsd4_no_wait.message" = "Deploy TimeCapsuleSMB to this %@, request reboot, and return immediately without running Samba activation after SSH returns?"; +"confirm.deploy.netbsd4_no_wait.title" = "Deploy And Request NetBSD4 Reboot?"; +"confirm.deploy.no_reboot.action" = "Deploy"; +"confirm.deploy.no_reboot.message" = "Deploy TimeCapsuleSMB to this %@ without rebooting it?"; +"confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; +"confirm.deploy.reboot.action" = "Deploy and Reboot"; +"confirm.deploy.reboot.message" = "Deploy TimeCapsuleSMB and reboot this %@?"; +"confirm.deploy.reboot.title" = "Deploy And Reboot?"; +"confirm.deploy.reboot_no_wait.action" = "Deploy and Request Reboot"; +"confirm.deploy.reboot_no_wait.message" = "Deploy TimeCapsuleSMB to this %@, request reboot, and return immediately?"; +"confirm.deploy.reboot_no_wait.title" = "Deploy And Request Reboot?"; +"confirm.fsck.no_reboot.action" = "Run fsck"; +"confirm.fsck.no_reboot.message" = "Run fsck on the selected HFS volume?"; +"confirm.fsck.no_reboot.title" = "Run Disk Repair?"; +"confirm.fsck.reboot.action" = "Run fsck"; +"confirm.fsck.reboot.message" = "Run fsck on the selected HFS volume and reboot the device?"; +"confirm.fsck.reboot.title" = "Run Disk Repair And Reboot?"; +"confirm.flash.patch_write.action" = "Write Firmware"; +"confirm.flash.patch_write.message" = "Patch the primary firmware bank boot hook on %@? Manual power cycle is required after a successful write."; +"confirm.flash.patch_write.title" = "Patch Firmware Boot Hook?"; +"confirm.flash.restore_write.action" = "Write Firmware"; +"confirm.flash.restore_write.message" = "Restore Apple stock firmware to the active firmware bank on %@ and reboot after validation?"; +"confirm.flash.restore_write.title" = "Restore Apple Firmware?"; +"confirm.repair_xattrs.action" = "Repair xattrs"; +"confirm.repair_xattrs.message" = "Repair known-safe macOS metadata issues under %@?"; +"confirm.repair_xattrs.title" = "Repair Extended Attributes?"; +"confirm.uninstall.no_reboot.action" = "Uninstall"; +"confirm.uninstall.no_reboot.message" = "Remove managed TimeCapsuleSMB files from the device?"; +"confirm.uninstall.no_reboot.title" = "Uninstall?"; +"confirm.uninstall.reboot.action" = "Uninstall"; +"confirm.uninstall.reboot.message" = "Remove managed TimeCapsuleSMB files from the device and reboot it?"; +"confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; +"dashboard.action.install_smb" = "Install SMB"; +"dashboard.action.install_update_smb" = "Install / Update SMB"; +"dashboard.action.refresh_status" = "Refresh Status"; +"dashboard.action.settings" = "Settings"; +"dashboard.action.open_finder" = "Open Finder"; +"dashboard.action.open_smb" = "Open Finder"; +"dashboard.action.replace_password" = "Replace Password"; +"dashboard.action.run_checkup" = "Run Checkup"; +"dashboard.action.start_smb" = "Activate"; +"dashboard.action.view_checkup" = "View Checkup"; +"dashboard.header.last_checked" = "Last checked"; +"dashboard.header.last_checked_value" = "Last checked: %@"; +"dashboard.health.check_counts" = "PASS %d, WARN %d, FAIL %d"; +"dashboard.health.connection" = "Connection"; +"dashboard.health.connection.keychain_unavailable" = "The saved password cannot be read from Keychain."; +"dashboard.health.connection.not_refreshed" = "Connection status has not been refreshed."; +"dashboard.health.connection.password_invalid" = "The saved password was rejected by the device."; +"dashboard.health.connection.refreshing" = "Checking DNS, SSH, and SMB reachability..."; +"dashboard.health.connection.running" = "A backend operation is using this profile."; +"dashboard.health.checkup" = "Checkup"; +"dashboard.health.finder_bonjour" = "Finder / Bonjour"; +"dashboard.health.runtime" = "Runtime"; +"dashboard.health.runtime.activation_needed" = "This NetBSD4 device may need Activate after reboot."; +"dashboard.health.runtime.installing" = "Install / Update is running."; +"dashboard.health.runtime.not_installed" = "Samba has not been installed from this app."; +"dashboard.health.smb_auth" = "SMB Auth"; +"dashboard.health.status.failed" = "Failed"; +"dashboard.health.status.good" = "Good"; +"dashboard.health.status.running" = "Running"; +"dashboard.health.status.unknown" = "Unknown"; +"dashboard.health.status.warning" = "Warning"; +"dashboard.health.time_machine" = "Time Machine"; +"dashboard.health.unchecked" = "Run Checkup to inspect this area."; +"dashboard.generation.1" = "1st generation"; +"dashboard.generation.2" = "2nd generation"; +"dashboard.generation.3" = "3rd generation"; +"dashboard.generation.4" = "4th generation"; +"dashboard.generation.5" = "5th generation"; +"dashboard.generation.6" = "6th generation"; +"dashboard.overview.generation" = "Generation"; +"dashboard.overview.addresses" = "Addresses"; +"dashboard.overview.connection_target" = "Connection Target"; +"dashboard.overview.host" = "Connection Target"; +"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.password.title" = "Saved Password"; +"dashboard.replacement_password" = "Update saved password"; +"dashboard.tab.settings" = "Settings"; +"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.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 needs an activation step before Samba is ready."; +"deploy.presentation.warning.netbsd4_activate_now" = "This NetBSD4 install will start Samba without rebooting."; +"deploy.presentation.warning.netbsd4_reboot_then_activate" = "This NetBSD4 install will reboot first, then start Samba after SSH returns."; +"deploy.presentation.warning.no_wait_post_reboot_activation" = "No Wait will return after requesting reboot. Samba activation will not run automatically after SSH returns."; +"deploy.presentation.warning.no_wait_post_reboot_verification" = "No Wait will return after requesting reboot. Post-reboot SMB verification will not run automatically."; +"deploy.result.default_message" = "Install completed."; +"deploy.result.message" = "Message"; +"deploy.result.reboot_requested" = "Reboot Requested"; +"deploy.result.verified" = "Verified"; +"deploy.failure.reboot_guidance" = "Troubleshooting tip: try rebooting your device and waiting 5 minutes for it to load. Then try deploy again."; +"install.action.create_plan" = "Create Install Plan"; +"install.action.install_update" = "Install / Update"; +"install.action.reinstall" = "Reinstall"; +"install.action.regenerate_plan" = "Regenerate Plan"; +"install.advanced_options" = "Advanced Options"; +"install.completion.title.finished" = "Install / Update Finished"; +"install.completion.title.verified" = "Install / Update Verified"; +"install.completion.warning.netbsd4" = "NetBSD4 devices may need Activate after a later reboot unless the boot hook is patched."; +"install.plan.downtime.activate_now" = "Usually under a minute while Samba starts without rebooting."; +"install.plan.downtime.netbsd4" = "Usually under a minute while Samba starts without rebooting."; +"install.plan.downtime.no_wait" = "The app will request reboot and return immediately."; +"install.plan.downtime.none" = "No reboot expected."; +"install.plan.downtime.reboot" = "Several minutes while the device reboots."; +"install.plan.row.expected_downtime" = "Expected Downtime"; +"install.plan.row.remote_actions" = "Remote Actions"; +"install.plan.row.uploads" = "Uploads"; +"install.plan.section.device_actions" = "Device Actions"; +"install.plan.section.target" = "Target"; +"install.plan.title.activate_now" = "Install / Update SMB and Start Runtime"; +"install.plan.title.netbsd4" = "Install / Update SMB and Start Runtime"; +"install.plan.title.reboot_no_wait" = "Install / Update SMB and Request Reboot"; +"install.plan.title.reboot_then_activate" = "Install / Update SMB, Reboot, and Start Runtime"; +"install.plan.title.standard" = "Install / Update SMB"; +"install.advanced_options.no_wait_note" = "No Wait is a power-user option: it requests reboot and returns immediately without post-reboot activation or verification."; +"install.progress.deploying.message" = "Uploading and applying the managed SMB runtime. This can take a few minutes..."; +"install.progress.deploying.title" = "Installing / Updating SMB"; +"install.state.awaiting_confirmation" = "Review the confirmation dialog before continuing."; +"install.state.deploy_failed" = "Install / Update failed."; +"install.state.deploy_interrupted" = "Deploy was interrupted before it completed."; +"install.state.deployed" = "Install / Update completed."; +"install.state.deploying" = "Installing files and applying device changes."; +"install.state.idle" = "Create a plan before installing or updating SMB."; +"install.state.plan_failed" = "The install plan could not be created."; +"install.state.plan_ready" = "Review the plan, then run Install / Update."; +"install.state.plan_stale" = "Advanced options changed after this plan was created."; +"install.state.planning" = "Creating an install plan."; +"install.timeline.title" = "Status"; +"install.timeline.waiting" = "Waiting for backend progress."; +"install.warning.awaiting_confirmation" = "The backend is waiting for explicit confirmation."; +"install.warning.plan_stale" = "Regenerate the plan before installing."; +"runtime.state.unknown" = "Not installed."; +"runtime.state.not_installed" = "Not installed."; +"runtime.state.unhealthy" = "Checkup found runtime problems."; +"diagnostics.backend_events" = "Backend Events"; +"diagnostics.copied" = "Copied diagnostics."; +"diagnostics.copy" = "Copy Diagnostics"; +"diagnostics.distribution" = "Distribution"; +"diagnostics.helper" = "Helper"; +"diagnostics.runtime_issues" = "Runtime Issues"; +"diagnostics.save" = "Save Diagnostics..."; +"diagnostics.saved" = "Saved diagnostics."; +"diagnostics.state" = "State"; +"diagnostics.title" = "Diagnostics"; +"diagnostics.validation" = "Validation"; +"bundle_issue.application_support_unavailable.message" = "TimeCapsuleSMB cannot write its Application Support directory."; +"bundle_issue.application_support_unavailable.recovery" = "Repair permissions for the TimeCapsuleSMB Application Support folder or reinstall the app."; +"bundle_issue.artifact_manifest_invalid.message" = "The bundled artifact manifest could not be read."; +"bundle_issue.artifact_manifest_missing.message" = "The bundled artifact manifest is missing."; +"bundle_issue.contract_decode_failed.message" = "The app received an unexpected helper response."; +"bundle_issue.distribution_artifacts_missing.message" = "The bundled TimeCapsuleSMB payload artifacts are missing."; +"bundle_issue.distribution_artifacts_missing_count.message" = "The bundled TimeCapsuleSMB distribution is missing %d payload artifact(s)."; +"bundle_issue.distribution_root_missing.message" = "The bundled TimeCapsuleSMB distribution is missing."; +"bundle_issue.helper_launch_failed.message" = "The TimeCapsuleSMB helper could not launch."; +"bundle_issue.helper_missing.message" = "The bundled TimeCapsuleSMB helper is missing."; +"bundle_issue.helper_not_executable.message" = "The bundled TimeCapsuleSMB helper is not executable."; +"bundle_issue.install_validation_failed.message" = "The bundled install validation failed."; +"bundle_issue.operation_failed.message" = "The helper operation failed."; +"bundle_issue.python_packages_missing.message" = "The bundled Python packages are missing."; +"bundle_issue.recovery.reinstall" = "Reinstall TimeCapsuleSMB."; +"bundle_issue.state_directory_unavailable.message" = "TimeCapsuleSMB cannot write its runtime state directory."; +"bundle_issue.state_directory_unavailable.recovery" = "Repair permissions for the configured state directory."; +"bundle_issue.tools_directory_missing.message" = "Bundled command-line tools are missing."; +"bundle_issue.tools_directory_missing.recovery" = "Some diagnostics may be unavailable until the app bundle is repaired."; +"bundle_issue.unsupported_version.message" = "This TimeCapsuleSMB version is unsupported."; +"bundle_issue.unsupported_version.recovery" = "Update TimeCapsuleSMB."; +"bundle_issue.version_metadata_unavailable.message" = "Update metadata is unavailable."; +"dialog.forget.action" = "Forget %@"; +"dialog.forget.error_title" = "Could Not Forget Device"; +"dialog.forget.message" = "Remove %@ from this Mac? This does not uninstall Samba from the device."; +"dialog.forget.title" = "Forget This Device?"; +"doctor.domain.connection" = "Connection"; +"doctor.domain.disk" = "Disk"; +"doctor.domain.finder_bonjour" = "Finder / Bonjour"; +"doctor.domain.general" = "General"; +"doctor.domain.metadata" = "Metadata"; +"doctor.domain.runtime" = "Runtime"; +"doctor.domain.smb_auth" = "SMB Auth"; +"doctor.domain.time_machine" = "Time Machine"; +"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" = "%@: %@"; +"backend.summary.activation_already_active" = "NetBSD4 payload was already active."; +"backend.summary.activation_completed" = "NetBSD4 activation completed."; +"backend.summary.activation_completed_with_followup" = "NetBSD4 activation completed. %@"; +"backend.summary.activation_followup" = "Run `activate` after a reboot if the device did not auto-start Samba."; +"backend.summary.activation_plan_generated" = "NetBSD4 activation dry-run plan generated."; +"backend.summary.configuration_saved" = "Configuration saved and SSH authentication verified."; +"backend.summary.deploy_completed" = "Deployment completed."; +"backend.summary.deploy_plan_generated" = "Deployment dry-run plan generated."; +"backend.summary.discovered_devices" = "Discovered %d device(s)."; +"backend.summary.doctor_checks_passed" = "Doctor checks passed."; +"backend.summary.doctor_found_fatal" = "Doctor found one or more fatal problems."; +"backend.summary.flash_apple_restore_detail" = " (%@)"; +"backend.summary.flash_apple_restore_product" = "product %@"; +"backend.summary.flash_apple_restore_validated" = "Apple restore firmware validated%@."; +"backend.summary.flash_apple_restore_version" = "version %@"; +"backend.summary.flash_apple_stock_matches" = "Active firmware bank matches Apple stock firmware%@."; +"backend.summary.flash_apple_stock_mismatch" = "Active firmware bank does not match Apple stock firmware%@."; +"backend.summary.flash_backup_saved" = "Flash backup saved to %@."; +"backend.summary.flash_mode.check_apple" = "Apple check"; +"backend.summary.flash_mode.download_only" = "Apple download"; +"backend.summary.flash_mode.patch" = "patch"; +"backend.summary.flash_mode.restore" = "restore"; +"backend.summary.flash_patch_write_validated_power_cycle" = "Flash patch write validated; manual power cycle required."; +"backend.summary.flash_plan_already_satisfied" = "Flash plan is already satisfied; no write is needed."; +"backend.summary.flash_plan_generated" = "Flash %@ plan generated."; +"backend.summary.flash_restore_write_validated_manual_reboot" = "Flash restore write validated; manual reboot required."; +"backend.summary.flash_restore_write_validated_reboot_requested" = "Flash restore write validated; reboot requested."; +"backend.summary.flash_restore_write_validated_rebooted" = "Flash restore write validated; device rebooted."; +"backend.summary.flash_version_suffix" = " %@"; +"backend.summary.flash_write_completed" = "Flash write completed."; +"backend.summary.flash_write_not_needed" = "Flash write was not needed."; +"backend.summary.flash_write_plan_generated" = "Flash %@ write plan generated."; +"backend.summary.flash_write_validated" = "Flash %@ write validated."; +"backend.summary.fsck_completed" = "Disk repair completed with fsck."; +"backend.summary.fsck_plan_generated" = "Dry-run plan generated for fsck."; +"backend.summary.helper_capabilities_resolved" = "Helper capabilities resolved."; +"backend.summary.hfs_volumes_found" = "Found %d mounted HFS volume(s)."; +"backend.summary.install_validation_failed" = "Install validation failed."; +"backend.summary.install_validation_passed" = "Install validation passed."; +"backend.summary.operation_exited" = "Operation exited."; +"backend.summary.reachability.all_reachable" = "SSH reachable; SMB port reachable."; +"backend.summary.reachability.partial" = "Device is partially reachable."; +"backend.summary.reachability.smb_only" = "SMB port reachable, SSH closed."; +"backend.summary.reachability.ssh_only" = "SSH reachable, SMB port closed."; +"backend.summary.reachability.unreachable" = "Could not reach SSH or SMB."; +"backend.summary.repair_xattrs_found" = "Found %d metadata issue(s), %d repairable."; +"backend.summary.telemetry_disabled" = "Telemetry is disabled."; +"backend.summary.telemetry_enabled" = "Telemetry is enabled."; +"backend.summary.uninstall_completed" = "Uninstall completed."; +"backend.summary.uninstall_plan_generated" = "Uninstall dry-run plan generated."; +"backend.summary.uninstall_unverified" = "Uninstall completed without post-reboot verification."; +"backend.summary.up_to_date" = "TimeCapsuleSMB is up to date."; +"backend.summary.update_available" = "Update available."; +"backend.summary.update_required" = "Update required."; +"backend.summary.version_metadata_unavailable" = "Version metadata is unavailable."; +"field.ata_idle_seconds" = "ATA idle seconds"; +"field.ata_standby" = "ATA standby seconds"; +"field.firmware_template" = "Firmware template path, optional"; +"field.firmware_version" = "Firmware version, optional"; +"field.helper" = "Helper"; +"field.mount_wait" = "Mount wait seconds"; +"field.repair_xattrs_max_depth" = "Max depth"; +"field.repair_xattrs_path" = "Repair xattrs path"; +"flash.eligibility.disabled" = "Firmware boot hook analysis is disabled in this build."; +"flash.eligibility.netbsd4_required" = "Persistent boot hook tools are only for NetBSD4 devices. If your device should be supported, please add details to https://github.com/jamesyc/TimeCapsuleSMB/issues/160."; +"flash.eligibility.read_only" = "This NetBSD4 device can be backed up and inspected before any write is available."; +"flash.eligibility.write_ready" = "Modify the firmware of NetBSD 4 devices to automatically run deployed Samba payload after a restart.\nBack up and inspect firmware before planning a patch or restore write."; +"flash.action.backup_inspect" = "Back Up and Inspect"; +"flash.action.backup_inspect_again" = "Back Up and Inspect Again"; +"flash.action.check_apple" = "Check Apple Firmware"; +"flash.action.choose_template" = "Choose"; +"flash.action.download_apple" = "Validate Apple Restore Firmware"; +"flash.action.plan_patch" = "Plan Patch"; +"flash.action.plan_restore" = "Plan Restore"; +"flash.action.write_patch" = "Write Patch"; +"flash.action.write_restore" = "Write Restore"; +"flash.manual_power_cycle.message" = "Flash write validation completed. Unplug the device, wait 10 seconds, then plug it back in. Wait for it to finish booting, then run Checkup. One firmware bank was left untouched."; +"flash.manual_power_cycle.title" = "Manual Reboot Required"; +"flash.mode.check_apple" = "Check Apple Firmware"; +"flash.mode.download_only" = "Validate Apple Restore Firmware"; +"flash.mode.patch" = "Patch Boot Hook"; +"flash.mode.restore" = "Restore Apple Firmware"; +"flash.options.apple_firmware" = "Apple Firmware Options"; +"flash.row.active_bank" = "Active Bank"; +"flash.row.apple_match" = "Apple Match"; +"flash.row.apple_payload_sha256" = "Apple Payload SHA-256"; +"flash.row.apple_product" = "Apple Product"; +"flash.row.apple_source" = "Apple Source"; +"flash.row.apple_version" = "Apple Version"; +"flash.row.backup_dir" = "Backup"; +"flash.row.banks" = "Banks"; +"flash.row.firmware_payload_path" = "Firmware Payload"; +"flash.row.firmware_payload_sha256" = "Firmware Payload SHA-256"; +"flash.row.firmware_payload_size" = "Firmware Payload Size"; +"flash.row.firmware_product" = "Firmware Product"; +"flash.row.firmware_source" = "Firmware Source"; +"flash.row.firmware_version" = "Firmware Version"; +"flash.row.mode" = "Mode"; +"flash.row.write_requested" = "Write Requested"; +"flash.row.write_status" = "Write Status"; +"flash.row.write_validated" = "Write Validated"; +"flash.title" = "Persistent NetBSD4 Boot Hook"; +"flash.warning.manual_power_cycle" = "Unplug the device, wait 10 seconds, then plug it back in."; +"flash.warning.snapshot_stale" = "Firmware was written after this backup. Back up and inspect again before planning another flash action."; +"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.app_ready" = "App Ready"; +"activity.active" = "Active"; +"activity.last_operation" = "Last operation"; +"activity.multiple_active" = "%d active operations"; +"activity.multiple_active.message" = "Open Activity for details."; +"activity.no_active_operation" = "No active operation"; +"activity.one_active" = "1 active operation"; +"activity.recent" = "Recent"; +"activity.timeline" = "Timeline"; +"activity.timeline.empty" = "No operation history yet."; +"discovery_monitor.last_seen.now" = "Seen now"; +"discovery_monitor.state.discovering" = "Discovering"; +"discovery_monitor.state.empty" = "No devices found"; +"discovery_monitor.state.failed" = "Discovery failed"; +"discovery_monitor.state.idle" = "Idle"; +"discovery_monitor.state.paused" = "Paused"; +"discovery_monitor.state.readiness_blocked" = "App blocked"; +"discovery_monitor.state.ready" = "Devices found"; +"discovery_monitor.state.waiting_for_readiness" = "Waiting for app readiness"; +"checkup.advanced_options" = "Advanced Options"; +"checkup.option.skip_bonjour" = "Skip Bonjour checks"; +"checkup.option.skip_smb" = "Skip SMB checks"; +"checkup.option.skip_ssh" = "Skip SSH checks"; +"checkup.status.failed" = "Failed"; +"checkup.status.info" = "Info"; +"checkup.status.passed" = "Passed"; +"checkup.status.unknown" = "Unknown"; +"checkup.status.warning" = "Warning"; +"checkup.timeline.title" = "Progress"; +"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 Activate"; +"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" = "Activate"; +"maintenance.action.uninstall" = "Uninstall"; +"maintenance.advanced_options" = "Advanced Options"; +"maintenance.presentation.activate.primary_action" = "Activate"; +"maintenance.presentation.activate.subtitle" = "Start the deployed SMB runtime on a NetBSD 4 device."; +"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 payload from the selected device."; +"maintenance.presentation.uninstall.title" = "Uninstall"; +"maintenance.completion.activate" = "Activation Complete"; +"maintenance.completion.fsck" = "Disk Repair Complete"; +"maintenance.completion.repair_xattrs" = "Metadata Repair Complete"; +"maintenance.completion.uninstall" = "Uninstall Complete"; +"maintenance.fsck.no_volumes" = "Find mounted volumes before planning disk repair."; +"maintenance.plan.activate" = "Activation Plan"; +"maintenance.plan.fsck" = "Disk Repair Plan"; +"maintenance.plan.repair_xattrs" = "Metadata Scan"; +"maintenance.plan.row.actions" = "Actions"; +"maintenance.plan.row.device" = "Device"; +"maintenance.plan.row.findings" = "Findings"; +"maintenance.plan.row.host" = "Host"; +"maintenance.plan.row.mountpoint" = "Mountpoint"; +"maintenance.plan.row.path" = "Path"; +"maintenance.plan.row.payload_dirs" = "Payload Directories"; +"maintenance.plan.row.post_checks" = "Post-checks"; +"maintenance.plan.row.reboot" = "Reboot"; +"maintenance.plan.row.remote_actions" = "Remote Actions"; +"maintenance.plan.row.repairable" = "Repairable"; +"maintenance.plan.row.wait_after_reboot" = "Wait After Reboot"; +"maintenance.plan.uninstall" = "Uninstall Plan"; +"maintenance.result.already_active" = "Already Active"; +"maintenance.result.returncode" = "Return Code"; +"maintenance.state.awaiting_confirmation" = "Review the confirmation dialog before continuing."; +"maintenance.state.failed" = "Maintenance failed."; +"maintenance.state.fsck_list_ready" = "Choose a volume, then plan disk repair."; +"maintenance.state.idle" = "Choose the next maintenance action."; +"maintenance.state.loading" = "Finding mounted volumes."; +"maintenance.state.plan_ready" = "Review the plan before running this maintenance action."; +"maintenance.state.plan_stale" = "Options changed after this plan was created."; +"maintenance.state.planning" = "Creating a maintenance plan."; +"maintenance.state.running" = "Maintenance is running."; +"maintenance.state.scan_ready" = "Review the scan before repairing metadata."; +"maintenance.state.scan_stale" = "The path changed after this scan."; +"maintenance.state.scanning" = "Scanning metadata."; +"maintenance.state.succeeded" = "Maintenance completed."; +"maintenance.timeline.title" = "Progress"; +"maintenance.warning.destructive_fsck" = "Disk repair can modify the selected volume."; +"maintenance.warning.destructive_uninstall" = "Uninstall removes installed files from this device."; +"maintenance.warning.local_metadata_repair" = "Metadata repair modifies files under the selected local SMB mount."; +"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 an Apple AirPort Time Capsule or AirPort Extreme device to configure SMB, run checkups, and manage maintenance tasks."; +"overview.empty.title" = "No Devices Saved"; +"overview.saved_devices.title" = "All Devices"; +"overview.discovery.add" = "Add"; +"overview.discovery.discovering" = "Looking for devices..."; +"overview.discovery.empty" = "No nearby Apple AirPort Time Capsule or AirPort Extreme devices found."; +"overview.discovery.failed" = "Discovery failed."; +"overview.discovery.paused" = "Discovery will resume after the current operation finishes."; +"overview.discovery.readiness_blocked" = "Discovery is unavailable until app readiness is fixed."; +"overview.discovery.refresh" = "Refresh"; +"overview.discovery.saved" = "Saved"; +"overview.discovery.title" = "Nearby Devices"; +"overview.discovery.unsaved" = "Not saved"; +"overview.discovery.waiting" = "Discovery starts after the app runtime is ready."; +"operation.error.already_running" = "Another operation is already running."; +"workflow.error.activation_plan_required" = "Plan NetBSD4 activation before running it."; +"workflow.error.ata_idle_seconds_invalid" = "ATA idle seconds must be a non-negative integer."; +"workflow.error.ata_standby_invalid" = "ATA standby seconds must be blank or a non-negative integer."; +"workflow.error.deploy_options_invalid" = "Deploy options are invalid."; +"workflow.error.deploy_plan_not_ready" = "Deploy plan is not ready."; +"workflow.error.deploy_plan_stale" = "Review and regenerate the deploy plan before deploying."; +"workflow.error.flash_backup_required" = "Back up and inspect firmware before planning flash work."; +"workflow.error.flash_backup_unavailable" = "Flash backup is not available."; +"workflow.error.flash_mode_read_only" = "This flash mode does not write firmware."; +"workflow.error.flash_plan_required" = "Plan the selected flash write before running it."; +"workflow.error.flash_plan_stale" = "Plan the selected flash write again after changing Apple firmware options."; +"workflow.error.flash_writes_disabled" = "Firmware writes are disabled in this build."; +"workflow.error.fsck_plan_not_ready" = "The fsck plan is not ready."; +"workflow.error.fsck_plan_stale" = "Review and regenerate the fsck plan before running it."; +"workflow.error.fsck_target_required" = "Select a mounted HFS volume before planning fsck."; +"workflow.error.mount_wait_invalid" = "Mount wait must be a non-negative integer."; +"workflow.error.operation_already_running" = "Another operation is already running."; +"workflow.error.operation_could_not_start" = "Operation could not start."; +"workflow.error.repair_xattrs_depth_invalid" = "Max depth must be empty or a non-negative integer."; +"workflow.error.repair_xattrs_path_required" = "Choose a mounted SMB share path before scanning."; +"workflow.error.repair_xattrs_scan_stale" = "Run a fresh xattr scan before repairing."; +"workflow.error.uninstall_plan_not_ready" = "Uninstall plan is not ready."; +"workflow.error.uninstall_plan_stale" = "Review and regenerate the uninstall plan before running it."; +"workflow.state.analyzing_firmware" = "Analyzing Firmware"; +"workflow.state.apple_check_complete" = "Apple Check Complete"; +"workflow.state.apple_firmware_mismatch" = "Apple Firmware Mismatch"; +"workflow.state.apple_firmware_ready" = "Apple Firmware Ready"; +"workflow.state.awaiting_confirmation" = "Awaiting Confirmation"; +"workflow.state.deployed" = "Deployed"; +"workflow.state.deploy_failed" = "Deploy Failed"; +"workflow.state.deploying" = "Deploying"; +"workflow.state.disabled_in_this_build" = "Disabled in This Build"; +"workflow.state.failed" = "Failed"; +"workflow.state.idle" = "Idle"; +"workflow.state.list_ready" = "List Ready"; +"workflow.state.loading" = "Loading"; +"workflow.state.manual_power_cycle_required" = "Manual Power Cycle Required"; +"workflow.state.passed" = "Passed"; +"workflow.state.plan_available" = "Plan Available"; +"workflow.state.plan_failed" = "Plan Failed"; +"workflow.state.plan_ready" = "Plan Ready"; +"workflow.state.plan_stale" = "Plan Stale"; +"workflow.state.planning" = "Planning"; +"workflow.state.read_only_analysis_available" = "Read-Only Analysis Available"; +"workflow.state.reading_firmware_banks" = "Reading Firmware Banks"; +"workflow.state.ready" = "Ready"; +"workflow.state.rebooting_after_restore" = "Rebooting After Restore"; +"workflow.state.repaired" = "Repaired"; +"workflow.state.repairing" = "Repairing"; +"workflow.state.run_failed" = "Run Failed"; +"workflow.state.running" = "Running"; +"workflow.state.saving_backup" = "Saving Backup"; +"workflow.state.scan_ready" = "Scan Ready"; +"workflow.state.scan_stale" = "Scan Stale"; +"workflow.state.scanning" = "Scanning"; +"workflow.state.snapshot_stale" = "Snapshot Stale"; +"workflow.state.succeeded" = "Succeeded"; +"workflow.state.unavailable" = "Unavailable"; +"workflow.state.validating_write" = "Validating Write"; +"workflow.state.warning" = "Warning"; +"workflow.state.write_validated" = "Write Validated"; +"workflow.state.writing_firmware" = "Writing Firmware"; +"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.missing" = "Password is missing."; +"password.error.required" = "Password is required."; +"password.error.unreadable_keychain_item" = "Keychain returned an unreadable password."; +"profile_editor.advanced" = "Advanced"; +"profile_editor.advanced.deploy_notice" = "Please do a Deploy to update these settings to your device"; +"profile_editor.display_name" = "Display Name"; +"profile_editor.error.duplicate_host" = "Another saved device already uses this host."; +"profile_editor.error.host_required" = "Host is required."; +"profile_editor.error.ata_idle_seconds_invalid" = "ATA idle seconds must be a non-negative integer."; +"profile_editor.error.ata_standby_invalid" = "ATA standby seconds must be blank or a non-negative integer."; +"profile_editor.error.mount_wait_invalid" = "Mount wait must be a non-negative integer."; +"profile_editor.error.password_required" = "A saved password is required to change the host."; +"profile_editor.reset" = "Reset"; +"profile_editor.save" = "Save Profile"; +"profile_editor.state.auth_failed" = "Password Rejected"; +"profile_editor.state.clean" = "Saved"; +"profile_editor.state.dirty" = "Unsaved Changes"; +"profile_editor.state.failed" = "Failed"; +"profile_editor.state.invalid" = "Needs Changes"; +"profile_editor.state.reconfiguring" = "Reconfiguring"; +"profile_editor.state.saved" = "Saved"; +"profile_editor.state.saving" = "Saving"; +"profile_editor.state.unsupported" = "Unsupported"; +"profile_editor.title" = "Device Profile"; +"readiness.blocked.title" = "TimeCapsuleSMB cannot start"; +"readiness.state.checking_capabilities" = "Checking helper"; +"readiness.state.checking_version" = "Checking version"; +"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" = "Activate"; +"recovery.action.uninstall" = "Uninstall"; +"recovery.guidance.next_steps" = "Next steps"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.title" = "Reboot did not finish"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.message" = "The payload was uploaded and the reboot request succeeded, but the device did not accept SSH again before the 4 minute timeout. It may still be booting, or it may have come back with a different IP address."; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.1" = "Wait a few more minutes."; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.2" = "If the device is reachable at a new IP, update TC_HOST or rerun configure."; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.3" = "Make sure you are connected to the same network or Wi-Fi as the device."; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.4" = "On NetBSD 4 devices, run tcapsule activate once SSH is reachable; deploy did not get far enough to activate Samba after reboot."; +"screen.readiness" = "Readiness"; +"sidebar.add_airport_device" = "Add Device"; +"sidebar.all_airport_devices" = "All Devices"; +"sidebar.activity" = "Activity"; +"sidebar.devices" = "Devices"; +"sidebar.menu.copy_hostname" = "Copy Hostname"; +"sidebar.menu.copy_ip_address" = "Copy IP Address"; +"sidebar.menu.copy_smb_address" = "Copy SMB Address"; +"sidebar.menu.open_overview" = "Open Overview"; +"sidebar.menu.remove_from_this_mac" = "Remove From This Mac"; +"sidebar.settings" = "Settings"; +"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"; +"summary.install_verified_by_checkup" = "Installed and verified by checkup."; +"timeline.error.needs_attention" = "Needs Attention"; +"timeline.error.needs_confirmation" = "Needs Confirmation"; +"timeline.operation.activate" = "Activate"; +"timeline.operation.configure" = "Add Device"; +"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.reachability" = "Reachability"; +"timeline.operation.readiness" = "App Readiness"; +"timeline.operation.repair_xattrs" = "File Metadata Repair"; +"timeline.operation.telemetry" = "Telemetry Settings"; +"timeline.operation.uninstall" = "Uninstall"; +"timeline.operation.version_check" = "Update Check"; +"timeline.result.done" = "Done"; +"timeline.result.failed" = "Failed"; +"timeline.deploy.result.completed" = "Deployment completed."; +"timeline.state.failed" = "Failed"; +"timeline.state.pending" = "Pending"; +"timeline.state.running" = "Running"; +"timeline.state.succeeded" = "Succeeded"; +"timeline.state.warning" = "Warning"; +"timeline.deploy.detail.activate_runtime" = "Starting the deployed runtime without reboot."; +"timeline.deploy.detail.build_deployment_plan" = "Building the ordered deployment actions."; +"timeline.deploy.detail.check_compatibility" = "Choosing the payload for the detected NetBSD version."; +"timeline.deploy.detail.flush_payload_upload" = "Flushing payload writes to disk."; +"timeline.deploy.detail.load_config" = "Reading the saved device configuration."; +"timeline.deploy.detail.post_reboot_activation" = "Starting SMB after SSH returns."; +"timeline.deploy.detail.post_upload_actions" = "Installing flash hooks and applying file modes."; +"timeline.deploy.detail.pre_upload_actions" = "Stopping managed services and removing stale deployment files."; +"timeline.deploy.detail.prepare_deployment_files" = "Generating runtime config and Samba account files."; +"timeline.activate.detail.probe_runtime" = "Checking whether TimeCapsuleSMB is already running before activating it."; +"timeline.deploy.detail.probe_runtime" = "Checking whether the device will start TimeCapsuleSMB automatically."; +"timeline.deploy.detail.read_mast" = "Looking for mounted HFS payload volumes."; +"timeline.deploy.detail.reboot" = "Sending the SSH reboot request."; +"timeline.deploy.detail.resolve_managed_target" = "Resolving the deployment target and probing SSH."; +"timeline.deploy.detail.select_payload_home" = "Selecting a writable payload directory."; +"timeline.deploy.detail.upload_boot_files" = "Copying boot scripts to /mnt/Flash."; +"timeline.deploy.detail.upload_mdns_advertiser" = "Copying mdns-advertiser to the payload volume and flash."; +"timeline.deploy.detail.upload_nbns_advertiser" = "Copying nbns-advertiser to the payload volume."; +"timeline.deploy.detail.upload_payload" = "Uploading managed SMB payload files."; +"timeline.deploy.detail.upload_runtime_config" = "Writing runtime settings to /mnt/Flash."; +"timeline.deploy.detail.upload_samba_accounts" = "Writing Samba account files to the payload volume."; +"timeline.deploy.detail.upload_smbd" = "Copying smbd to the payload volume."; +"timeline.deploy.detail.validate_artifacts" = "Checking the bundled payload files before writing to the device."; +"timeline.deploy.detail.verify_payload_upload" = "Checking that the uploaded files are present."; +"timeline.deploy.detail.verify_payload_upload_after_sync" = "Checking the payload again after disk flush."; +"timeline.deploy.detail.verify_runtime_activation" = "Waiting for managed runtime to finish starting."; +"timeline.deploy.detail.verify_runtime_reboot" = "Device is back online. Waiting for managed runtime to finish starting."; +"timeline.deploy.detail.wait_for_reboot_down" = "Waiting for SSH to go down after reboot request."; +"timeline.deploy.detail.wait_for_reboot_up" = "Device went down; waiting for it to come back up."; +"timeline.deploy.title.activate_runtime" = "Start SMB"; +"timeline.deploy.title.build_deployment_plan" = "Prepare Install Plan"; +"timeline.deploy.title.check_compatibility" = "Check Device Compatibility"; +"timeline.deploy.title.flush_payload_upload" = "Flush to Disk"; +"timeline.deploy.title.load_config" = "Read Configuration"; +"timeline.deploy.title.post_reboot_activation" = "Start SMB After Reboot"; +"timeline.deploy.title.post_upload_actions" = "Apply File Permissions"; +"timeline.deploy.title.pre_upload_actions" = "Stop Existing Runtime"; +"timeline.deploy.title.prepare_deployment_files" = "Build Runtime Config"; +"timeline.activate.title.probe_runtime" = "Check Existing Runtime"; +"timeline.deploy.title.probe_runtime" = "Check Boot Startup"; +"timeline.deploy.title.read_mast" = "Find Payload Volume"; +"timeline.deploy.title.reboot" = "Request Reboot"; +"timeline.deploy.title.resolve_managed_target" = "Connect to Device"; +"timeline.deploy.title.select_payload_home" = "Select Payload Folder"; +"timeline.deploy.title.upload_boot_files" = "Upload Boot Files"; +"timeline.deploy.title.upload_mdns_advertiser" = "Upload mdns-advertiser"; +"timeline.deploy.title.upload_nbns_advertiser" = "Upload nbns-advertiser"; +"timeline.deploy.title.upload_payload" = "Upload Payload"; +"timeline.deploy.title.upload_runtime_config" = "Upload Runtime Config"; +"timeline.deploy.title.upload_samba_accounts" = "Upload Samba Account Files"; +"timeline.deploy.title.upload_smbd" = "Upload smbd"; +"timeline.deploy.title.validate_artifacts" = "Check Local Files"; +"timeline.deploy.title.verify_payload_upload" = "Verify Upload"; +"timeline.deploy.title.verify_payload_upload_after_sync" = "Verify Flushed Upload"; +"timeline.deploy.title.verify_runtime_activation" = "Verify SMB Startup"; +"timeline.deploy.title.verify_runtime_reboot" = "Verify SMB Startup"; +"timeline.deploy.title.wait_for_reboot_down" = "Wait for Device Restart"; +"timeline.deploy.title.wait_for_reboot_up" = "Device Went Down"; +"timeline.stage.checking_bundled_files" = "Checking Bundled Files"; +"timeline.stage.checking_runtime" = "Checking SMB"; +"timeline.stage.checking_ssh" = "Checking SSH"; +"timeline.stage.checking_airport_identity" = "Checking AirPort Identity"; +"timeline.stage.confirming_ssh_enable" = "Confirming SSH Enablement"; +"timeline.stage.deleting_old_deployed_files" = "Deleting Old Deployed Files"; +"timeline.stage.enabling_ssh" = "Enabling SSH"; +"timeline.stage.finding_disk" = "Finding Disk"; +"timeline.stage.finding_devices" = "Finding Devices"; +"timeline.stage.finding_volumes" = "Finding Volumes"; +"timeline.stage.planning_install" = "Planning Install"; +"timeline.stage.planning_start_smb" = "Planning Activation"; +"timeline.stage.planning_uninstall" = "Planning Uninstall"; +"timeline.stage.reachability_candidates" = "Preparing Reachability Check"; +"timeline.stage.reachability_dns" = "Checking DNS"; +"timeline.stage.reachability_ping" = "Checking Ping"; +"timeline.stage.reachability_smb_port" = "Checking SMB Port"; +"timeline.stage.reachability_ssh_auth" = "Checking SSH Auth"; +"timeline.stage.reachability_ssh_port" = "Checking SSH Port"; +"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.enable_debug_logging" = "Enable Debug Logging"; +"toggle.enable_nbns" = "Enable NBNS"; +"toggle.internal_share_use_disk_root" = "Internal Share Uses Disk Root"; +"toggle.any_protocol" = "Allow Any SMB Protocol"; +"toggle.force_debug_logging" = "Force Debug Logging"; +"toggle.no_reboot" = "No Reboot"; +"toggle.no_wait" = "No Wait"; +"toggle.repair_xattrs_fix_permissions" = "Fix Permissions"; +"toggle.repair_xattrs_include_hidden" = "Include Hidden Paths"; +"toggle.repair_xattrs_include_time_machine" = "Include Time Machine Paths"; +"toggle.repair_xattrs_recursive" = "Recursive"; +"toggle.repair_xattrs_verbose" = "Verbose Output"; +"toolbar.cancel" = "Cancel"; +"toolbar.add" = "Add"; +"toolbar.diagnostics" = "Diagnostics"; +"toolbar.disabled" = "Disabled"; +"toolbar.forget" = "Forget"; +"value.auto" = "Auto"; +"value.never" = "Never"; +"value.list_separator" = ", "; +"value.no" = "no"; +"value.not_required" = "Not required"; +"value.required" = "Required"; +"value.unknown" = "Unknown"; +"value.yes" = "yes"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..8769c772 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,850 @@ +"action.activate" = "启动 SMB"; +"action.cancel" = "å–ę¶ˆ"; +"action.confirm" = "甮认"; +"action.deploy" = "部署"; +"action.deploy_allow_reboot" = "éƒØē½²å¹¶å…č®øé‡åÆ"; +"action.done" = "完成"; +"action.ok" = "OK"; +"action.repair_xattrs" = "äæ®å¤ xattrs"; +"action.run_fsck" = "运蔌 fsck"; +"action.uninstall" = "åøč½½"; +"add_device.connection_method" = "čæžęŽ„ę–¹å¼"; +"add_device.discover.placeholder" = "ęµč§ˆ AirPort Bonjour ęœåŠ”ļ¼š"; +"add_device.discovered_devices" = "å‘ēŽ°ēš„č®¾å¤‡"; +"add_device.entry.discover" = "å‘ēŽ°"; +"add_device.entry.manual" = "ę‰‹åŠØåœ°å€"; +"add_device.error.choose_target" = "čÆ·é€‰ę‹©å‘ēŽ°ēš„č®¾å¤‡ęˆ–č¾“å…„ Host怂"; +"add_device.error.invalid_bonjour_timeout" = "Bonjour timeout åæ…é”»ę˜Æéžč“Ÿę•°ć€‚"; +"add_device.error.password_required" = "éœ€č¦č®¾å¤‡åÆ†ē ć€‚"; +"add_device.host_or_ip" = "Hostname ꈖ IP 地址"; +"add_device.password" = "设备密码"; +"add_device.progress.configuring.message" = "ę­£åœØéŖŒčÆč®æé—®ęƒé™å¹¶å‡†å¤‡ę­¤č®¾å¤‡ć€‚čæ™åÆčƒ½éœ€č¦å‡ åˆ†é’Ÿ..."; +"add_device.progress.configuring.title" = "ę­£åœØčæžęŽ„ Apple AirPort 设备"; +"add_device.progress.discovering.message" = "ę­£åœØęµč§ˆé™„čæ‘ēš„ AirPort Bonjour ęœåŠ”..."; +"add_device.progress.discovering.title" = "ę­£åœØå‘ēŽ° Apple AirPort 设备"; +"add_device.progress.saving.message" = "ę­£åœØå†™å…„äæå­˜ēš„č®¾å¤‡ Profile 和 Keychain 密码。"; +"add_device.progress.saving.title" = "ę­£åœØäæå­˜č®¾å¤‡"; +"add_device.reset" = "é‡ē½®"; +"add_device.save_device" = "äæå­˜č®¾å¤‡"; +"add_device.saved" = "å·²äæå­˜ %@"; +"add_device.setup_target" = "Setup target:%@"; +"add_device.state.auth_failed" = "åÆ†ē č¢«ę‹’ē»"; +"add_device.state.awaiting_confirmation" = "等待甮认"; +"add_device.state.configuring" = "ę­£åœØé…ē½®"; +"add_device.state.discovering" = "ę­£åœØå‘ēŽ°"; +"add_device.state.discovery_empty" = "ęœŖę‰¾åˆ°č®¾å¤‡"; +"add_device.state.discovery_ready" = "å·²ę‰¾åˆ°č®¾å¤‡"; +"add_device.state.failed" = "失蓄"; +"add_device.state.idle" = "空闲"; +"add_device.state.manual_entry" = "ę‰‹åŠØåœ°å€"; +"add_device.state.password_entry" = "éœ€č¦åÆ†ē "; +"add_device.state.saved" = "å·²äæå­˜"; +"add_device.state.saving_profile" = "ę­£åœØäæå­˜"; +"add_device.state.unsupported" = "äøę”ÆęŒ"; +"add_device.title" = "添加 Apple AirPort Time Capsule ꈖ AirPort Extreme"; +"advanced.config" = "é…ē½®"; +"advanced.helper" = "Helper"; +"advanced.profile_id" = "Profile ID"; +"app_readiness.state.blocked" = "已阻止"; +"app_readiness.state.checking_capabilities" = "ę­£åœØę£€ęŸ„ Helper"; +"app_readiness.state.checking_version" = "ę­£åœØę£€ęŸ„ē‰ˆęœ¬"; +"app_readiness.state.degraded" = "é™ēŗ§"; +"app_readiness.state.idle" = "空闲"; +"app_readiness.state.ready" = "就绪"; +"app_readiness.state.resolving_bundle" = "ę­£åœØå‡†å¤‡ App Runtime"; +"app_readiness.state.validating_install" = "正在验证 bundled files"; +"app_readiness.error.unexpected_payload" = "%@ čæ”å›žäŗ†ę„å¤–ēš„ payload:%@"; +"app_readiness.recovery.contract_mismatch" = "čÆ·ę›“ę–°ęˆ–é‡ę–°å®‰č£… TimeCapsuleSMB,使 App 和 Helper ä½æē”Øē›øåŒēš„ API contract怂"; +"app_readiness.recovery.helper_missing" = "čÆ·é‡ę–°å®‰č£… TimeCapsuleSMBļ¼Œęˆ–åœØ Diagnostics äø­é€‰ę‹©ęœ‰ę•ˆēš„ Helper怂"; +"app_readiness.recovery.install_validation_failed" = "čÆ·é‡ę–°å®‰č£… TimeCapsuleSMBļ¼Œęˆ–ę‰“å¼€ Diagnostics ęŸ„ēœ‹å¤±č“„ēš„ę£€ęŸ„é”¹ć€‚"; +"app_readiness.recovery.retry_diagnostics" = "打开 Diagnostics å¹¶é‡čÆ• App Readiness怂"; +"app_readiness.recovery.update_required" = "从 %@ äø‹č½½ęœ€ę–°ē‰ˆęœ¬ć€‚"; +"app_readiness.recovery.version_metadata_unavailable" = "čÆ·ę£€ęŸ„ē½‘ē»œčæžęŽ„ļ¼Œęˆ–ēØåŽé‡čÆ•ć€‚"; +"app_settings.blank_uses_device_default" = "留空"; +"app_settings.appearance" = "外观"; +"app_settings.check_now" = "ē«‹å³ę£€ęŸ„"; +"app_settings.check_updates_on_launch" = "åÆåŠØę—¶ę£€ęŸ„ę›“ę–°"; +"app_settings.default_bonjour_timeout" = "Discovery timeout ē§’ę•°"; +"app_settings.error.ata_idle" = "ATA idle seconds åæ…é”»ę˜Æéžč“Ÿę•“ę•°ć€‚"; +"app_settings.error.ata_standby" = "ATA standby seconds åæ…é”»ē•™ē©ŗęˆ–äøŗéžč“Ÿę•“ę•°ć€‚"; +"app_settings.error.bonjour_timeout" = "Bonjour timeout åæ…é”»ę˜Æéžč“Ÿę•°ć€‚"; +"app_settings.error.corrupt" = "ę— ę³•čÆ»å– App settings:%@"; +"app_settings.error.mount_wait" = "Mount wait åæ…é”»ę˜Æéžč“Ÿę•“ę•°ć€‚"; +"app_settings.error.version_url" = "Version check URL åæ…é”»ē•™ē©ŗļ¼Œęˆ–äøŗ HTTP/HTTPS URL怂"; +"app_settings.helper_path" = "Helper path"; +"app_settings.language" = "语言"; +"app_settings.restore_defaults" = "ę¢å¤é»˜č®¤å€¼"; +"app_settings.reset_saved" = "é‡ē½®äøŗå·²äæå­˜"; +"app_settings.save" = "äæå­˜č®¾ē½®"; +"app_settings.section.defaults" = "ę–°č®¾å¤‡é»˜č®¤å€¼"; +"app_settings.section.diagnostics" = "Helper 和 Diagnostics"; +"app_settings.section.general" = "é€šē”Ø"; +"app_settings.section.privacy" = "隐私"; +"app_settings.section.time_machine" = "Time Machine"; +"app_settings.section.updates" = "ꛓꖰ"; +"app_settings.show_raw_events" = "在 Diagnostics 中显示 raw Backend events"; +"app_settings.subtitle" = "ę–°č®¾å¤‡é»˜č®¤å€¼å’Œ App ēŗ§åˆ«č”Œäøŗć€‚"; +"app_settings.telemetry_enabled" = "åˆ†äŗ«åŒæå telemetry"; +"app_settings.time_machine_warnings" = "显示 macOS Time Machine å…¼å®¹ę€§č­¦å‘Š"; +"app_settings.title" = "设置"; +"app_settings.version_url" = "Version metadata URL"; +"app_appearance.dark" = "深色"; +"app_appearance.light" = "浅色"; +"app_appearance.system" = "č·Ÿéšē³»ē»Ÿ"; +"app_language.english" = "English"; +"app_language.simplified_chinese" = "简体中文"; +"app_language.system" = "系统默认"; +"app_update.state.checking" = "ę­£åœØę£€ęŸ„ę›“ę–°"; +"app_update.state.current" = "TimeCapsuleSMB å·²ę˜Æęœ€ę–°ē‰ˆęœ¬ć€‚"; +"app_update.state.failed" = "ę›“ę–°ę£€ęŸ„å¤±č“„ć€‚"; +"app_update.state.idle" = "å°šęœŖę£€ęŸ„ć€‚"; +"app_update.state.unavailable" = "Version metadata äøåÆē”Øć€‚"; +"app_update.state.update_available" = "ęœ‰åÆē”Øę›“ę–°ć€‚"; +"button.discover" = "å‘ēŽ°"; +"checkup.presentation.headline.failed" = "ę£€ęŸ„å¤±č“„ć€‚"; +"checkup.presentation.headline.idle" = "čæč”Œę£€ęŸ„ę„ę£€ęŸ„ę­¤ Apple AirPort Time Capsule ꈖ AirPort Extreme怂"; +"checkup.presentation.headline.passed" = "ę£€ęŸ„é€ščæ‡ć€‚"; +"checkup.presentation.headline.run_failed" = "ę£€ęŸ„ę— ę³•å®Œęˆć€‚"; +"checkup.presentation.headline.running" = "ę­£åœØčæč”Œę£€ęŸ„ć€‚"; +"checkup.presentation.headline.warning" = "ę£€ęŸ„å‘ēŽ°č­¦å‘Šć€‚"; +"checkup.progress.running.message" = "ę­£åœØčæč”Œęœ¬åœ°å’ŒčæœēØ‹čÆŠę–­ę£€ęŸ„ć€‚\nčæ™åÆčƒ½éœ€č¦å‡ åˆ†é’Ÿ..."; +"checkup.progress.running.title" = "ę­£åœØčæč”Œę£€ęŸ„"; +"checkup.presentation.row.fail" = "失蓄"; +"checkup.presentation.row.info" = "俔息"; +"checkup.presentation.row.pass" = "é€ščæ‡"; +"checkup.presentation.row.warning" = "č­¦å‘Š"; +"close_guard.close_anyway" = "ä»ē„¶å…³é—­"; +"close_guard.keep_open" = "äæęŒę‰“å¼€"; +"close_guard.message" = "TimeCapsuleSMB ęœ‰ę“ä½œę­£åœØčæ›č”Œć€‚ēŽ°åœØå…³é—­åÆčƒ½ä¼šäø­ę–­č®¾å¤‡äøŠēš„å·„ä½œć€‚"; +"close_guard.title" = "关闭 TimeCapsuleSMB?"; +"confirm.activate.netbsd4.action" = "启动"; +"confirm.activate.netbsd4.message" = "åÆåŠØå·²éƒØē½²ēš„ NetBSD4 payload å¹¶é‡åÆ managed services?"; +"confirm.activate.netbsd4.title" = "启动 NetBSD4 Runtime?"; +"confirm.backend.message" = "ē»§ē»­ę­¤ę“ä½œļ¼Ÿ"; +"confirm.backend.title" = "ē”®č®¤ę“ä½œļ¼Ÿ"; +"confirm.configure.enable_ssh_reboot.action" = "启用 SSH å¹¶é‡åÆ"; +"confirm.configure.enable_ssh_reboot.message" = "%@ äøŠēš„ SSH å·²å…³é—­ć€‚ę˜Æå¦ä½æē”Ø AirPort ACP 启用 SSH å¹¶é‡åÆę­¤ AirPort č®¾å¤‡ļ¼Ÿ"; +"confirm.configure.enable_ssh_reboot.title" = "启用 SSH å¹¶é‡åÆļ¼Ÿ"; +"confirm.deploy.activate_now.action" = "部署并启动 SMB"; +"confirm.deploy.activate_now.message" = "将 TimeCapsuleSMB Deploy 到此 %@ å¹¶åœØäøé‡åÆēš„ęƒ…å†µäø‹åÆåŠØ SMB?"; +"confirm.deploy.activate_now.title" = "部署并启动 SMB?"; +"confirm.deploy.netbsd4.action" = "éƒØē½²ć€é‡åÆå¹¶åÆåŠØ"; +"confirm.deploy.netbsd4.message" = "将 TimeCapsuleSMB Deploy 到此 %@ļ¼Œé‡åÆå®ƒļ¼Œē„¶åŽåœØ SSH ę¢å¤åŽ Activate Samba?"; +"confirm.deploy.netbsd4.title" = "éƒØē½²ć€é‡åÆå¹¶åÆåŠØ NetBSD4?"; +"confirm.deploy.netbsd4_no_wait.action" = "éƒØē½²å¹¶čÆ·ę±‚é‡åÆ"; +"confirm.deploy.netbsd4_no_wait.message" = "将 TimeCapsuleSMB Deploy 到此 %@ļ¼ŒčÆ·ę±‚é‡åÆļ¼Œå¹¶ē«‹å³čæ”å›žļ¼ŒäøåœØ SSH ę¢å¤åŽčæč”Œ Samba activation?"; +"confirm.deploy.netbsd4_no_wait.title" = "éƒØē½²å¹¶čÆ·ę±‚ NetBSD4 é‡åÆļ¼Ÿ"; +"confirm.deploy.no_reboot.action" = "部署"; +"confirm.deploy.no_reboot.message" = "将 TimeCapsuleSMB Deploy 到此 %@ļ¼Œä½†äøé‡åÆļ¼Ÿ"; +"confirm.deploy.no_reboot.title" = "éƒØē½²ä½†äøé‡åÆļ¼Ÿ"; +"confirm.deploy.reboot.action" = "éƒØē½²å¹¶é‡åÆ"; +"confirm.deploy.reboot.message" = "部署 TimeCapsuleSMB å¹¶é‡åÆę­¤ %@?"; +"confirm.deploy.reboot.title" = "éƒØē½²å¹¶é‡åÆļ¼Ÿ"; +"confirm.deploy.reboot_no_wait.action" = "éƒØē½²å¹¶čÆ·ę±‚é‡åÆ"; +"confirm.deploy.reboot_no_wait.message" = "将 TimeCapsuleSMB Deploy 到此 %@ļ¼ŒčÆ·ę±‚é‡åÆļ¼Œå¹¶ē«‹å³čæ”å›žļ¼Ÿ"; +"confirm.deploy.reboot_no_wait.title" = "éƒØē½²å¹¶čÆ·ę±‚é‡åÆļ¼Ÿ"; +"confirm.fsck.no_reboot.action" = "运蔌 fsck"; +"confirm.fsck.no_reboot.message" = "åœØé€‰å®šēš„ HFS volume 上运蔌 fsck?"; +"confirm.fsck.no_reboot.title" = "运蔌 Disk Repair?"; +"confirm.fsck.reboot.action" = "运蔌 fsck"; +"confirm.fsck.reboot.message" = "åœØé€‰å®šēš„ HFS volume 上运蔌 fsck å¹¶é‡åÆč®¾å¤‡ļ¼Ÿ"; +"confirm.fsck.reboot.title" = "运蔌 Disk Repair å¹¶é‡åÆļ¼Ÿ"; +"confirm.flash.patch_write.action" = "写兄 Firmware"; +"confirm.flash.patch_write.message" = "Patch %@ 上 primary firmware bank ēš„ Boot Hookļ¼ŸęˆåŠŸå†™å…„åŽéœ€č¦ę‰‹åŠØę–­ē”µé‡åÆć€‚"; +"confirm.flash.patch_write.title" = "Patch Firmware Boot Hook?"; +"confirm.flash.restore_write.action" = "写兄 Firmware"; +"confirm.flash.restore_write.message" = "将 %@ äøŠēš„ active firmware bank ę¢å¤äøŗ Apple stock firmwareļ¼Œå¹¶åœØéŖŒčÆåŽé‡åÆļ¼Ÿ"; +"confirm.flash.restore_write.title" = "ę¢å¤ Apple Firmware?"; +"confirm.repair_xattrs.action" = "äæ®å¤ xattrs"; +"confirm.repair_xattrs.message" = "äæ®å¤ %@ äø‹å·²ēŸ„å®‰å…Øēš„ macOS metadata é—®é¢˜ļ¼Ÿ"; +"confirm.repair_xattrs.title" = "äæ®å¤ Extended Attributes?"; +"confirm.uninstall.no_reboot.action" = "åøč½½"; +"confirm.uninstall.no_reboot.message" = "ä»Žč®¾å¤‡äø­ē§»é™¤ managed TimeCapsuleSMB files?"; +"confirm.uninstall.no_reboot.title" = "åøč½½ļ¼Ÿ"; +"confirm.uninstall.reboot.action" = "åøč½½"; +"confirm.uninstall.reboot.message" = "ä»Žč®¾å¤‡äø­ē§»é™¤ managed TimeCapsuleSMB files å¹¶é‡åÆļ¼Ÿ"; +"confirm.uninstall.reboot.title" = "åøč½½å¹¶é‡åÆļ¼Ÿ"; +"dashboard.action.install_smb" = "安装 SMB"; +"dashboard.action.install_update_smb" = "安装 / ꛓꖰ SMB"; +"dashboard.action.refresh_status" = "åˆ·ę–°ēŠ¶ę€"; +"dashboard.action.settings" = "设置"; +"dashboard.action.open_finder" = "打开 Finder"; +"dashboard.action.open_smb" = "打开 Finder"; +"dashboard.action.replace_password" = "ę›æę¢åÆ†ē "; +"dashboard.action.run_checkup" = "čæč”Œę£€ęŸ„"; +"dashboard.action.start_smb" = "启动 SMB"; +"dashboard.action.view_checkup" = "ęŸ„ēœ‹ę£€ęŸ„"; +"dashboard.header.last_checked" = "äøŠę¬”ę£€ęŸ„"; +"dashboard.header.last_checked_value" = "äøŠę¬”ę£€ęŸ„ļ¼š%@"; +"dashboard.health.check_counts" = "PASS %d,WARN %d,FAIL %d"; +"dashboard.health.connection" = "čæžęŽ„"; +"dashboard.health.connection.keychain_unavailable" = "ę— ę³•ä»Ž Keychain čÆ»å–äæå­˜ēš„åÆ†ē ć€‚"; +"dashboard.health.connection.not_refreshed" = "čæžęŽ„ēŠ¶ę€å°šęœŖåˆ·ę–°ć€‚"; +"dashboard.health.connection.password_invalid" = "äæå­˜ēš„åÆ†ē č¢«č®¾å¤‡ę‹’ē»ć€‚"; +"dashboard.health.connection.refreshing" = "ę­£åœØę£€ęŸ„ DNS态SSH 和 SMB reachability..."; +"dashboard.health.connection.running" = "åŽē«Æę“ä½œę­£åœØä½æē”Øę­¤ Profile怂"; +"dashboard.health.checkup" = "ę£€ęŸ„"; +"dashboard.health.finder_bonjour" = "Finder / Bonjour"; +"dashboard.health.runtime" = "čæč”Œę—¶"; +"dashboard.health.runtime.activation_needed" = "ę­¤ NetBSD4 č®¾å¤‡é‡åÆåŽåÆčƒ½éœ€č¦ Activate怂"; +"dashboard.health.runtime.installing" = "安装 / ę›“ę–°ę­£åœØčæč”Œć€‚"; +"dashboard.health.runtime.not_installed" = "å°šęœŖä»Žę­¤ App 安装 Samba怂"; +"dashboard.health.smb_auth" = "SMB Auth"; +"dashboard.health.status.failed" = "失蓄"; +"dashboard.health.status.good" = "良儽"; +"dashboard.health.status.running" = "运蔌中"; +"dashboard.health.status.unknown" = "未矄"; +"dashboard.health.status.warning" = "č­¦å‘Š"; +"dashboard.health.time_machine" = "Time Machine"; +"dashboard.health.unchecked" = "čæč”Œę£€ęŸ„ä»„ę£€ęŸ„ę­¤åŒŗåŸŸć€‚"; +"dashboard.generation.1" = "第 1 代"; +"dashboard.generation.2" = "第 2 代"; +"dashboard.generation.3" = "第 3 代"; +"dashboard.generation.4" = "第 4 代"; +"dashboard.generation.5" = "第 5 代"; +"dashboard.generation.6" = "第 6 代"; +"dashboard.overview.generation" = "代际"; +"dashboard.overview.addresses" = "地址"; +"dashboard.overview.connection_target" = "čæžęŽ„ē›®ę ‡"; +"dashboard.overview.host" = "čæžęŽ„ē›®ę ‡"; +"dashboard.overview.last_checkup" = "äøŠę¬”ę£€ęŸ„"; +"dashboard.overview.last_install" = "äøŠę¬”å®‰č£…"; +"dashboard.overview.model" = "型号"; +"dashboard.overview.password" = "密码"; +"dashboard.overview.payload" = "Payload"; +"dashboard.overview.status" = "ēŠ¶ę€"; +"dashboard.password.title" = "å·²äæå­˜åÆ†ē "; +"dashboard.replacement_password" = "ę›“ę–°å·²äæå­˜åÆ†ē "; +"dashboard.tab.settings" = "设置"; +"dashboard.tab.checkup" = "ę£€ęŸ„"; +"dashboard.tab.install" = "安装 / ꛓꖰ"; +"dashboard.tab.maintenance" = "结护"; +"dashboard.tab.overview" = "ę¦‚č§ˆ"; +"deploy.action.plan_install" = "č§„åˆ’å®‰č£…"; +"deploy.advanced_plan_details" = "é«˜ēŗ§č®”åˆ’čÆ¦ęƒ…"; +"deploy.presentation.expected_changes" = "%d äøŖę–‡ä»¶äøŠä¼ ļ¼Œ%d äøŖå®‰č£…åŠØä½œ"; +"deploy.presentation.row.activation_actions" = "åÆåŠØę“ä½œ"; +"deploy.presentation.row.disk_location" = "ē£ē›˜ä½ē½®"; +"deploy.presentation.row.expected_changes" = "é¢„ęœŸå˜åŒ–"; +"deploy.presentation.row.host" = "主机"; +"deploy.presentation.row.payload" = "Payload"; +"deploy.presentation.row.post_install_checks" = "å®‰č£…åŽę£€ęŸ„"; +"deploy.presentation.row.post_upload_actions" = "äøŠä¼ åŽę“ä½œ"; +"deploy.presentation.row.pre_upload_actions" = "äøŠä¼ å‰ę“ä½œ"; +"deploy.presentation.row.reboot" = "é‡åÆ"; +"deploy.presentation.row.target" = "目标"; +"deploy.presentation.title.netbsd4" = "安装 SMB 并启动 Runtime"; +"deploy.presentation.title.standard" = "安装 SMB"; +"deploy.presentation.warning.netbsd4_activation" = "ę­¤ NetBSD4 č®¾å¤‡éœ€č¦ activation step 后 Samba ę‰čƒ½å°±ē»Ŗć€‚"; +"deploy.presentation.warning.netbsd4_activate_now" = "ę­¤ NetBSD4 å®‰č£…ä¼šåœØäøé‡åÆēš„ęƒ…å†µäø‹åÆåŠØ Samba怂"; +"deploy.presentation.warning.netbsd4_reboot_then_activate" = "ę­¤ NetBSD4 å®‰č£…ä¼šå…ˆé‡åÆļ¼Œē„¶åŽåœØ SSH ę¢å¤åŽåÆåŠØ Samba怂"; +"deploy.presentation.warning.no_wait_post_reboot_activation" = "No Wait ä¼šåœØčÆ·ę±‚é‡åÆåŽčæ”å›žć€‚SSH ę¢å¤åŽäøä¼šč‡ŖåŠØåÆåŠØ Samba怂"; +"deploy.presentation.warning.no_wait_post_reboot_verification" = "No Wait ä¼šåœØčÆ·ę±‚é‡åÆåŽčæ”å›žć€‚äøä¼šč‡ŖåŠØčæč”Œé‡åÆåŽēš„ SMB éŖŒčÆć€‚"; +"deploy.result.default_message" = "å®‰č£…å·²å®Œęˆć€‚"; +"deploy.result.message" = "消息"; +"deploy.result.reboot_requested" = "å·²čÆ·ę±‚é‡åÆ"; +"deploy.result.verified" = "已验证"; +"deploy.failure.reboot_guidance" = "ę•…éšœęŽ’ęŸ„ęē¤ŗļ¼ščÆ·å°čÆ•é‡åÆč®¾å¤‡å¹¶ē­‰å¾… 5 åˆ†é’Ÿč®©å®ƒå®ŒęˆåŠ č½½ļ¼Œē„¶åŽå†ę¬”å°čÆ•éƒØē½²ć€‚"; +"install.action.create_plan" = "åˆ›å»ŗå®‰č£…č®”åˆ’"; +"install.action.install_update" = "安装 / ꛓꖰ"; +"install.action.reinstall" = "é‡ę–°å®‰č£…"; +"install.action.regenerate_plan" = "é‡ę–°ē”Ÿęˆč®”åˆ’"; +"install.advanced_options" = "é«˜ēŗ§é€‰é”¹"; +"install.completion.title.finished" = "安装 / ę›“ę–°å·²å®Œęˆ"; +"install.completion.title.verified" = "安装 / ę›“ę–°å·²éŖŒčÆ"; +"install.completion.warning.netbsd4" = "NetBSD4 č®¾å¤‡ä¹‹åŽé‡åÆåŽåÆčƒ½éœ€č¦ę‰‹åŠØåÆåŠØļ¼Œé™¤éž Boot Hook å·² patch怂"; +"install.plan.downtime.activate_now" = "Samba äøé‡åÆåÆåŠØę—¶é€šåøøäøåˆ°äø€åˆ†é’Ÿć€‚"; +"install.plan.downtime.netbsd4" = "Samba äøé‡åÆåÆåŠØę—¶é€šåøøäøåˆ°äø€åˆ†é’Ÿć€‚"; +"install.plan.downtime.no_wait" = "App ä¼ščÆ·ę±‚é‡åÆå¹¶ē«‹å³čæ”å›žć€‚"; +"install.plan.downtime.none" = "é¢„č®”ę— éœ€é‡åÆć€‚"; +"install.plan.downtime.reboot" = "č®¾å¤‡é‡åÆę—¶éœ€č¦å‡ åˆ†é’Ÿć€‚"; +"install.plan.row.expected_downtime" = "é¢„č®”åœęœŗę—¶é—“"; +"install.plan.row.remote_actions" = "čæœēØ‹ę“ä½œ"; +"install.plan.row.uploads" = "上传"; +"install.plan.section.device_actions" = "č®¾å¤‡ę“ä½œ"; +"install.plan.section.target" = "目标"; +"install.plan.title.activate_now" = "安装 / ꛓꖰ SMB 并启动 Runtime"; +"install.plan.title.netbsd4" = "安装 / ꛓꖰ SMB 并启动 Runtime"; +"install.plan.title.reboot_no_wait" = "安装 / ꛓꖰ SMB å¹¶čÆ·ę±‚é‡åÆ"; +"install.plan.title.reboot_then_activate" = "安装 / ꛓꖰ SMBć€é‡åÆå¹¶åÆåŠØ Runtime"; +"install.plan.title.standard" = "安装 / ꛓꖰ SMB"; +"install.advanced_options.no_wait_note" = "No Wait ę˜Æé«˜ēŗ§ē”Øęˆ·é€‰é”¹ļ¼šå®ƒä¼ščÆ·ę±‚é‡åÆå¹¶ē«‹å³čæ”å›žļ¼Œäøčæ›č”Œé‡åÆåŽēš„åÆåŠØęˆ–éŖŒčÆć€‚"; +"install.progress.deploying.message" = "ę­£åœØäøŠä¼ å¹¶åŗ”ē”Ø managed SMB Runtimeć€‚čæ™åÆčƒ½éœ€č¦å‡ åˆ†é’Ÿ..."; +"install.progress.deploying.title" = "ę­£åœØå®‰č£… / ꛓꖰ SMB"; +"install.state.awaiting_confirmation" = "ē»§ē»­å‰čÆ·ęŸ„ēœ‹ē”®č®¤åÆ¹čÆę”†ć€‚"; +"install.state.deploy_failed" = "安装 / 曓新失蓄。"; +"install.state.deploy_interrupted" = "éƒØē½²åœØå®Œęˆå‰č¢«äø­ę–­ć€‚"; +"install.state.deployed" = "安装 / ę›“ę–°å·²å®Œęˆć€‚"; +"install.state.deploying" = "ę­£åœØå®‰č£…ę–‡ä»¶å¹¶åŗ”ē”Øč®¾å¤‡ę›“ę”¹ć€‚"; +"install.state.idle" = "å®‰č£…ęˆ–ę›“ę–° SMB å‰čÆ·å…ˆåˆ›å»ŗč®”åˆ’ć€‚"; +"install.state.plan_failed" = "ę— ę³•åˆ›å»ŗå®‰č£…č®”åˆ’ć€‚"; +"install.state.plan_ready" = "ęŸ„ēœ‹č®”åˆ’ļ¼Œē„¶åŽčæč”Œå®‰č£… / ꛓꖰ怂"; +"install.state.plan_stale" = "é«˜ēŗ§é€‰é”¹åœØę­¤č®”åˆ’åˆ›å»ŗåŽå‘ē”Ÿäŗ†å˜åŒ–ć€‚"; +"install.state.planning" = "ę­£åœØåˆ›å»ŗå®‰č£…č®”åˆ’ć€‚"; +"install.timeline.title" = "ēŠ¶ę€"; +"install.timeline.waiting" = "ę­£åœØē­‰å¾… Backend progress怂"; +"install.warning.awaiting_confirmation" = "åŽē«Æę­£åœØē­‰å¾…ę˜Žē”®ē”®č®¤ć€‚"; +"install.warning.plan_stale" = "å®‰č£…å‰čÆ·é‡ę–°ē”Ÿęˆč®”åˆ’ć€‚"; +"runtime.state.unknown" = "ęœŖå®‰č£…ć€‚"; +"runtime.state.not_installed" = "ęœŖå®‰č£…ć€‚"; +"runtime.state.unhealthy" = "ę£€ęŸ„å‘ēŽ°čæč”Œę—¶é—®é¢˜ć€‚"; +"diagnostics.backend_events" = "Backend Events"; +"diagnostics.copied" = "čÆŠę–­äæ”ęÆå·²å¤åˆ¶ć€‚"; +"diagnostics.copy" = "å¤åˆ¶čÆŠę–­äæ”ęÆ"; +"diagnostics.distribution" = "分发包"; +"diagnostics.helper" = "Helper"; +"diagnostics.runtime_issues" = "čæč”Œę—¶é—®é¢˜"; +"diagnostics.save" = "äæå­˜čÆŠę–­äæ”ęÆ..."; +"diagnostics.saved" = "čÆŠę–­äæ”ęÆå·²äæå­˜ć€‚"; +"diagnostics.state" = "ēŠ¶ę€"; +"diagnostics.title" = "čÆŠę–­"; +"diagnostics.validation" = "验证"; +"bundle_issue.application_support_unavailable.message" = "TimeCapsuleSMB 无法写兄其 Application Support 目录。"; +"bundle_issue.application_support_unavailable.recovery" = "äæ®å¤ TimeCapsuleSMB Application Support ę–‡ä»¶å¤¹ēš„ęƒé™ļ¼Œęˆ–é‡ę–°å®‰č£… App怂"; +"bundle_issue.artifact_manifest_invalid.message" = "ę— ę³•čÆ»å–ę†ē»‘ēš„ artifact manifest怂"; +"bundle_issue.artifact_manifest_missing.message" = "ē¼ŗå°‘ę†ē»‘ēš„ artifact manifest怂"; +"bundle_issue.contract_decode_failed.message" = "App ę”¶åˆ°äŗ†ę„å¤–ēš„ Helper å“åŗ”ć€‚"; +"bundle_issue.distribution_artifacts_missing.message" = "ē¼ŗå°‘ę†ē»‘ēš„ TimeCapsuleSMB payload artifact怂"; +"bundle_issue.distribution_artifacts_missing_count.message" = "ę†ē»‘ēš„ TimeCapsuleSMB åˆ†å‘åŒ…ē¼ŗå°‘ %d äøŖ payload artifact怂"; +"bundle_issue.distribution_root_missing.message" = "ē¼ŗå°‘ę†ē»‘ēš„ TimeCapsuleSMB åˆ†å‘åŒ…ć€‚"; +"bundle_issue.helper_launch_failed.message" = "TimeCapsuleSMB Helper ę— ę³•åÆåŠØć€‚"; +"bundle_issue.helper_missing.message" = "ē¼ŗå°‘ę†ē»‘ēš„ TimeCapsuleSMB Helper怂"; +"bundle_issue.helper_not_executable.message" = "ę†ē»‘ēš„ TimeCapsuleSMB Helper äøåÆę‰§č”Œć€‚"; +"bundle_issue.install_validation_failed.message" = "ę†ē»‘å®‰č£…éŖŒčÆå¤±č“„ć€‚"; +"bundle_issue.operation_failed.message" = "Helper ę“ä½œå¤±č“„ć€‚"; +"bundle_issue.python_packages_missing.message" = "ē¼ŗå°‘ę†ē»‘ēš„ Python åŒ…ć€‚"; +"bundle_issue.recovery.reinstall" = "é‡ę–°å®‰č£… TimeCapsuleSMB怂"; +"bundle_issue.state_directory_unavailable.message" = "TimeCapsuleSMB ę— ę³•å†™å…„å…¶čæč”Œę—¶ēŠ¶ę€ē›®å½•ć€‚"; +"bundle_issue.state_directory_unavailable.recovery" = "äæ®å¤å·²é…ē½®ēŠ¶ę€ē›®å½•ēš„ęƒé™ć€‚"; +"bundle_issue.tools_directory_missing.message" = "ē¼ŗå°‘ę†ē»‘ēš„å‘½ä»¤č”Œå·„å…·ć€‚"; +"bundle_issue.tools_directory_missing.recovery" = "åœØäæ®å¤ App bundle å‰ļ¼ŒęŸäŗ›čÆŠę–­åÆčƒ½äøåÆē”Øć€‚"; +"bundle_issue.unsupported_version.message" = "ę­¤ TimeCapsuleSMB ē‰ˆęœ¬äøå—ę”ÆęŒć€‚"; +"bundle_issue.unsupported_version.recovery" = "ꛓꖰ TimeCapsuleSMB怂"; +"bundle_issue.version_metadata_unavailable.message" = "ę›“ę–°å…ƒę•°ę®äøåÆē”Øć€‚"; +"dialog.forget.action" = "忘记 %@"; +"dialog.forget.error_title" = "ę— ę³•åæ˜č®°č®¾å¤‡"; +"dialog.forget.message" = "从ꭤ Mac 移除 %@ļ¼Ÿčæ™äøä¼šä»Žč®¾å¤‡åøč½½ Samba怂"; +"dialog.forget.title" = "åæ˜č®°ę­¤č®¾å¤‡ļ¼Ÿ"; +"doctor.domain.connection" = "čæžęŽ„"; +"doctor.domain.disk" = "ē£ē›˜"; +"doctor.domain.finder_bonjour" = "Finder / Bonjour"; +"doctor.domain.general" = "åøøč§„"; +"doctor.domain.metadata" = "å…ƒę•°ę®"; +"doctor.domain.runtime" = "čæč”Œę—¶"; +"doctor.domain.smb_auth" = "SMB Auth"; +"doctor.domain.time_machine" = "Time Machine"; +"event.summary.check" = "%@ %@"; +"event.summary.check.default_status" = "INFO"; +"event.summary.error" = "%@:%@"; +"event.summary.error.default_message" = "错误"; +"event.summary.result" = "%@:%@"; +"event.summary.result.failed" = "失蓄"; +"event.summary.result.finished" = "完成"; +"event.summary.stage" = "%@:%@"; +"backend.summary.activation_already_active" = "NetBSD4 payload å·²å¤„äŗŽę“»åŠØēŠ¶ę€ć€‚"; +"backend.summary.activation_completed" = "NetBSD4 activation å·²å®Œęˆć€‚"; +"backend.summary.activation_completed_with_followup" = "NetBSD4 activation å·²å®Œęˆć€‚%@"; +"backend.summary.activation_followup" = "å¦‚ęžœč®¾å¤‡é‡åÆåŽę²”ęœ‰č‡ŖåŠØåÆåŠØ Samba,请运蔌 `activate`怂"; +"backend.summary.activation_plan_generated" = "NetBSD4 activation dry-run č®”åˆ’å·²ē”Ÿęˆć€‚"; +"backend.summary.configuration_saved" = "é…ē½®å·²äæå­˜ļ¼Œå¹¶å·²éŖŒčÆ SSH 身份认证。"; +"backend.summary.deploy_completed" = "éƒØē½²å·²å®Œęˆć€‚"; +"backend.summary.deploy_plan_generated" = "部署 dry-run č®”åˆ’å·²ē”Ÿęˆć€‚"; +"backend.summary.discovered_devices" = "å‘ēŽ° %d 个设备。"; +"backend.summary.doctor_checks_passed" = "čÆŠę–­ę£€ęŸ„é€ščæ‡ć€‚"; +"backend.summary.doctor_found_fatal" = "čÆŠę–­å‘ēŽ°äø€äøŖęˆ–å¤šäøŖäø„é‡é—®é¢˜ć€‚"; +"backend.summary.flash_apple_restore_detail" = "(%@)"; +"backend.summary.flash_apple_restore_product" = "产品 %@"; +"backend.summary.flash_apple_restore_validated" = "Apple restore å›ŗä»¶å·²éŖŒčÆ%@怂"; +"backend.summary.flash_apple_restore_version" = "ē‰ˆęœ¬ %@"; +"backend.summary.flash_apple_stock_matches" = "ę“»åŠØå›ŗä»¶åŒŗäøŽ Apple åŽŸåŽ‚å›ŗä»¶%@åŒ¹é…ć€‚"; +"backend.summary.flash_apple_stock_mismatch" = "ę“»åŠØå›ŗä»¶åŒŗäøŽ Apple åŽŸåŽ‚å›ŗä»¶%@äøåŒ¹é…ć€‚"; +"backend.summary.flash_backup_saved" = "Flash å¤‡ä»½å·²äæå­˜åˆ° %@怂"; +"backend.summary.flash_mode.check_apple" = "Apple ę£€ęŸ„"; +"backend.summary.flash_mode.download_only" = "Apple äø‹č½½"; +"backend.summary.flash_mode.patch" = "patch"; +"backend.summary.flash_mode.restore" = "restore"; +"backend.summary.flash_patch_write_validated_power_cycle" = "Flash patch å†™å…„å·²éŖŒčÆļ¼›éœ€č¦ę‰‹åŠØę–­ē”µé‡åÆć€‚"; +"backend.summary.flash_plan_already_satisfied" = "Flash č®”åˆ’å·²ę»”č¶³ļ¼›ę— éœ€å†™å…„ć€‚"; +"backend.summary.flash_plan_generated" = "Flash %@ č®”åˆ’å·²ē”Ÿęˆć€‚"; +"backend.summary.flash_restore_write_validated_manual_reboot" = "Flash restore å†™å…„å·²éŖŒčÆļ¼›éœ€č¦ę‰‹åŠØé‡åÆć€‚"; +"backend.summary.flash_restore_write_validated_reboot_requested" = "Flash restore å†™å…„å·²éŖŒčÆļ¼›å·²čÆ·ę±‚é‡åÆć€‚"; +"backend.summary.flash_restore_write_validated_rebooted" = "Flash restore å†™å…„å·²éŖŒčÆļ¼›č®¾å¤‡å·²é‡åÆć€‚"; +"backend.summary.flash_version_suffix" = " %@"; +"backend.summary.flash_write_completed" = "Flash å†™å…„å·²å®Œęˆć€‚"; +"backend.summary.flash_write_not_needed" = "ꗠ需 Flash 写兄。"; +"backend.summary.flash_write_plan_generated" = "Flash %@ å†™å…„č®”åˆ’å·²ē”Ÿęˆć€‚"; +"backend.summary.flash_write_validated" = "Flash %@ å†™å…„å·²éŖŒčÆć€‚"; +"backend.summary.fsck_completed" = "已使用 fsck å®Œęˆē£ē›˜äæ®å¤ć€‚"; +"backend.summary.fsck_plan_generated" = "å·²ē”Ÿęˆ fsck dry-run č®”åˆ’ć€‚"; +"backend.summary.helper_capabilities_resolved" = "Helper čƒ½åŠ›å·²č§£ęžć€‚"; +"backend.summary.hfs_volumes_found" = "ę‰¾åˆ° %d äøŖå·²ęŒ‚č½½ēš„ HFS å·ć€‚"; +"backend.summary.install_validation_failed" = "å®‰č£…éŖŒčÆå¤±č“„ć€‚"; +"backend.summary.install_validation_passed" = "å®‰č£…éŖŒčÆé€ščæ‡ć€‚"; +"backend.summary.operation_exited" = "ę“ä½œå·²é€€å‡ŗć€‚"; +"backend.summary.reachability.all_reachable" = "SSH åÆč¾¾ļ¼›SMB ē«Æå£åÆč¾¾ć€‚"; +"backend.summary.reachability.partial" = "č®¾å¤‡éƒØåˆ†åÆč¾¾ć€‚"; +"backend.summary.reachability.smb_only" = "SMB ē«Æå£åÆč¾¾ļ¼ŒSSH 已关闭。"; +"backend.summary.reachability.ssh_only" = "SSH åÆč¾¾ļ¼ŒSMB ē«Æå£å·²å…³é—­ć€‚"; +"backend.summary.reachability.unreachable" = "无法访问 SSH ꈖ SMB怂"; +"backend.summary.repair_xattrs_found" = "å‘ēŽ° %d äøŖå…ƒę•°ę®é—®é¢˜ļ¼Œå…¶äø­ %d äøŖåÆäæ®å¤ć€‚"; +"backend.summary.telemetry_disabled" = "遄测已禁用。"; +"backend.summary.telemetry_enabled" = "遄测已启用。"; +"backend.summary.uninstall_completed" = "åøč½½å·²å®Œęˆć€‚"; +"backend.summary.uninstall_plan_generated" = "åøč½½ dry-run č®”åˆ’å·²ē”Ÿęˆć€‚"; +"backend.summary.uninstall_unverified" = "åøč½½å·²å®Œęˆļ¼Œä½†ęœŖåœØé‡åÆåŽéŖŒčÆć€‚"; +"backend.summary.up_to_date" = "TimeCapsuleSMB å·²ę˜Æęœ€ę–°ē‰ˆęœ¬ć€‚"; +"backend.summary.update_available" = "ęœ‰åÆē”Øę›“ę–°ć€‚"; +"backend.summary.update_required" = "éœ€č¦ę›“ę–°ć€‚"; +"backend.summary.version_metadata_unavailable" = "ē‰ˆęœ¬å…ƒę•°ę®äøåÆē”Øć€‚"; +"field.ata_idle_seconds" = "ATA idle ē§’ę•°"; +"field.ata_standby" = "ATA standby ē§’ę•°"; +"flash.eligibility.disabled" = "ę­¤ęž„å»ŗå·²ē¦ē”Øå›ŗä»¶ Boot Hook åˆ†ęžć€‚"; +"flash.eligibility.netbsd4_required" = "Persistent Boot Hook å·„å…·ä»…é€‚ē”ØäŗŽ NetBSD4 č®¾å¤‡ć€‚å¦‚ęžœä½ ēš„č®¾å¤‡åŗ”čÆ„å—ę”ÆęŒļ¼ŒčÆ·åœØ https://github.com/jamesyc/TimeCapsuleSMB/issues/160 蔄充详细俔息。"; +"flash.eligibility.read_only" = "ę­¤ NetBSD4 č®¾å¤‡åÆå…ˆå¤‡ä»½å’Œę£€ęŸ„ļ¼Œä¹‹åŽę‰åÆå†™å…„ć€‚"; +"flash.eligibility.write_ready" = "修改 NetBSD 4 č®¾å¤‡å›ŗä»¶ļ¼Œä½æå…¶é‡åÆåŽč‡ŖåŠØčæč”Œå·²éƒØē½²ēš„ Samba payload怂\nč§„åˆ’ patch ꈖ restore å†™å…„å‰ļ¼ŒčÆ·å…ˆå¤‡ä»½å¹¶ę£€ęŸ„å›ŗä»¶ć€‚"; +"field.firmware_template" = "固件 template č·Æå¾„ļ¼ŒåÆé€‰"; +"field.firmware_version" = "å›ŗä»¶ē‰ˆęœ¬ļ¼ŒåÆé€‰"; +"field.helper" = "Helper"; +"field.mount_wait" = "Mount wait ē§’ę•°"; +"field.repair_xattrs_max_depth" = "ęœ€å¤§ę·±åŗ¦"; +"field.repair_xattrs_path" = "äæ®å¤ xattrs 路径"; +"flash.action.backup_inspect" = "å¤‡ä»½å¹¶ę£€ęŸ„"; +"flash.action.backup_inspect_again" = "å†ę¬”å¤‡ä»½å¹¶ę£€ęŸ„"; +"flash.action.check_apple" = "ę£€ęŸ„ Apple Firmware"; +"flash.action.choose_template" = "选ꋩ"; +"flash.action.download_apple" = "验证 Apple Restore Firmware"; +"flash.action.plan_patch" = "č§„åˆ’ Patch"; +"flash.action.plan_restore" = "č§„åˆ’ Restore"; +"flash.action.write_patch" = "写兄 Patch"; +"flash.action.write_restore" = "写兄 Restore"; +"flash.manual_power_cycle.message" = "Flash å†™å…„éŖŒčÆå·²å®Œęˆć€‚ę‹”ęŽ‰č®¾å¤‡ē”µęŗļ¼Œē­‰å¾… 10 ē§’ļ¼Œē„¶åŽé‡ę–°ę’äøŠć€‚ē­‰å®ƒå®ŒęˆåÆåŠØåŽčæč”Œę£€ęŸ„ć€‚äø€äøŖå›ŗä»¶ bank ęœŖč¢«äæ®ę”¹ć€‚"; +"flash.manual_power_cycle.title" = "éœ€č¦ę‰‹åŠØé‡åÆ"; +"flash.mode.check_apple" = "ę£€ęŸ„ Apple Firmware"; +"flash.mode.download_only" = "验证 Apple Restore Firmware"; +"flash.mode.patch" = "Patch Boot Hook"; +"flash.mode.restore" = "ę¢å¤ Apple Firmware"; +"flash.options.apple_firmware" = "Apple 固件选锹"; +"flash.row.active_bank" = "当前 Bank"; +"flash.row.apple_match" = "Apple 匹配"; +"flash.row.apple_payload_sha256" = "Apple Payload SHA-256"; +"flash.row.apple_product" = "Apple 产品"; +"flash.row.apple_source" = "Apple ę„ęŗ"; +"flash.row.apple_version" = "Apple ē‰ˆęœ¬"; +"flash.row.backup_dir" = "备份"; +"flash.row.banks" = "Banks"; +"flash.row.firmware_payload_path" = "固件 Payload"; +"flash.row.firmware_payload_sha256" = "Firmware Payload SHA-256"; +"flash.row.firmware_payload_size" = "固件 Payload 大小"; +"flash.row.firmware_product" = "固件产品"; +"flash.row.firmware_source" = "å›ŗä»¶ę„ęŗ"; +"flash.row.firmware_version" = "å›ŗä»¶ē‰ˆęœ¬"; +"flash.row.mode" = "ęØ”å¼"; +"flash.row.write_requested" = "请求写兄"; +"flash.row.write_status" = "å†™å…„ēŠ¶ę€"; +"flash.row.write_validated" = "å†™å…„å·²éŖŒčÆ"; +"flash.title" = "Persistent NetBSD4 Boot Hook"; +"flash.warning.manual_power_cycle" = "ę‹”ęŽ‰č®¾å¤‡ē”µęŗļ¼Œē­‰å¾… 10 ē§’ļ¼Œē„¶åŽé‡ę–°ę’äøŠć€‚"; +"flash.warning.snapshot_stale" = "ę­¤å¤‡ä»½ä¹‹åŽå·²å†™å…„å›ŗä»¶ć€‚č§„åˆ’å¦äø€äøŖ flash action å‰ļ¼ŒčÆ·å†ę¬”å¤‡ä»½å¹¶ę£€ęŸ„ć€‚"; +"helper.error.cancelled" = "ę“ä½œå·²å–ę¶ˆć€‚"; +"helper.error.missing_terminal_event" = "Helper é€€å‡ŗę—¶ę²”ęœ‰ result ꈖ error event怂"; +"host_warning.time_machine.message" = "macOS %d.%d.%d å­˜åœØå·²ēŸ„ēš„ Time Machine network backup é—®é¢˜ć€‚SMB åÆčƒ½åÆē”Øļ¼Œä½† backup åÆé ę€§åÆčƒ½å— Host OS å½±å“ć€‚"; +"host_warning.time_machine.title" = "macOS Time Machine č­¦å‘Š"; +"activity.app_ready" = "App 就绪"; +"activity.active" = "ę­£åœØčæ›č”Œ"; +"activity.last_operation" = "äøŠę¬”ę“ä½œ"; +"activity.multiple_active" = "%d äøŖę­£åœØčæ›č”Œēš„ę“ä½œ"; +"activity.multiple_active.message" = "ę‰“å¼€ę“»åŠØęŸ„ēœ‹čÆ¦ęƒ…ć€‚"; +"activity.no_active_operation" = "ę²”ęœ‰ę­£åœØčæ›č”Œēš„ę“ä½œ"; +"activity.one_active" = "1 äøŖę­£åœØčæ›č”Œēš„ę“ä½œ"; +"activity.recent" = "ęœ€čæ‘"; +"activity.timeline" = "ę—¶é—“ēŗæ"; +"activity.timeline.empty" = "čæ˜ę²”ęœ‰ę“ä½œåŽ†å²ć€‚"; +"discovery_monitor.last_seen.now" = "åˆšåˆšēœ‹åˆ°"; +"discovery_monitor.state.discovering" = "ę­£åœØå‘ēŽ°"; +"discovery_monitor.state.empty" = "ęœŖę‰¾åˆ°č®¾å¤‡"; +"discovery_monitor.state.failed" = "å‘ēŽ°å¤±č“„"; +"discovery_monitor.state.idle" = "空闲"; +"discovery_monitor.state.paused" = "å·²ęš‚åœ"; +"discovery_monitor.state.readiness_blocked" = "App Readiness 被阻止"; +"discovery_monitor.state.ready" = "å·²ę‰¾åˆ°č®¾å¤‡"; +"discovery_monitor.state.waiting_for_readiness" = "ę­£åœØē­‰å¾… App Readiness"; +"checkup.advanced_options" = "é«˜ēŗ§é€‰é”¹"; +"checkup.option.skip_bonjour" = "跳过 Bonjour ę£€ęŸ„"; +"checkup.option.skip_smb" = "跳过 SMB ę£€ęŸ„"; +"checkup.option.skip_ssh" = "跳过 SSH ę£€ęŸ„"; +"checkup.status.failed" = "失蓄"; +"checkup.status.info" = "Info"; +"checkup.status.passed" = "é€ščæ‡"; +"checkup.status.unknown" = "未矄"; +"checkup.status.warning" = "č­¦å‘Š"; +"checkup.timeline.title" = "进度"; +"maintenance.action.choose" = "选ꋩ"; +"maintenance.action.choose_folder" = "选择文件夹"; +"maintenance.action.find_volumes" = "ęŸ„ę‰¾å·"; +"maintenance.action.plan_disk_repair" = "č§„åˆ’ē£ē›˜äæ®å¤"; +"maintenance.action.plan_start_smb" = "č§„åˆ’åÆåŠØ"; +"maintenance.action.plan_uninstall" = "č§„åˆ’åøč½½"; +"maintenance.action.repair_metadata" = "äæ®å¤å…ƒę•°ę®"; +"maintenance.action.run_disk_repair" = "čæč”Œē£ē›˜äæ®å¤"; +"maintenance.action.scan_metadata" = "ę‰«ęå…ƒę•°ę®"; +"maintenance.action.start_smb" = "启动 SMB"; +"maintenance.action.uninstall" = "åøč½½"; +"maintenance.advanced_options" = "é«˜ēŗ§é€‰é”¹"; +"maintenance.presentation.activate.primary_action" = "启动"; +"maintenance.presentation.activate.subtitle" = "在 NetBSD 4 č®¾å¤‡äøŠåÆåŠØå·²éƒØē½²ēš„ SMB Runtime怂"; +"maintenance.presentation.activate.title" = "NetBSD4 启动"; +"maintenance.presentation.fsck.primary_action" = "čæč”Œē£ē›˜äæ®å¤"; +"maintenance.presentation.fsck.subtitle" = "åøč½½é€‰å®šēš„ HFS å·ļ¼Œå¹¶åœØč®¾å¤‡äøŠčæč”Œ fsck_hfs怂"; +"maintenance.presentation.fsck.title" = "ē£ē›˜äæ®å¤"; +"maintenance.presentation.repair_xattrs.primary_action" = "äæ®å¤å…ƒę•°ę®"; +"maintenance.presentation.repair_xattrs.subtitle" = "ę‰«ęå¹¶äæ®å¤å·²ęŒ‚č½½ SMB å…±äŗ«äøŠēš„ macOS å…ƒę•°ę®ć€‚"; +"maintenance.presentation.repair_xattrs.title" = "ę–‡ä»¶å…ƒę•°ę®äæ®å¤"; +"maintenance.presentation.risk.destructive" = "ē “åę€§"; +"maintenance.presentation.risk.local_destructive" = "ęœ¬åœ°ē “åę€§"; +"maintenance.presentation.risk.remote_write" = "čæœēØ‹å†™å…„"; +"maintenance.presentation.uninstall.primary_action" = "åøč½½"; +"maintenance.presentation.uninstall.subtitle" = "ä»Žé€‰å®šč®¾å¤‡ē§»é™¤ payload怂"; +"maintenance.presentation.uninstall.title" = "åøč½½"; +"maintenance.completion.activate" = "启动完成"; +"maintenance.completion.fsck" = "ē£ē›˜äæ®å¤å®Œęˆ"; +"maintenance.completion.repair_xattrs" = "å…ƒę•°ę®äæ®å¤å®Œęˆ"; +"maintenance.completion.uninstall" = "åøč½½å®Œęˆ"; +"maintenance.fsck.no_volumes" = "č§„åˆ’ē£ē›˜äæ®å¤å‰čÆ·å…ˆęŸ„ę‰¾å·²ęŒ‚č½½å·ć€‚"; +"maintenance.plan.activate" = "åÆåŠØč®”åˆ’"; +"maintenance.plan.fsck" = "ē£ē›˜äæ®å¤č®”åˆ’"; +"maintenance.plan.repair_xattrs" = "å…ƒę•°ę®ę‰«ę"; +"maintenance.plan.row.actions" = "ę“ä½œ"; +"maintenance.plan.row.device" = "设备"; +"maintenance.plan.row.findings" = "å‘ēŽ°é”¹"; +"maintenance.plan.row.host" = "主机"; +"maintenance.plan.row.mountpoint" = "ęŒ‚č½½ē‚¹"; +"maintenance.plan.row.path" = "路径"; +"maintenance.plan.row.payload_dirs" = "Payload 目录"; +"maintenance.plan.row.post_checks" = "åŽē»­ę£€ęŸ„"; +"maintenance.plan.row.reboot" = "é‡åÆ"; +"maintenance.plan.row.remote_actions" = "čæœēØ‹ę“ä½œ"; +"maintenance.plan.row.repairable" = "åÆäæ®å¤"; +"maintenance.plan.row.wait_after_reboot" = "é‡åÆåŽē­‰å¾…"; +"maintenance.plan.uninstall" = "åøč½½č®”åˆ’"; +"maintenance.result.already_active" = "已启动"; +"maintenance.result.returncode" = "čæ”å›žē "; +"maintenance.state.awaiting_confirmation" = "ē»§ē»­å‰čÆ·ęŸ„ēœ‹ē”®č®¤åÆ¹čÆę”†ć€‚"; +"maintenance.state.failed" = "ē»“ęŠ¤å¤±č“„ć€‚"; +"maintenance.state.fsck_list_ready" = "é€‰ę‹©äø€äøŖå·ļ¼Œē„¶åŽč§„åˆ’ē£ē›˜äæ®å¤ć€‚"; +"maintenance.state.idle" = "é€‰ę‹©äø‹äø€äøŖē»“ęŠ¤åŠØä½œć€‚"; +"maintenance.state.loading" = "ę­£åœØęŸ„ę‰¾å·²ęŒ‚č½½å·ć€‚"; +"maintenance.state.plan_ready" = "čæč”Œę­¤ē»“ęŠ¤åŠØä½œå‰čÆ·ęŸ„ēœ‹č®”åˆ’ć€‚"; +"maintenance.state.plan_stale" = "é€‰é”¹åœØę­¤č®”åˆ’åˆ›å»ŗåŽå‘ē”Ÿäŗ†å˜åŒ–ć€‚"; +"maintenance.state.planning" = "ę­£åœØåˆ›å»ŗē»“ęŠ¤č®”åˆ’ć€‚"; +"maintenance.state.running" = "ē»“ęŠ¤ę­£åœØčæč”Œć€‚"; +"maintenance.state.scan_ready" = "äæ®å¤å…ƒę•°ę®å‰čÆ·ęŸ„ēœ‹ę‰«ęē»“ęžœć€‚"; +"maintenance.state.scan_stale" = "ę­¤ę¬”ę‰«ęåŽč·Æå¾„å·²å‘ē”Ÿå˜åŒ–ć€‚"; +"maintenance.state.scanning" = "ę­£åœØę‰«ęå…ƒę•°ę®ć€‚"; +"maintenance.state.succeeded" = "ē»“ęŠ¤å·²å®Œęˆć€‚"; +"maintenance.timeline.title" = "进度"; +"maintenance.warning.destructive_fsck" = "ē£ē›˜äæ®å¤åÆčƒ½äæ®ę”¹é€‰å®šēš„å·ć€‚"; +"maintenance.warning.destructive_uninstall" = "åøč½½ä¼šä»Žę­¤č®¾å¤‡ē§»é™¤å·²å®‰č£…ę–‡ä»¶ć€‚"; +"maintenance.warning.local_metadata_repair" = "å…ƒę•°ę®äæ®å¤ä¼šäæ®ę”¹é€‰å®šęœ¬åœ° SMB ęŒ‚č½½äø‹ēš„ę–‡ä»¶ć€‚"; +"maintenance.repairable_count" = "%d äøŖåÆäæ®å¤é”¹ē›®"; +"maintenance.workflow.activate" = "NetBSD4 启动"; +"maintenance.workflow.fsck" = "ē£ē›˜äæ®å¤"; +"maintenance.workflow.repair_xattrs" = "ę–‡ä»¶å…ƒę•°ę®äæ®å¤"; +"maintenance.workflow.uninstall" = "åøč½½"; +"overview.empty.message" = "添加 Apple AirPort Time Capsule ꈖ AirPort Extreme č®¾å¤‡ļ¼Œä»„é…ē½® SMBć€čæč”Œę£€ęŸ„å¹¶ē®”ē†ē»“ęŠ¤ä»»åŠ”ć€‚"; +"overview.empty.title" = "ę²”ęœ‰äæå­˜ēš„č®¾å¤‡"; +"overview.saved_devices.title" = "ę‰€ęœ‰č®¾å¤‡"; +"overview.discovery.add" = "添加"; +"overview.discovery.discovering" = "ę­£åœØęŸ„ę‰¾č®¾å¤‡..."; +"overview.discovery.empty" = "ęœŖę‰¾åˆ°é™„čæ‘ēš„ Apple AirPort Time Capsule ꈖ AirPort Extreme 设备。"; +"overview.discovery.failed" = "Discovery 失蓄。"; +"overview.discovery.paused" = "å½“å‰ę“ä½œå®ŒęˆåŽ Discovery ä¼šę¢å¤ć€‚"; +"overview.discovery.readiness_blocked" = "äæ®å¤ App Readiness 前 Discovery äøåÆē”Øć€‚"; +"overview.discovery.refresh" = "åˆ·ę–°"; +"overview.discovery.saved" = "å·²äæå­˜"; +"overview.discovery.title" = "é™„čæ‘ēš„č®¾å¤‡"; +"overview.discovery.unsaved" = "ęœŖäæå­˜"; +"overview.discovery.waiting" = "App Runtime å°±ē»ŖåŽä¼šå¼€å§‹ Discovery怂"; +"operation.error.already_running" = "å·²ęœ‰å¦äø€äøŖę“ä½œę­£åœØčæč”Œć€‚"; +"workflow.error.activation_plan_required" = "čæč”Œå‰čÆ·å…ˆč§„åˆ’ NetBSD4 activation怂"; +"workflow.error.ata_idle_seconds_invalid" = "ATA idle seconds åæ…é”»ę˜Æéžč“Ÿę•“ę•°ć€‚"; +"workflow.error.ata_standby_invalid" = "ATA standby seconds åæ…é”»ē•™ē©ŗęˆ–äøŗéžč“Ÿę•“ę•°ć€‚"; +"workflow.error.deploy_options_invalid" = "éƒØē½²é€‰é”¹ę— ę•ˆć€‚"; +"workflow.error.deploy_plan_not_ready" = "éƒØē½²č®”åˆ’å°šęœŖå°±ē»Ŗć€‚"; +"workflow.error.deploy_plan_stale" = "éƒØē½²å‰čÆ·ę£€ęŸ„å¹¶é‡ę–°ē”ŸęˆéƒØē½²č®”åˆ’ć€‚"; +"workflow.error.flash_backup_required" = "č§„åˆ’ flash å·„ä½œå‰ļ¼ŒčÆ·å…ˆå¤‡ä»½å¹¶ę£€ęŸ„å›ŗä»¶ć€‚"; +"workflow.error.flash_backup_unavailable" = "Flash å¤‡ä»½äøåÆē”Øć€‚"; +"workflow.error.flash_mode_read_only" = "ę­¤ flash ęØ”å¼äøä¼šå†™å…„å›ŗä»¶ć€‚"; +"workflow.error.flash_plan_required" = "čæč”Œå‰čÆ·å…ˆč§„åˆ’ę‰€é€‰ flash 写兄。"; +"workflow.error.flash_plan_stale" = "Apple å›ŗä»¶é€‰é”¹å˜ę›“åŽļ¼ŒčÆ·é‡ę–°č§„åˆ’ę‰€é€‰ flash 写兄。"; +"workflow.error.flash_writes_disabled" = "ę­¤ęž„å»ŗå·²ē¦ē”Øå›ŗä»¶å†™å…„ć€‚"; +"workflow.error.fsck_plan_not_ready" = "评 fsck č®”åˆ’å°šęœŖå°±ē»Ŗć€‚"; +"workflow.error.fsck_plan_stale" = "čæč”Œå‰čÆ·ę£€ęŸ„å¹¶é‡ę–°ē”Ÿęˆ fsck č®”åˆ’ć€‚"; +"workflow.error.fsck_target_required" = "č§„åˆ’ fsck å‰čÆ·é€‰ę‹©å·²ęŒ‚č½½ēš„ HFS å·ć€‚"; +"workflow.error.mount_wait_invalid" = "Mount wait åæ…é”»ę˜Æéžč“Ÿę•“ę•°ć€‚"; +"workflow.error.operation_already_running" = "å·²ęœ‰å¦äø€äøŖę“ä½œę­£åœØčæč”Œć€‚"; +"workflow.error.operation_could_not_start" = "ę“ä½œę— ę³•åÆåŠØć€‚"; +"workflow.error.repair_xattrs_depth_invalid" = "ęœ€å¤§ę·±åŗ¦åæ…é”»ē•™ē©ŗęˆ–äøŗéžč“Ÿę•“ę•°ć€‚"; +"workflow.error.repair_xattrs_path_required" = "ę‰«ęå‰čÆ·é€‰ę‹©å·²ęŒ‚č½½ēš„ SMB 共享路径。"; +"workflow.error.repair_xattrs_scan_stale" = "äæ®å¤å‰čÆ·é‡ę–°čæč”Œäø€ę¬” xattr ę‰«ęć€‚"; +"workflow.error.uninstall_plan_not_ready" = "åøč½½č®”åˆ’å°šęœŖå°±ē»Ŗć€‚"; +"workflow.error.uninstall_plan_stale" = "čæč”Œå‰čÆ·ę£€ęŸ„å¹¶é‡ę–°ē”Ÿęˆåøč½½č®”åˆ’ć€‚"; +"workflow.state.analyzing_firmware" = "ę­£åœØåˆ†ęžå›ŗä»¶"; +"workflow.state.apple_check_complete" = "Apple å›ŗä»¶ę£€ęŸ„å®Œęˆ"; +"workflow.state.apple_firmware_mismatch" = "Apple å›ŗä»¶äøåŒ¹é…"; +"workflow.state.apple_firmware_ready" = "Apple 固件已就绪"; +"workflow.state.awaiting_confirmation" = "等待甮认"; +"workflow.state.deployed" = "已部署"; +"workflow.state.deploy_failed" = "部署失蓄"; +"workflow.state.deploying" = "正在部署"; +"workflow.state.disabled_in_this_build" = "ę­¤ęž„å»ŗå·²ē¦ē”Ø"; +"workflow.state.failed" = "失蓄"; +"workflow.state.idle" = "空闲"; +"workflow.state.list_ready" = "åˆ—č”Øå·²å°±ē»Ŗ"; +"workflow.state.loading" = "正在加载"; +"workflow.state.manual_power_cycle_required" = "éœ€č¦ę‰‹åŠØę–­ē”µé‡åÆ"; +"workflow.state.passed" = "é€ščæ‡"; +"workflow.state.plan_available" = "č®”åˆ’åÆē”Ø"; +"workflow.state.plan_failed" = "č®”åˆ’å¤±č“„"; +"workflow.state.plan_ready" = "č®”åˆ’å·²å°±ē»Ŗ"; +"workflow.state.plan_stale" = "č®”åˆ’å·²čæ‡ęœŸ"; +"workflow.state.planning" = "ę­£åœØč§„åˆ’"; +"workflow.state.read_only_analysis_available" = "åÆčæ›č”ŒåŖčÆ»åˆ†ęž"; +"workflow.state.reading_firmware_banks" = "ę­£åœØčÆ»å–å›ŗä»¶ Bank"; +"workflow.state.ready" = "就绪"; +"workflow.state.rebooting_after_restore" = "Restore åŽę­£åœØé‡åÆ"; +"workflow.state.repaired" = "å·²äæ®å¤"; +"workflow.state.repairing" = "ę­£åœØäæ®å¤"; +"workflow.state.run_failed" = "运蔌失蓄"; +"workflow.state.running" = "运蔌中"; +"workflow.state.saving_backup" = "ę­£åœØäæå­˜å¤‡ä»½"; +"workflow.state.scan_ready" = "ę‰«ęå·²å°±ē»Ŗ"; +"workflow.state.scan_stale" = "ę‰«ęå·²čæ‡ęœŸ"; +"workflow.state.scanning" = "ę­£åœØę‰«ę"; +"workflow.state.snapshot_stale" = "åæ«ē…§å·²čæ‡ęœŸ"; +"workflow.state.succeeded" = "成功"; +"workflow.state.unavailable" = "äøåÆē”Ø"; +"workflow.state.validating_write" = "ę­£åœØéŖŒčÆå†™å…„"; +"workflow.state.warning" = "č­¦å‘Š"; +"workflow.state.write_validated" = "å†™å…„å·²éŖŒčÆ"; +"workflow.state.writing_firmware" = "ę­£åœØå†™å…„å›ŗä»¶"; +"password_state.available" = "åÆē”Ø"; +"password_state.invalid" = "ꗠꕈ"; +"password_state.keychain_unavailable" = "Keychain äøåÆē”Ø"; +"password_state.missing" = "缺失"; +"password_state.unknown" = "未矄"; +"password.error.keychain_status" = "Keychain error %d怂"; +"password.error.missing" = "密码缺失。"; +"password.error.required" = "éœ€č¦åÆ†ē ć€‚"; +"password.error.unreadable_keychain_item" = "Keychain čæ”å›žäŗ†äøåÆčÆ»å–ēš„åÆ†ē ć€‚"; +"profile_editor.advanced" = "高级"; +"profile_editor.advanced.deploy_notice" = "čÆ·ę‰§č”Œ Deployļ¼Œå°†čæ™äŗ›č®¾ē½®ę›“ę–°åˆ°ä½ ēš„č®¾å¤‡"; +"profile_editor.display_name" = "ę˜¾ē¤ŗåē§°"; +"profile_editor.error.duplicate_host" = "å¦äø€äøŖäæå­˜ēš„č®¾å¤‡å·²ä½æē”Øę­¤ Host怂"; +"profile_editor.error.host_required" = "äø»ęœŗäøŗåæ…å”«ć€‚"; +"profile_editor.error.ata_idle_seconds_invalid" = "ATA idle seconds åæ…é”»ę˜Æéžč“Ÿę•“ę•°ć€‚"; +"profile_editor.error.ata_standby_invalid" = "ATA standby seconds åæ…é”»ē•™ē©ŗęˆ–äøŗéžč“Ÿę•“ę•°ć€‚"; +"profile_editor.error.mount_wait_invalid" = "Mount wait åæ…é”»ę˜Æéžč“Ÿę•“ę•°ć€‚"; +"profile_editor.error.password_required" = "曓改 Host éœ€č¦äæå­˜ēš„åÆ†ē ć€‚"; +"profile_editor.reset" = "é‡ē½®"; +"profile_editor.save" = "äæå­˜ Profile"; +"profile_editor.state.auth_failed" = "åÆ†ē č¢«ę‹’ē»"; +"profile_editor.state.clean" = "å·²äæå­˜"; +"profile_editor.state.dirty" = "ęœŖäæå­˜ēš„ę›“ę”¹"; +"profile_editor.state.failed" = "失蓄"; +"profile_editor.state.invalid" = "éœ€č¦ę›“ę”¹"; +"profile_editor.state.reconfiguring" = "正在 Reconfigure"; +"profile_editor.state.saved" = "å·²äæå­˜"; +"profile_editor.state.saving" = "ę­£åœØäæå­˜"; +"profile_editor.state.unsupported" = "äøę”ÆęŒ"; +"profile_editor.title" = "设备 Profile"; +"readiness.blocked.title" = "TimeCapsuleSMB ę— ę³•åÆåŠØ"; +"readiness.state.checking_capabilities" = "ę­£åœØę£€ęŸ„ Helper"; +"readiness.state.checking_version" = "ę­£åœØę£€ęŸ„ē‰ˆęœ¬"; +"readiness.state.resolving_bundle" = "ę­£åœØå‡†å¤‡ App Runtime"; +"readiness.state.validating_install" = "正在验证 bundled files"; +"readiness.warning.default" = "TimeCapsuleSMB ę­£åœØåø¦č­¦å‘Ščæč”Œć€‚"; +"recovery.action.copy_diagnostics" = "复制 Diagnostics"; +"recovery.action.disk_repair" = "运蔌 Disk Repair"; +"recovery.action.install_smb" = "安装 SMB"; +"recovery.action.metadata_repair" = "äæ®å¤ File Metadata"; +"recovery.action.open" = "打开"; +"recovery.action.open_diagnostics" = "打开 Diagnostics"; +"recovery.action.open_finder" = "打开 Finder"; +"recovery.action.replace_password" = "ę›æę¢åÆ†ē "; +"recovery.action.retry" = "é‡čÆ•"; +"recovery.action.run_checkup" = "čæč”Œę£€ęŸ„"; +"recovery.action.start_smb" = "启动 SMB"; +"recovery.action.uninstall" = "åøč½½"; +"recovery.guidance.next_steps" = "下一歄"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.title" = "é‡åÆęœŖå®Œęˆ"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.message" = "č½½č·å·²äøŠä¼ ļ¼Œé‡åÆčÆ·ę±‚ä¹Ÿå·²ęˆåŠŸå‘é€ļ¼Œä½†č®¾å¤‡ęœŖčƒ½åœØ 4 åˆ†é’Ÿč¶…ę—¶å‰é‡ę–°ęŽ„å— SSH čæžęŽ„ć€‚å®ƒåÆčƒ½ä»åœØåÆåŠØļ¼Œä¹ŸåÆčƒ½å·²ä½æē”Øę–°ēš„ IP åœ°å€é‡ę–°äøŠēŗæć€‚"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.1" = "å†ē­‰å¾…å‡ åˆ†é’Ÿć€‚"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.2" = "å¦‚ęžœč®¾å¤‡åÆé€ščæ‡ę–°ēš„ IP č®æé—®ļ¼ŒčÆ·ę›“ę–° TC_HOST ęˆ–é‡ę–°čæč”Œé…ē½®ć€‚"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.3" = "ē”®č®¤ä½ čæžęŽ„åœØäøŽč®¾å¤‡ē›øåŒēš„ē½‘ē»œęˆ– Wi-Fi äøŠć€‚"; +"backend.recovery.deploy.remote_error.wait_for_reboot_up.action.4" = "在 NetBSD 4 č®¾å¤‡äøŠļ¼ŒSSH åÆē”ØåŽčÆ·čæč”Œ tcapsule activateļ¼›éƒØē½²å°šęœŖčµ°åˆ°é‡åÆåŽęæ€ę“» Samba ēš„ę­„éŖ¤ć€‚"; +"screen.readiness" = "Readiness"; +"sidebar.add_airport_device" = "ę·»åŠ č®¾å¤‡"; +"sidebar.all_airport_devices" = "ę‰€ęœ‰č®¾å¤‡"; +"sidebar.activity" = "擻动"; +"sidebar.devices" = "设备"; +"sidebar.menu.copy_hostname" = "复制 Hostname"; +"sidebar.menu.copy_ip_address" = "复制 IP Address"; +"sidebar.menu.copy_smb_address" = "复制 SMB Address"; +"sidebar.menu.open_overview" = "打开 Overview"; +"sidebar.menu.remove_from_this_mac" = "从ꭤ Mac 移除"; +"sidebar.settings" = "设置"; +"status.activation_needed" = "éœ€č¦ Activation"; +"status.checking" = "ę­£åœØę£€ęŸ„"; +"status.failed" = "失蓄"; +"status.healthy" = "偄康"; +"status.installing" = "ę­£åœØå®‰č£…"; +"status.keychain_unavailable" = "Keychain äøåÆē”Ø"; +"status.maintenance" = "结护"; +"status.offline" = "离线"; +"status.password_invalid" = "åÆ†ē ę— ę•ˆ"; +"status.password_needed" = "éœ€č¦åÆ†ē "; +"status.ready_to_install" = "åÆä»„å®‰č£…"; +"status.removed" = "已移除"; +"status.unchecked" = "ęœŖę£€ęŸ„"; +"status.unsupported" = "äøę”ÆęŒ"; +"status.warning" = "č­¦å‘Š"; +"summary.checkup_counts" = "PASS %d,WARN %d,FAIL %d"; +"summary.install_verified_by_checkup" = "å·²å®‰č£…ļ¼Œå¹¶å·²é€ščæ‡ę£€ęŸ„éŖŒčÆć€‚"; +"timeline.error.needs_attention" = "éœ€č¦ę³Øę„"; +"timeline.error.needs_confirmation" = "éœ€č¦ē”®č®¤"; +"timeline.operation.activate" = "启动"; +"timeline.operation.configure" = "ę·»åŠ č®¾å¤‡"; +"timeline.operation.deploy" = "安装 / ꛓꖰ"; +"timeline.operation.discovery" = "å‘ēŽ°"; +"timeline.operation.doctor" = "ę£€ęŸ„"; +"timeline.operation.flash" = "Persistent NetBSD4 Boot Hook"; +"timeline.operation.fsck" = "ē£ē›˜äæ®å¤"; +"timeline.operation.reachability" = "åÆč¾¾ę€§"; +"timeline.operation.readiness" = "App Readiness"; +"timeline.operation.repair_xattrs" = "ę–‡ä»¶å…ƒę•°ę®äæ®å¤"; +"timeline.operation.telemetry" = "遄测设置"; +"timeline.operation.uninstall" = "åøč½½"; +"timeline.operation.version_check" = "ę›“ę–°ę£€ęŸ„"; +"timeline.result.done" = "完成"; +"timeline.result.failed" = "失蓄"; +"timeline.deploy.result.completed" = "éƒØē½²å·²å®Œęˆć€‚"; +"timeline.state.failed" = "失蓄"; +"timeline.state.pending" = "等待中"; +"timeline.state.running" = "运蔌中"; +"timeline.state.succeeded" = "成功"; +"timeline.state.warning" = "č­¦å‘Š"; +"timeline.deploy.detail.activate_runtime" = "ę­£åœØäøé‡åÆč®¾å¤‡ēš„ęƒ…å†µäø‹åÆåŠØå·²éƒØē½²ēš„čæč”Œę—¶ć€‚"; +"timeline.deploy.detail.build_deployment_plan" = "ę­£åœØē”Ÿęˆęœ‰åŗēš„éƒØē½²ę“ä½œć€‚"; +"timeline.deploy.detail.check_compatibility" = "ę­£åœØę ¹ę®ę£€ęµ‹åˆ°ēš„ NetBSD ē‰ˆęœ¬é€‰ę‹© payload怂"; +"timeline.deploy.detail.flush_payload_upload" = "ę­£åœØå°† payload å†™å…„åˆ·ę–°åˆ°ē£ē›˜ć€‚"; +"timeline.deploy.detail.load_config" = "ę­£åœØčÆ»å–å·²äæå­˜ēš„č®¾å¤‡é…ē½®ć€‚"; +"timeline.deploy.detail.post_reboot_activation" = "SSH ę¢å¤åŽę­£åœØåÆåŠØ SMB怂"; +"timeline.deploy.detail.post_upload_actions" = "ę­£åœØå®‰č£… flash hooks å¹¶åŗ”ē”Øę–‡ä»¶ęƒé™ć€‚"; +"timeline.deploy.detail.pre_upload_actions" = "ę­£åœØåœę­¢ę‰˜ē®”ęœåŠ”å¹¶ē§»é™¤ę—§éƒØē½²ę–‡ä»¶ć€‚"; +"timeline.deploy.detail.prepare_deployment_files" = "ę­£åœØē”Ÿęˆčæč”Œę—¶é…ē½®å’Œ Samba č“¦å·ę–‡ä»¶ć€‚"; +"timeline.activate.detail.probe_runtime" = "ę­£åœØę£€ęŸ„ TimeCapsuleSMB ę˜Æå¦å·²åœØčæč”Œļ¼Œē„¶åŽå†ęæ€ę“»ć€‚"; +"timeline.deploy.detail.probe_runtime" = "ę­£åœØę£€ęŸ„č®¾å¤‡ę˜Æå¦ä¼šč‡ŖåŠØåÆåŠØ TimeCapsuleSMB怂"; +"timeline.deploy.detail.read_mast" = "ę­£åœØęŸ„ę‰¾å·²ęŒ‚č½½ēš„ HFS payload å·ć€‚"; +"timeline.deploy.detail.reboot" = "ę­£åœØå‘é€ SSH é‡åÆčÆ·ę±‚ć€‚"; +"timeline.deploy.detail.resolve_managed_target" = "ę­£åœØč§£ęžéƒØē½²ē›®ę ‡å¹¶ęŽ¢ęµ‹ SSH怂"; +"timeline.deploy.detail.select_payload_home" = "ę­£åœØé€‰ę‹©åÆå†™å…„ēš„ payload 目录。"; +"timeline.deploy.detail.upload_boot_files" = "ę­£åœØå¤åˆ¶åÆåŠØč„šęœ¬åˆ° /mnt/Flash怂"; +"timeline.deploy.detail.upload_mdns_advertiser" = "ę­£åœØå¤åˆ¶ mdns-advertiser 到 payload 卷和 flash怂"; +"timeline.deploy.detail.upload_nbns_advertiser" = "ę­£åœØå¤åˆ¶ nbns-advertiser 到 payload å·ć€‚"; +"timeline.deploy.detail.upload_payload" = "ę­£åœØäøŠä¼ ę‰˜ē®” SMB payload ꖇ件怂"; +"timeline.deploy.detail.upload_runtime_config" = "ę­£åœØå†™å…„čæč”Œę—¶č®¾ē½®åˆ° /mnt/Flash怂"; +"timeline.deploy.detail.upload_samba_accounts" = "ę­£åœØå†™å…„ Samba č“¦å·ę–‡ä»¶åˆ° payload å·ć€‚"; +"timeline.deploy.detail.upload_smbd" = "ę­£åœØå¤åˆ¶ smbd 到 payload å·ć€‚"; +"timeline.deploy.detail.validate_artifacts" = "å†™å…„č®¾å¤‡å‰ę­£åœØę£€ęŸ„ę‰“åŒ…ēš„ payload ꖇ件怂"; +"timeline.deploy.detail.verify_payload_upload" = "ę­£åœØę£€ęŸ„äøŠä¼ ēš„ę–‡ä»¶ę˜Æå¦å­˜åœØć€‚"; +"timeline.deploy.detail.verify_payload_upload_after_sync" = "ē£ē›˜åˆ·ę–°åŽę­£åœØå†ę¬”ę£€ęŸ„ payload怂"; +"timeline.deploy.detail.verify_runtime_activation" = "ę­£åœØē­‰å¾…ę‰˜ē®”čæč”Œę—¶åÆåŠØå®Œęˆć€‚"; +"timeline.deploy.detail.verify_runtime_reboot" = "č®¾å¤‡å·²é‡ę–°äøŠēŗæć€‚ę­£åœØē­‰å¾…ę‰˜ē®”čæč”Œę—¶åÆåŠØå®Œęˆć€‚"; +"timeline.deploy.detail.wait_for_reboot_down" = "ę­£åœØē­‰å¾… SSH åœØé‡åÆčÆ·ę±‚åŽę–­å¼€ć€‚"; +"timeline.deploy.detail.wait_for_reboot_up" = "č®¾å¤‡å·²ę–­å¼€ļ¼›ę­£åœØē­‰å¾…å®ƒé‡ę–°äøŠēŗæć€‚"; +"timeline.deploy.title.activate_runtime" = "启动 SMB"; +"timeline.deploy.title.build_deployment_plan" = "å‡†å¤‡å®‰č£…č®”åˆ’"; +"timeline.deploy.title.check_compatibility" = "ę£€ęŸ„č®¾å¤‡å…¼å®¹ę€§"; +"timeline.deploy.title.flush_payload_upload" = "åˆ·ę–°åˆ°ē£ē›˜"; +"timeline.deploy.title.load_config" = "čÆ»å–é…ē½®"; +"timeline.deploy.title.post_reboot_activation" = "é‡åÆåŽåÆåŠØ SMB"; +"timeline.deploy.title.post_upload_actions" = "åŗ”ē”Øę–‡ä»¶ęƒé™"; +"timeline.deploy.title.pre_upload_actions" = "åœę­¢ēŽ°ęœ‰čæč”Œę—¶"; +"timeline.deploy.title.prepare_deployment_files" = "ē”Ÿęˆčæč”Œę—¶é…ē½®"; +"timeline.activate.title.probe_runtime" = "ę£€ęŸ„ēŽ°ęœ‰čæč”ŒēŠ¶ę€"; +"timeline.deploy.title.probe_runtime" = "ę£€ęŸ„åÆåŠØé…ē½®"; +"timeline.deploy.title.read_mast" = "ęŸ„ę‰¾ Payload 卷"; +"timeline.deploy.title.reboot" = "čÆ·ę±‚é‡åÆ"; +"timeline.deploy.title.resolve_managed_target" = "čæžęŽ„č®¾å¤‡"; +"timeline.deploy.title.select_payload_home" = "选ꋩ Payload 目录"; +"timeline.deploy.title.upload_boot_files" = "äøŠä¼ åÆåŠØę–‡ä»¶"; +"timeline.deploy.title.upload_mdns_advertiser" = "上传 mdns-advertiser"; +"timeline.deploy.title.upload_nbns_advertiser" = "上传 nbns-advertiser"; +"timeline.deploy.title.upload_payload" = "上传 Payload"; +"timeline.deploy.title.upload_runtime_config" = "äøŠä¼ čæč”Œę—¶é…ē½®"; +"timeline.deploy.title.upload_samba_accounts" = "上传 Samba č“¦å·ę–‡ä»¶"; +"timeline.deploy.title.upload_smbd" = "上传 smbd"; +"timeline.deploy.title.validate_artifacts" = "ę£€ęŸ„ęœ¬åœ°ę–‡ä»¶"; +"timeline.deploy.title.verify_payload_upload" = "验证上传"; +"timeline.deploy.title.verify_payload_upload_after_sync" = "éŖŒčÆå·²åˆ·ę–°äøŠä¼ "; +"timeline.deploy.title.verify_runtime_activation" = "验证 SMB 启动"; +"timeline.deploy.title.verify_runtime_reboot" = "验证 SMB 启动"; +"timeline.deploy.title.wait_for_reboot_down" = "ē­‰å¾…č®¾å¤‡é‡åÆ"; +"timeline.deploy.title.wait_for_reboot_up" = "设备已断开"; +"timeline.stage.checking_bundled_files" = "ę­£åœØę£€ęŸ„ Bundled Files"; +"timeline.stage.checking_runtime" = "ę­£åœØę£€ęŸ„ SMB"; +"timeline.stage.checking_ssh" = "ę­£åœØę£€ęŸ„ SSH"; +"timeline.stage.checking_airport_identity" = "ę­£åœØę£€ęŸ„ AirPort 身份"; +"timeline.stage.confirming_ssh_enable" = "ę­£åœØē”®č®¤åÆē”Ø SSH"; +"timeline.stage.deleting_old_deployed_files" = "ę­£åœØåˆ é™¤ę—§ deployed files"; +"timeline.stage.enabling_ssh" = "ę­£åœØåÆē”Ø SSH"; +"timeline.stage.finding_disk" = "ę­£åœØęŸ„ę‰¾ Disk"; +"timeline.stage.finding_devices" = "ę­£åœØęŸ„ę‰¾č®¾å¤‡"; +"timeline.stage.finding_volumes" = "ę­£åœØęŸ„ę‰¾ Volumes"; +"timeline.stage.planning_install" = "正在 Planning Install"; +"timeline.stage.planning_start_smb" = "正在 Planning Activation"; +"timeline.stage.planning_uninstall" = "正在 Planning Uninstall"; +"timeline.stage.reachability_candidates" = "ę­£åœØå‡†å¤‡ Reachability Check"; +"timeline.stage.reachability_dns" = "ę­£åœØę£€ęŸ„ DNS"; +"timeline.stage.reachability_ping" = "ę­£åœØę£€ęŸ„ Ping"; +"timeline.stage.reachability_smb_port" = "ę­£åœØę£€ęŸ„ SMB Port"; +"timeline.stage.reachability_ssh_auth" = "ę­£åœØę£€ęŸ„ SSH Auth"; +"timeline.stage.reachability_ssh_port" = "ę­£åœØę£€ęŸ„ SSH Port"; +"timeline.stage.rebooting" = "ę­£åœØé‡åÆ"; +"timeline.stage.removing_managed_files" = "ę­£åœØē§»é™¤ Managed Files"; +"timeline.stage.repairing_disk" = "ę­£åœØäæ®å¤ Disk"; +"timeline.stage.repairing_metadata" = "ę­£åœØäæ®å¤ Metadata"; +"timeline.stage.running_checkup" = "ę­£åœØčæč”Œę£€ęŸ„"; +"timeline.stage.saving_device" = "ę­£åœØäæå­˜č®¾å¤‡"; +"timeline.stage.scanning_metadata" = "ę­£åœØę‰«ę Metadata"; +"timeline.stage.starting_smb" = "正在启动 SMB"; +"timeline.stage.syncing_to_disk" = "正在 Sync 到 Disk"; +"timeline.stage.uploading" = "正在上传"; +"timeline.stage.validating_app_bundle" = "正在验证 App Bundle"; +"timeline.stage.verifying_smb" = "正在验证 SMB"; +"timeline.stage.waiting_for_device" = "ę­£åœØē­‰å¾…č®¾å¤‡"; +"toggle.enable_debug_logging" = "åÆē”Øč°ƒčÆ•ę—„åæ—"; +"toggle.enable_nbns" = "启用 NBNS"; +"toggle.internal_share_use_disk_root" = "å†…éƒØå…±äŗ«ä½æē”Øē£ē›˜ę ¹ē›®å½•"; +"toggle.any_protocol" = "å…č®øä»»ę„ SMB åč®®"; +"toggle.force_debug_logging" = "å¼ŗåˆ¶č°ƒčÆ•ę—„åæ—"; +"toggle.no_reboot" = "äøé‡åÆ"; +"toggle.no_wait" = "äøē­‰å¾…"; +"toggle.repair_xattrs_fix_permissions" = "äæ®å¤ęƒé™"; +"toggle.repair_xattrs_include_hidden" = "åŒ…å«éšč—č·Æå¾„"; +"toggle.repair_xattrs_include_time_machine" = "包含 Time Machine 路径"; +"toggle.repair_xattrs_recursive" = "递归"; +"toggle.repair_xattrs_verbose" = "详细输出"; +"toolbar.cancel" = "å–ę¶ˆ"; +"toolbar.add" = "添加"; +"toolbar.diagnostics" = "čÆŠę–­"; +"toolbar.disabled" = "已禁用"; +"toolbar.forget" = "忘记"; +"value.auto" = "č‡ŖåŠØ"; +"value.never" = "ä»ŽęœŖ"; +"value.list_separator" = ","; +"value.no" = "否"; +"value.not_required" = "äøéœ€č¦"; +"value.required" = "éœ€č¦"; +"value.unknown" = "未矄"; +"value.yes" = "是"; diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift new file mode 100644 index 00000000..c9bc2191 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/AddDevice/AddDeviceView.swift @@ -0,0 +1,207 @@ +import SwiftUI + +struct AddDeviceView: View { + @ObservedObject var store: AddDeviceFlowStore + + var body: some View { + ZStack { + content + if let progress = AddDeviceProgressPresentation(state: store.state, currentStage: store.currentStage) { + BlockingProgressOverlay(progress: progress) + } + } + } + + private var content: some View { + VStack(alignment: .leading, spacing: 14) { + topSection + if store.entryMode == .manual { + connectionControls + Spacer(minLength: 0) + } else { + deviceResultsSection + connectionControls + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .disabled(AddDeviceProgressPresentation(state: store.state, currentStage: store.currentStage) != nil) + } + + private var topSection: 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(discoveryStatusText) + .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) + } + } + + private var discoveryStatusText: String { + guard let stage = store.currentStage else { + return L10n.string("add_device.discover.placeholder") + } + return OperationTimelineBuilder.stageDetail(for: stage.operation, stage: stage.stage, fallback: nil) + ?? OperationTimelineBuilder.stageTitle(for: stage.operation, stage: stage.stage) + } + + private var deviceResultsSection: some View { + Group { + if store.entryMode == .discover && !store.devices.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.string("add_device.discovered_devices")) + .font(.headline) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(store.devices) { device in + Button { + store.select(device) + } label: { + DeviceCandidateRow(device: device, selected: store.selectedDeviceID == device.id) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + } + } + .scrollIndicators(.visible) + .frame(maxWidth: .infinity) + } + } else { + Spacer(minLength: 24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var connectionControls: some View { + VStack(alignment: .leading, spacing: 10) { + 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) + .onSubmit { + guard store.canConfigure else { + return + } + store.runConfigure() + } + } + + 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) + } + } + } + + private var statusIcon: String { + switch store.state { + case .idle, .manualEntry, .passwordEntry: + return "circle" + case .discovering, .configuring, .savingProfile: + return "hourglass" + case .awaitingConfirmation: + return "questionmark.circle" + 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 .awaitingConfirmation: + return .yellow + 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.hostname, device.addressSummary].filter { !$0.isEmpty }.joined(separator: " ")) + .font(.caption) + .foregroundStyle(.secondary) + Text(L10n.format("add_device.setup_target", device.connectionTarget)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + if !device.discoveryModelText.isEmpty { + Text(device.discoveryModelText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 6) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/AnimatedProgressText.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/AnimatedProgressText.swift new file mode 100644 index 00000000..e4e7506e --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/AnimatedProgressText.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct AnimatedProgressText: View { + let message: String + let isRunning: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var phase = 0 + + var body: some View { + Text(animatedMessage) + .onChange(of: animationIdentity) { _, _ in + phase = 0 + } + .task(id: animationIdentity) { + await animateWhileRunning() + } + } + + private var animatedMessage: String { + ProgressTextAnimator.message(message, isRunning: shouldAnimate, phase: phase) ?? message + } + + private var animationIdentity: String? { + ProgressTextAnimator.shouldAnimate(message, isRunning: shouldAnimate) ? message : nil + } + + private var shouldAnimate: Bool { + isRunning && !reduceMotion + } + + private func animateWhileRunning() async { + phase = 0 + guard animationIdentity != nil else { + return + } + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: ProgressTextAnimator.frameIntervalNanoseconds) + } catch { + return + } + guard !Task.isCancelled else { + return + } + phase = ProgressTextAnimator.nextPhase(after: phase) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift new file mode 100644 index 00000000..958dc273 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/BlockingProgressOverlay.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct BlockingProgressOverlay: View { + let progress: Progress + let allowsBackgroundInteraction: Bool + + init(progress: Progress, allowsBackgroundInteraction: Bool = false) { + self.progress = progress + self.allowsBackgroundInteraction = allowsBackgroundInteraction + } + + var body: some View { + ZStack { + if !allowsBackgroundInteraction { + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + } + + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text(progress.title) + .font(.headline) + Text(progress.message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text(progress.detail ?? "") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(minHeight: 28, alignment: .top) + .opacity(progress.detail?.isEmpty == false ? 1 : 0) + } + .padding(22) + .frame(width: 340) + .frame(minHeight: 176) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 18) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .allowsHitTesting(!allowsBackgroundInteraction) + .transition(.opacity) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift new file mode 100644 index 00000000..85ba3915 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ErrorRecoveryView.swift @@ -0,0 +1,131 @@ +import AppKit +import SwiftUI + +struct ErrorBlock: View { + let presentation: RecoveryGuidancePresentation + + init(error: BackendErrorViewModel) { + self.presentation = RecoveryGuidancePresentation(error: error) + } + + init(presentation: RecoveryGuidancePresentation) { + self.presentation = presentation + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.body.weight(.medium)) + Text(presentation.errorMessage) + .font(.caption) + } + .foregroundStyle(.red) + } +} + +struct ErrorRecoveryView: View { + let error: BackendErrorViewModel + let guidance: String? + let onAction: (RecoveryAction) -> Void + let diagnosticsText: (() -> String)? + + init( + error: BackendErrorViewModel, + guidance: String? = nil, + diagnosticsText: (() -> String)? = nil, + onAction: @escaping (RecoveryAction) -> Void + ) { + self.error = error + self.guidance = guidance + self.diagnosticsText = diagnosticsText + self.onAction = onAction + } + + var body: some View { + let presentation = RecoveryGuidancePresentation(error: error) + VStack(alignment: .leading, spacing: 6) { + ErrorBlock(presentation: presentation) + if let guidance { + Text(guidance) + .font(.caption) + .foregroundStyle(.red) + } + if let detail = presentation.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + if !presentation.steps.isEmpty { + VStack(alignment: .leading, spacing: 3) { + Text(L10n.string("recovery.guidance.next_steps")) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + ForEach(Array(presentation.steps.enumerated()), id: \.offset) { index, step in + Text("\(index + 1). \(step)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + 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( + diagnosticsText?() ?? "\(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.down.on.square" + 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/Views/Components/OperationTimelineStateIcon.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift new file mode 100644 index 00000000..2e71a7f6 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineStateIcon.swift @@ -0,0 +1,112 @@ +import SwiftUI + +enum OperationTimelineVisualStyle { + static func symbol(for state: OperationTimelineItem.State) -> String { + switch state { + case .pending: + return "circle" + case .running: + return "arrow.triangle.2.circlepath" + case .succeeded: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + } + } + + static func color(for state: OperationTimelineItem.State) -> Color { + switch state { + case .pending: + return .secondary + case .running: + return .accentColor + case .succeeded: + return .green + case .warning: + return .yellow + case .failed: + return .red + } + } + + static func accessibilityLabel(for state: OperationTimelineItem.State) -> String { + switch state { + case .pending: + return L10n.string("timeline.state.pending") + case .running: + return L10n.string("timeline.state.running") + case .succeeded: + return L10n.string("timeline.state.succeeded") + case .warning: + return L10n.string("timeline.state.warning") + case .failed: + return L10n.string("timeline.state.failed") + } + } +} + +struct OperationTimelineStateIcon: View { + let state: OperationTimelineItem.State + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + icon + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(OperationTimelineVisualStyle.color(for: state)) + .frame(width: 20, height: 20) + .scaleEffect(scale) + .animation(animation, value: state) + .accessibilityLabel(OperationTimelineVisualStyle.accessibilityLabel(for: state)) + } + + @ViewBuilder + private var icon: some View { + switch state { + case .pending: + Image(systemName: OperationTimelineVisualStyle.symbol(for: state)) + case .running: + RotatingTimelineIcon() + case .succeeded, .warning, .failed: + Image(systemName: OperationTimelineVisualStyle.symbol(for: state)) + } + } + + private var scale: CGFloat { + switch state { + case .running: + return 1.05 + case .succeeded: + return 1.08 + case .pending, .warning, .failed: + return 1 + } + } + + private var animation: Animation? { + reduceMotion ? nil : .snappy(duration: 0.18) + } +} + +private struct RotatingTimelineIcon: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isRotating = false + + var body: some View { + Image(systemName: OperationTimelineVisualStyle.symbol(for: .running)) + .rotationEffect(.degrees(!reduceMotion && isRotating ? 360 : 0)) + .animation(animation, value: isRotating) + .onAppear { + guard !reduceMotion else { return } + isRotating = true + } + .onDisappear { + isRotating = false + } + } + + private var animation: Animation? { + reduceMotion ? nil : .linear(duration: 1).repeatForever(autoreverses: false) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineView.swift new file mode 100644 index 00000000..b230a077 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/OperationTimelineView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct OperationTimelineListView: View { + let title: String? + let emptyMessage: String? + let items: [OperationTimelineItem] + let showsRowBackground: Bool + + init( + title: String? = nil, + emptyMessage: String? = nil, + items: [OperationTimelineItem], + showsRowBackground: Bool = true + ) { + self.title = title + self.emptyMessage = emptyMessage + self.items = items + self.showsRowBackground = showsRowBackground + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let title { + Text(title) + .font(.headline) + } + + if items.isEmpty { + if let emptyMessage { + Text(emptyMessage) + .font(.caption) + .foregroundStyle(.secondary) + .transition(.opacity) + } + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(items) { item in + OperationTimelineRow(item: item, showsBackground: showsRowBackground) + .transition(rowTransition) + } + } + } + } + .animation(.snappy(duration: 0.22), value: items) + } + + private var rowTransition: AnyTransition { + .opacity.combined(with: .move(edge: .top)) + } +} + +struct OperationTimelineRow: View { + let item: OperationTimelineItem + let showsBackground: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(item: OperationTimelineItem, showsBackground: Bool = true) { + self.item = item + self.showsBackground = showsBackground + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + OperationTimelineStateIcon(state: item.state) + .frame(width: 22, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + AnimatedProgressText(message: item.title, isRunning: item.state == .running && !hasDetail) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + if let detail = item.detail, !detail.isEmpty { + AnimatedProgressText(message: detail, isRunning: item.state == .running) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 0) + } + .padding(.vertical, 5) + .padding(.horizontal, showsBackground ? 6 : 0) + .background(background) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .contentShape(Rectangle()) + .animation(reduceMotion ? nil : .snappy(duration: 0.18), value: item.state) + .accessibilityElement(children: .combine) + } + + private var hasDetail: Bool { + item.detail?.isEmpty == false + } + + @ViewBuilder + private var background: some View { + if showsBackground && item.state == .running { + OperationTimelineVisualStyle.color(for: item.state).opacity(0.10) + } else if showsBackground && item.state == .failed { + OperationTimelineVisualStyle.color(for: item.state).opacity(0.08) + } else { + Color.clear + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift new file mode 100644 index 00000000..d794065c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/SharedViews.swift @@ -0,0 +1,94 @@ +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(.vertical, 10) + .padding(.leading, 14) + .padding(.trailing, 18) + .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) + } + } + } +} + +struct DashboardDisclosureSection: View { + let title: String + @ViewBuilder let content: () -> Content + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .frame(width: 12) + Text(title) + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + content() + .padding(.top, 8) + .padding(.leading, 18) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ToolbarIconButton.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ToolbarIconButton.swift new file mode 100644 index 00000000..f2eac622 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Components/ToolbarIconButton.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct ToolbarIconButton: View { + let title: String + let systemImage: String + var disabled = false + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button { + action() + } label: { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .medium)) + .frame(width: 28, height: 28) + .background { + Circle() + .fill(isHovered && !disabled ? Color.primary.opacity(0.10) : Color.clear) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .help(title) + .accessibilityLabel(title) + .onHover { hovering in + isHovered = hovering + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift new file mode 100644 index 00000000..57c67525 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/CheckupTab.swift @@ -0,0 +1,167 @@ +import SwiftUI + +struct CheckupTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + @ObservedObject var operationCoordinator: OperationCoordinator + let appSettings: AppSettings + let showDiagnostics: () -> Void + let diagnosticsText: () -> String + + var body: some View { + let store = session.doctorStore + let presentation = CheckupPresentation( + summary: store.summary, + state: store.state, + events: store.events, + currentStage: store.currentStage, + hostWarning: HostCompatibilityPolicy.warning(enabled: appSettings.timeMachineWarningsEnabled) + ) + let progress = CheckupProgressPresentation(state: store.state, currentStage: store.currentStage) + let isDeviceBusy = operationCoordinator.isDeviceBusy(profile) + + ZStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + CheckupHeaderView(presentation: presentation) + + if let warning = presentation.hostWarning { + WarningBanner(warning: warning) + } + + if let action = presentation.primaryAction { + Button { + session.performCheckupAction(action, profile: profile, showDiagnostics: showDiagnostics) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + .disabled(isDeviceBusy) + } + + if !presentation.timeline.isEmpty { + OperationTimelineListView( + title: L10n.string("checkup.timeline.title"), + items: presentation.timeline + ) + } + + if !presentation.summaryRows.isEmpty { + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + } + + ForEach(presentation.domains) { domain in + CheckupDomainView(domain: domain) + } + + CheckupAdvancedOptionsView(store: store) + + if let error = store.error { + ErrorRecoveryView(error: error, diagnosticsText: diagnosticsText) { action in + handleRecovery(action: action, error: error) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let progress { + BlockingProgressOverlay(progress: progress, allowsBackgroundInteraction: true) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = session.handleRecoveryAction(action, error: error, profile: profile) + } +} + +private struct CheckupHeaderView: View { + let presentation: CheckupPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(presentation.title) + .font(.title2.weight(.semibold)) + Spacer() + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } + Text(presentation.headline) + .font(.callout) + .foregroundStyle(.secondary) + } + } +} + +private struct CheckupDomainView: View { + let domain: CheckupDomainPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + HStack(spacing: 6) { + Image(systemName: domain.status.systemImage) + .foregroundStyle(iconColor(for: domain.status)) + .accessibilityLabel(domain.status.title) + Text(domain.title) + } + .font(.headline) + Spacer() + Text(domain.countSummary) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(domain.rows) { row in + HStack(alignment: .top, spacing: 8) { + Image(systemName: row.status.systemImage) + .foregroundStyle(iconColor(for: row.status)) + .accessibilityLabel(row.status.title) + .frame(width: 16) + Text(row.statusText) + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(row.message) + .font(.caption) + } + } + } + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private func iconColor(for status: CheckupStatusPresentation) -> Color { + status == .passed ? .green : .primary + } +} + +private struct CheckupAdvancedOptionsView: View { + @ObservedObject var store: DoctorStore + + var body: some View { + DashboardDisclosureSection(title: L10n.string("checkup.advanced_options")) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Toggle(L10n.string("checkup.option.skip_ssh"), isOn: $store.skipSSH) + Toggle(L10n.string("checkup.option.skip_bonjour"), isOn: $store.skipBonjour) + } + GridRow { + Toggle(L10n.string("checkup.option.skip_smb"), isOn: $store.skipSMB) + EmptyView() + } + } + } + .disabled(store.isRunning) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift new file mode 100644 index 00000000..c2e89432 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/DeviceDashboardView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct DeviceDashboardView: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + let appStore: AppStore + @ObservedObject var appSettingsStore: AppSettingsStore + @ObservedObject var reachabilityStore: DeviceReachabilityStore + @ObservedObject var operationCoordinator: OperationCoordinator + @ObservedObject var backend: BackendClient + let showDiagnostics: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Picker("", selection: $session.selectedTab) { + ForEach(DeviceDashboardTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + + Divider() + + Group { + switch session.selectedTab { + case .overview: + OverviewTab(profile: profile, session: session, reachabilityStore: reachabilityStore) + case .install: + InstallTab( + profile: profile, + session: session, + operationCoordinator: operationCoordinator, + appSettings: appSettingsStore.settings, + showDiagnostics: showDiagnostics, + diagnosticsText: diagnosticsText + ) + case .checkup: + CheckupTab( + profile: profile, + session: session, + operationCoordinator: operationCoordinator, + appSettings: appSettingsStore.settings, + showDiagnostics: showDiagnostics, + diagnosticsText: diagnosticsText + ) + case .maintenance: + MaintenanceTab( + profile: profile, + session: session, + showDiagnostics: showDiagnostics, + diagnosticsText: diagnosticsText + ) + case .settings: + ScrollView { + SettingsTab( + profile: profile, + session: session, + appStore: appStore, + backend: backend + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .alert( + session.flashStore.manualPowerCycleNotice?.title ?? "", + isPresented: manualPowerCycleNoticePresented, + presenting: session.flashStore.manualPowerCycleNotice + ) { notice in + Button(notice.viewCheckupActionTitle) { + session.viewCheckupAfterFlashNotice() + } + Button(notice.actionTitle, role: .cancel) { + session.flashStore.dismissManualPowerCycleNotice() + } + } message: { notice in + Text(notice.message) + } + } + + private var manualPowerCycleNoticePresented: Binding { + Binding( + get: { session.flashStore.manualPowerCycleNotice != nil }, + set: { isPresented in + if !isPresented { + session.flashStore.dismissManualPowerCycleNotice() + } + } + ) + } + + private func diagnosticsText() -> String { + DiagnosticsExportBuilder().build(context: appStore.diagnosticsExportContext(includeBackendEvents: true)) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift new file mode 100644 index 00000000..b2bc7162 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/FlashBootHookView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct FlashBootHookSection: View { + let profile: DeviceProfile + @ObservedObject var store: FlashWorkflowStore + let performAction: (FlashUserAction) -> Void + let chooseFirmwareTemplate: () -> Void + + var body: some View { + let presentation = FlashPresentation(store: store) + VStack(alignment: .leading, spacing: 12) { + Divider() + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.headline) + AnimatedProgressText(message: presentation.message, isRunning: store.isRunning) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } + + if !presentation.warnings.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + + DisclosureGroup { + FlashFirmwareOptionsView(store: store, chooseFirmwareTemplate: chooseFirmwareTemplate) + } label: { + Label(L10n.string("flash.options.apple_firmware"), systemImage: "gearshape") + .font(.subheadline.weight(.medium)) + } + + HStack { + ForEach(presentation.primaryActions) { action in + Button { + performAction(action) + } label: { + Label(presentation.title(for: action), systemImage: action.systemImage) + } + .disabled(!presentation.isEnabled(action)) + } + } + + HStack { + ForEach(presentation.secondaryActions) { action in + Button { + performAction(action) + } label: { + Label(presentation.title(for: action), systemImage: action.systemImage) + } + .disabled(!presentation.isEnabled(action)) + } + } + + if !presentation.rows.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(presentation.rows) { row in + HStack(alignment: .firstTextBaseline) { + Text(row.label) + .foregroundStyle(.secondary) + Spacer() + Text(row.value) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } + .font(.caption) + } + } + } + + if let timeline = FlashTimelinePresentation(events: store.events, currentStage: store.currentStage), + !timeline.items.isEmpty { + MaintenanceTimelineView(presentation: MaintenanceTimelinePresentation(items: timeline.items)) + } + } + .onAppear { + store.refresh(profile: profile) + } + .onChange(of: profile.id) { _, _ in + store.refresh(profile: profile) + } + } +} + +private struct FlashFirmwareOptionsView: View { + @ObservedObject var store: FlashWorkflowStore + let chooseFirmwareTemplate: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.string("field.firmware_version"), text: $store.firmwareVersion) + HStack { + TextField(L10n.string("field.firmware_template"), text: $store.firmwareTemplatePath) + Button { + chooseFirmwareTemplate() + } label: { + Label(L10n.string("flash.action.choose_template"), systemImage: "doc") + } + } + } + .textFieldStyle(.roundedBorder) + } +} + +private struct FlashTimelinePresentation: Equatable { + let items: [OperationTimelineItem] + + init?(events: [BackendEvent], currentStage: OperationStageState?) { + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == "flash" } + if items.isEmpty, let currentStage, currentStage.operation == "flash" { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: OperationTimelineBuilder.stageTitle(for: currentStage.operation, stage: currentStage.stage), + detail: OperationTimelineBuilder.stageDetail( + for: currentStage.operation, + stage: currentStage.stage, + fallback: nil + ), + state: .running, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + guard !items.isEmpty else { + return nil + } + self.items = items + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift new file mode 100644 index 00000000..7a9a8fca --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/InstallTab.swift @@ -0,0 +1,261 @@ +import SwiftUI + +struct InstallTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + @ObservedObject var operationCoordinator: OperationCoordinator + let appSettings: AppSettings + let showDiagnostics: () -> Void + let diagnosticsText: () -> String + + var body: some View { + let store = session.deployStore + let summary = session.summary(for: profile) + let presentation = InstallWorkflowPresentation( + state: store.state, + plan: store.plan, + result: store.result, + error: store.error, + events: store.events, + currentStage: store.currentStage, + plannedOptions: store.plannedOptions, + profile: profile, + hostWarning: HostCompatibilityPolicy.warning(enabled: appSettings.timeMachineWarningsEnabled), + isCheckupRunning: summary.displayStatus == .checking + ) + let progress = InstallProgressPresentation(state: store.state, currentStage: store.currentStage) + let isDeviceBusy = operationCoordinator.isDeviceBusy(profile) + + ZStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + InstallHeaderView(presentation: presentation) + + ForEach(presentation.notices, id: \.self) { notice in + Label(notice, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + + if !presentation.actions.isEmpty { + HStack { + ForEach(presentation.actions) { action in + InstallActionButton(action: action) { + session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + } + .disabled(isDisabled(action, store: store, isDeviceBusy: isDeviceBusy)) + } + } + } + + if let timeline = presentation.timeline { + InstallTimelineView(presentation: timeline) + } + + if let error = presentation.error { + ErrorRecoveryView( + error: error, + guidance: presentation.failureGuidance, + diagnosticsText: diagnosticsText + ) { action in + handleRecovery(action: action, error: error) + } + } + + if let plan = presentation.plan { + InstallPlanView(presentation: plan) + } + + if let completion = presentation.completion { + InstallCompletionView( + presentation: completion, + isDisabled: { isDisabled($0, store: store, isDeviceBusy: isDeviceBusy) } + ) { action in + session.performInstallAction(action, profile: profile, showDiagnostics: showDiagnostics) + } + } + + InstallExecutionOptionsView(store: store, isDeviceBusy: isDeviceBusy) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let progress { + BlockingProgressOverlay(progress: progress, allowsBackgroundInteraction: true) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = session.handleRecoveryAction(action, error: error, profile: profile) + } + + private func isDisabled(_ action: InstallUserAction, store: DeployWorkflowStore, isDeviceBusy: Bool) -> Bool { + !InstallActionAvailabilityPolicy.isEnabled(action, store: store, isDeviceBusy: isDeviceBusy) + } +} + +private struct InstallHeaderView: View { + let presentation: InstallWorkflowPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(presentation.title) + .font(.title2.weight(.semibold)) + Spacer() + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } + Text(presentation.statusMessage) + .font(.callout) + .foregroundStyle(.secondary) + } + } +} + +private struct InstallActionButton: View { + let action: InstallUserAction + let perform: () -> Void + + var body: some View { + if action == .installUpdate { + Button(action: perform) { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + } else { + Button(action: perform) { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.bordered) + } + } +} + +private struct InstallPlanView: View { + let presentation: InstallPlanPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(presentation.title) + .font(.headline) + + ForEach(presentation.sections) { section in + VStack(alignment: .leading, spacing: 6) { + Text(section.title) + .font(.subheadline.weight(.medium)) + SummaryGrid(rows: section.rows.map { ($0.label, $0.value) }) + } + } + + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + } + } +} + +private struct InstallTimelineView: View { + let presentation: InstallTimelinePresentation + + var body: some View { + OperationTimelineListView( + title: L10n.string("install.timeline.title"), + emptyMessage: L10n.string("install.timeline.waiting"), + items: presentation.items + ) + } +} + +private struct InstallCompletionView: View { + let presentation: InstallCompletionPresentation + let isDisabled: (InstallUserAction) -> Bool + let performAction: (InstallUserAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + HStack { + ForEach(presentation.actions) { action in + Button { + performAction(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .disabled(isDisabled(action)) + } + } + } + } +} + +private struct InstallExecutionOptionsView: View { + @ObservedObject var store: DeployWorkflowStore + let isDeviceBusy: Bool + + var body: some View { + DashboardDisclosureSection(title: L10n.string("install.advanced_options")) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + .disabled(!allowsNoReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: noWaitBinding) + .disabled(!allowsNoWait) + } + GridRow { + Text(L10n.string("install.advanced_options.no_wait_note")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .gridCellColumns(2) + } + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + } + } + } + .disabled(store.isBusy || isDeviceBusy) + } + + private var allowsNoReboot: Bool { + DeployExecutionOptionPolicy.allowsNoReboot(noWait: store.noWait) + } + + private var allowsNoWait: Bool { + DeployExecutionOptionPolicy.allowsNoWait(noReboot: store.noReboot) + } + + private var noWaitBinding: Binding { + Binding { + allowsNoWait ? store.noWait : false + } set: { value in + if allowsNoWait { + store.noWait = value + } else { + store.noWait = false + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift new file mode 100644 index 00000000..47ede7ce --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/MaintenanceTab.swift @@ -0,0 +1,372 @@ +import AppKit +import SwiftUI + +struct MaintenanceTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + let showDiagnostics: () -> Void + let diagnosticsText: () -> String + + var body: some View { + let store = session.maintenanceStore + let presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(L10n.string("dashboard.tab.maintenance")) + .font(.title2.weight(.semibold)) + + MaintenanceWorkflowCardsView(cards: presentation.cards) { workflow in + session.maintenanceStore.selectedWorkflow = workflow + } + + MaintenanceDetailView( + presentation: presentation.detail, + store: store, + performAction: { action in + session.performMaintenanceAction(action, profile: profile, showDiagnostics: showDiagnostics) + }, + chooseRepairPath: { + chooseRepairPath(store: store) + } + ) + + if FlashBootHookVisibilityPolicy.isVisible(for: profile) { + FlashBootHookSection( + profile: profile, + store: session.flashStore, + performAction: { action in + session.performFlashAction(action, profile: profile) + }, + chooseFirmwareTemplate: { + chooseFirmwareTemplate(store: session.flashStore) + } + ) + } + + if let error = store.error(for: presentation.detail.workflow) { + ErrorRecoveryView(error: error, diagnosticsText: diagnosticsText) { action in + handleRecovery(action: action, error: error) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = session.handleRecoveryAction(action, error: error, profile: profile) + } + + private func chooseRepairPath(store: MaintenanceStore) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = L10n.string("maintenance.action.choose") + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + Task { @MainActor in + store.repairPath = url.path + } + } + } + + private func chooseFirmwareTemplate(store: FlashWorkflowStore) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.prompt = L10n.string("maintenance.action.choose") + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + Task { @MainActor in + store.firmwareTemplatePath = url.path + } + } + } +} + +private struct MaintenanceWorkflowCardsView: View { + let cards: [MaintenanceWorkflowCardPresentation] + let select: (MaintenanceWorkflow) -> Void + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 190), spacing: 10)], alignment: .leading, spacing: 10) { + ForEach(cards) { card in + Button { + select(card.workflow) + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(card.title) + .font(.headline) + Spacer() + Image(systemName: card.isSelected ? "checkmark.circle.fill" : "circle") + } + Text(card.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + Text(card.stateTitle) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading) + .padding(10) + .background(card.isSelected ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + } + } +} + +private struct MaintenanceDetailView: View { + let presentation: MaintenanceWorkflowDetailPresentation + @ObservedObject var store: MaintenanceStore + let performAction: (MaintenanceUserAction) -> Void + let chooseRepairPath: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.headline) + Text(presentation.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(presentation.stateTitle) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } + + Label(presentation.risk, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + Text(presentation.statusMessage) + .font(.callout) + .foregroundStyle(.secondary) + + if presentation.workflow == .repairXattrs { + RepairPathPicker(store: store, chooseRepairPath: chooseRepairPath) + } + + if presentation.workflow == .fsck { + FsckTargetListView(store: store) + } + + HStack { + ForEach(presentation.actions) { action in + MaintenanceActionButton( + action: action, + isEnabled: presentation.isEnabled(action), + perform: performAction + ) + } + } + + if let timeline = presentation.timeline, !timeline.items.isEmpty { + MaintenanceTimelineView(presentation: timeline) + } + + if let plan = presentation.plan { + MaintenancePlanView(presentation: plan) + } + + if let completion = presentation.completion { + MaintenanceCompletionView(presentation: completion) + } + + MaintenanceAdvancedOptionsView(workflow: presentation.workflow, store: store) + } + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + +} + +private struct MaintenanceActionButton: View { + let action: MaintenanceUserAction + let isEnabled: Bool + let perform: (MaintenanceUserAction) -> Void + + var body: some View { + if action.isCommitAction { + Button { + perform(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + .disabled(!isEnabled) + } else { + Button { + perform(action) + } label: { + Label(action.title, systemImage: action.systemImage) + } + .buttonStyle(.bordered) + .disabled(!isEnabled) + } + } +} + +private struct RepairPathPicker: View { + @ObservedObject var store: MaintenanceStore + let chooseRepairPath: () -> Void + + var body: some View { + HStack { + TextField(L10n.string("field.repair_xattrs_path"), text: $store.repairPath) + Button { + chooseRepairPath() + } label: { + Label(L10n.string("maintenance.action.choose_folder"), systemImage: "folder") + } + } + } +} + +private struct FsckTargetListView: View { + @ObservedObject var store: MaintenanceStore + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if store.fsckTargets.isEmpty { + Text(L10n.string("maintenance.fsck.no_volumes")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + 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) + } + } + } + } +} + +private struct MaintenancePlanView: View { + let presentation: MaintenancePlanPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + } + } +} + +private struct MaintenanceCompletionView: View { + let presentation: MaintenanceCompletionPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + } + } +} + +struct MaintenanceTimelineView: View { + let presentation: MaintenanceTimelinePresentation + + var body: some View { + OperationTimelineListView( + title: L10n.string("maintenance.timeline.title"), + items: presentation.items + ) + } +} + +private struct MaintenanceAdvancedOptionsView: View { + let workflow: MaintenanceWorkflow + @ObservedObject var store: MaintenanceStore + + var body: some View { + DashboardDisclosureSection(title: L10n.string("maintenance.advanced_options")) { + if workflow == .repairXattrs { + RepairXattrsAdvancedOptionsView(store: store) + } else { + RemoteMaintenanceAdvancedOptionsView(store: store) + } + } + } +} + +private struct RemoteMaintenanceAdvancedOptionsView: View { + @ObservedObject var store: MaintenanceStore + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + } + GridRow { + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + } + } + } +} + +private struct RepairXattrsAdvancedOptionsView: View { + @ObservedObject var store: MaintenanceStore + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Toggle(L10n.string("toggle.repair_xattrs_recursive"), isOn: $store.repairRecursive) + Toggle(L10n.string("toggle.repair_xattrs_include_hidden"), isOn: $store.repairIncludeHidden) + } + GridRow { + Toggle(L10n.string("toggle.repair_xattrs_include_time_machine"), isOn: $store.repairIncludeTimeMachine) + Toggle(L10n.string("toggle.repair_xattrs_fix_permissions"), isOn: $store.repairFixPermissions) + } + GridRow { + Toggle(L10n.string("toggle.repair_xattrs_verbose"), isOn: $store.repairVerbose) + HStack { + Text(L10n.string("field.repair_xattrs_max_depth")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.repair_xattrs_max_depth"), text: $store.repairMaxDepth) + .frame(width: 80) + } + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift new file mode 100644 index 00000000..38a24997 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/OverviewTab.swift @@ -0,0 +1,266 @@ +import SwiftUI + +private enum OverviewLayout { + static let actionIconSize: CGFloat = 16 + static let healthRowMinHeight: CGFloat = 64 + static let healthStatusIconSize: CGFloat = 30 + static let healthStatusSymbolSize: CGFloat = 18 + static let healthActionSlotMinWidth: CGFloat = 144 +} + +struct OverviewTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + @ObservedObject var reachabilityStore: DeviceReachabilityStore + + var body: some View { + let summary = session.summary(for: profile) + let presentation = DeviceDashboardOverviewPresentation( + summary: summary, + currentCheckupSummary: session.doctorStore.summary, + reachabilitySnapshot: reachabilityStore.snapshot(for: profile), + isReachabilityRunning: reachabilityStore.isRunning(profile: profile) + ) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let warning = presentation.hostWarning { + WarningBanner(warning: warning) + } + + DashboardHeaderView(presentation: presentation.header) + + DashboardPrimaryActionStrip( + primaryAction: presentation.primaryAction, + isPrimaryActionEnabled: presentation.isPrimaryActionEnabled, + secondaryActions: presentation.secondaryActions, + isSecondaryActionEnabled: presentation.isEnabled, + performPrimary: { + session.performPrimaryAction(presentation.primaryAction, profile: profile) + }, + performSecondary: { action in + session.performSecondaryAction(action, profile: profile) + } + ) + + VStack(alignment: .leading, spacing: 10) { + ForEach(presentation.healthSections) { section in + DashboardHealthSectionView(section: section, isActionEnabled: presentation.isEnabled) { action in + session.performSecondaryAction(action, profile: profile) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct DashboardHeaderView: View { + let presentation: DeviceDashboardHeaderPresentation + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 3) { + Text(presentation.title) + .font(.title2.weight(.semibold)) + Text(presentation.connectionTarget) + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + StatusBadge(status: presentation.status) + } + + Label(presentation.lastChecked, systemImage: "clock") + .font(.caption) + .foregroundStyle(.secondary) + + SummaryGrid(rows: presentation.rows.map { ($0.label, $0.value) }) + } + } +} + +private struct StatusBadge: View { + let status: DeviceDisplayStatus + + var body: some View { + Label { + Text(status.title) + } icon: { + Image(systemName: status.systemImage) + .frame(width: OverviewLayout.actionIconSize, height: OverviewLayout.actionIconSize) + } + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.quaternary) + .clipShape(Capsule()) + } +} + +private struct DashboardPrimaryActionStrip: View { + let primaryAction: DashboardPrimaryAction + let isPrimaryActionEnabled: Bool + let secondaryActions: [DashboardSecondaryAction] + let isSecondaryActionEnabled: (DashboardSecondaryAction) -> Bool + let performPrimary: () -> Void + let performSecondary: (DashboardSecondaryAction) -> Void + + var body: some View { + HStack(spacing: 8) { + DashboardPrimaryActionButton(action: primaryAction, perform: performPrimary) + .disabled(!isPrimaryActionEnabled) + + ForEach(secondaryActions) { action in + Button { + performSecondary(action) + } label: { + DashboardActionLabel(title: action.title, systemImage: action.systemImage) + } + .disabled(!isSecondaryActionEnabled(action)) + } + } + } +} + +private struct DashboardPrimaryActionButton: View { + let action: DashboardPrimaryAction + let perform: () -> Void + + var body: some View { + Button(action: perform) { + DashboardActionLabel(title: action.title, systemImage: action.systemImage) + } + .buttonStyle(.borderedProminent) + } +} + +private struct DashboardActionLabel: View { + let title: String + let systemImage: String + + var body: some View { + Label { + Text(title) + .lineLimit(1) + } icon: { + Image(systemName: systemImage) + .frame(width: OverviewLayout.actionIconSize, height: OverviewLayout.actionIconSize) + } + } +} + +private struct DashboardHealthSectionView: View { + let section: DashboardHealthSection + let isActionEnabled: (DashboardSecondaryAction) -> Bool + let performAction: (DashboardSecondaryAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(section.title) + .font(.headline) + ForEach(section.rows) { row in + HStack(alignment: .top, spacing: 10) { + DashboardHealthStatusIcon(status: row.status) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(row.title) + .font(.body.weight(.medium)) + Spacer() + DashboardHealthActionSlot( + action: row.action, + isActionEnabled: isActionEnabled, + performAction: performAction + ) + } + AnimatedProgressText(message: row.detail, isRunning: row.status == .running) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .frame(maxWidth: .infinity, minHeight: OverviewLayout.healthRowMinHeight, alignment: .topLeading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } +} + +private struct DashboardHealthActionSlot: View { + let action: DashboardSecondaryAction? + let isActionEnabled: (DashboardSecondaryAction) -> Bool + let performAction: (DashboardSecondaryAction) -> Void + + var body: some View { + Group { + if let action { + Button { + performAction(action) + } label: { + DashboardActionLabel(title: action.title, systemImage: action.systemImage) + } + .controlSize(.small) + .disabled(!isActionEnabled(action)) + } else { + // Reserve real button metrics so rows without actions align with rows that have action buttons. + Button {} label: { + DashboardActionLabel( + title: DashboardSecondaryAction.runCheckup.title, + systemImage: DashboardSecondaryAction.runCheckup.systemImage + ) + } + .controlSize(.small) + .hidden() + .accessibilityHidden(true) + .allowsHitTesting(false) + } + } + .frame( + minWidth: OverviewLayout.healthActionSlotMinWidth, + alignment: .trailing + ) + } +} + +private struct DashboardHealthStatusIcon: View { + let status: DashboardHealthStatus + + var body: some View { + ZStack { + Circle() + .fill(statusColor.opacity(status == .unknown ? 0.10 : 0.14)) + icon + .foregroundStyle(statusColor) + } + .frame(width: OverviewLayout.healthStatusIconSize, height: OverviewLayout.healthStatusIconSize) + .accessibilityLabel(status.title) + } + + @ViewBuilder + private var icon: some View { + if status == .running { + OperationTimelineStateIcon(state: .running) + } else { + Image(systemName: status.systemImage) + .font(.system(size: OverviewLayout.healthStatusSymbolSize, weight: .semibold)) + } + } + + private var statusColor: Color { + switch status { + case .unknown: + return .secondary + case .good: + return .green + case .warning: + return .orange + case .failed: + return .red + case .running: + return .accentColor + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift new file mode 100644 index 00000000..9f6b6438 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Dashboard/SettingsTab.swift @@ -0,0 +1,160 @@ +import SwiftUI + +struct SettingsTab: View { + let profile: DeviceProfile + @ObservedObject var session: DeviceDashboardSession + let appStore: AppStore + @ObservedObject var backend: BackendClient + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("dashboard.tab.settings")) + .font(.title2.weight(.semibold)) + DeviceProfileEditorView( + profile: profile, + store: session.profileEditorStore, + diagnosticsText: { + DiagnosticsExportBuilder().build(context: appStore.diagnosticsExportContext(includeBackendEvents: true)) + } + ) + SummaryGrid(rows: [ + (L10n.string("advanced.profile_id"), profile.id), + (L10n.string("advanced.config"), profile.configPath), + (L10n.string("advanced.helper"), backend.helperPath.isEmpty ? L10n.string("value.auto") : backend.helperPath) + ]) + EventList(events: session.events) + } + } +} + +private struct DeviceProfileEditorView: View { + let profile: DeviceProfile + @ObservedObject var store: DeviceProfileEditorStore + let diagnosticsText: () -> String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.string("profile_editor.title")) + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("profile_editor.display_name")) + .foregroundStyle(.secondary) + TextField(L10n.string("profile_editor.display_name"), text: $store.draft.displayName) + .frame(maxWidth: 360) + } + GridRow { + Text(L10n.string("dashboard.overview.host")) + .foregroundStyle(.secondary) + TextField(L10n.string("dashboard.overview.host"), text: $store.draft.host) + .frame(maxWidth: 360) + } + GridRow { + Text(L10n.string("dashboard.password.title")) + .foregroundStyle(.secondary) + SecureField(L10n.string("dashboard.replacement_password"), text: $store.replacementPassword) + .frame(maxWidth: 360) + .onSubmit { + guard store.canSave else { return } + Task { @MainActor in + await store.save(profile: profile) + } + } + } + } + + if let passwordError = store.passwordError { + Text(passwordError) + .font(.caption) + .foregroundStyle(.red) + } + + DeviceProfileAdvancedSettingsView(store: store) + + HStack { + Button { + Task { @MainActor in + await store.save(profile: profile) + } + } label: { + Label(L10n.string("profile_editor.save"), systemImage: "square.and.arrow.down") + } + .disabled(!store.canSave) + + Button { + store.reset(to: profile) + } label: { + Label(L10n.string("profile_editor.reset"), systemImage: "arrow.counterclockwise") + } + .disabled(store.isRunning) + + Label(store.state.title, systemImage: "circle") + .foregroundStyle(.secondary) + } + + ForEach(store.validationErrors, id: \.self) { validationError in + Text(validationError.localizedDescription) + .font(.caption) + .foregroundStyle(.red) + } + + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let error = store.error { + ErrorRecoveryView(error: error, diagnosticsText: diagnosticsText) { _ in } + } + } + .onAppear { + store.sync(to: profile) + } + .onChange(of: profile) { _, profile in + store.sync(to: profile) + } + .padding(.bottom, 8) + } +} + +private struct DeviceProfileAdvancedSettingsView: View { + @ObservedObject var store: DeviceProfileEditorStore + + var body: some View { + DashboardDisclosureSection(title: L10n.string("profile_editor.advanced")) { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.string("profile_editor.advanced.deploy_notice")) + .font(.caption) + .foregroundStyle(.secondary) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text(L10n.string("field.mount_wait")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.mount_wait"), text: $store.draft.mountWaitSeconds) + .frame(width: 160) + } + GridRow { + Text(L10n.string("field.ata_idle_seconds")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.ata_idle_seconds"), text: $store.draft.ataIdleSeconds) + .frame(width: 160) + } + GridRow { + Text(L10n.string("field.ata_standby")) + .foregroundStyle(.secondary) + TextField(L10n.string("field.ata_standby"), text: $store.draft.ataStandby) + .frame(width: 160) + } + GridRow { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.draft.nbnsEnabled) + Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $store.draft.internalShareUseDiskRoot) + } + GridRow { + Toggle(L10n.string("toggle.any_protocol"), isOn: $store.draft.anyProtocol) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.draft.debugLogging) + } + } + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift new file mode 100644 index 00000000..3a5d2a88 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Diagnostics/AppReadinessViews.swift @@ -0,0 +1,243 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct AppReadinessBannerView: View { + @ObservedObject var store: AppReadinessStore + let showDiagnostics: () -> Void + + var body: some View { + switch store.state { + case .idle, .ready: + EmptyView() + case .resolvingBundle, .checkingVersion, .checkingCapabilities, .validatingInstall: + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(title) + .font(.caption) + if let stage = currentStageText { + 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 .checkingVersion: + return L10n.string("readiness.state.checking_version") + case .checkingCapabilities: + return L10n.string("readiness.state.checking_capabilities") + case .validatingInstall: + return L10n.string("readiness.state.validating_install") + default: + return "" + } + } + + private var currentStageText: String? { + store.currentStage.map { + OperationTimelineBuilder.stageDetail(for: $0.operation, stage: $0.stage, fallback: nil) + ?? OperationTimelineBuilder.stageTitle(for: $0.operation, stage: $0.stage) + } + } +} + +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) + } +} + +struct AppDiagnosticsView: View { + @ObservedObject var store: AppReadinessStore + let exportContext: (_ includeBackendEvents: Bool) -> DiagnosticsExportContext + @Binding var showBackendEvents: Bool + @Binding var helperPath: String + @Environment(\.dismiss) private var dismiss + @State private var exportStatus: String? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(L10n.string("diagnostics.title")) + .font(.title2.weight(.semibold)) + Spacer() + if let exportStatus { + Text(exportStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + Button { + copyDiagnostics() + } label: { + Label(L10n.string("diagnostics.copy"), systemImage: "doc.on.doc") + } + Button { + saveDiagnostics() + } label: { + Label(L10n.string("diagnostics.save"), systemImage: "square.and.arrow.down") + } + 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.localizedSummary) + } + } + } + .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) + } + } + } + + Toggle(L10n.string("diagnostics.backend_events"), isOn: $showBackendEvents) + .font(.headline) + if showBackendEvents { + EventList(events: exportContext(true).events) + } + } + .padding() + .frame(minWidth: 720, minHeight: 520) + } + + private func copyDiagnostics() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(exportText(), forType: .string) + exportStatus = L10n.string("diagnostics.copied") + } + + private func saveDiagnostics() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "TimeCapsuleSMB-Diagnostics.txt" + panel.allowedContentTypes = [.plainText] + let text = exportText() + panel.begin { response in + guard response == .OK, let url = panel.url else { + return + } + do { + try text.write(to: url, atomically: true, encoding: .utf8) + Task { @MainActor in + exportStatus = L10n.string("diagnostics.saved") + } + } catch { + Task { @MainActor in + exportStatus = error.localizedDescription + } + } + } + } + + private func exportText() -> String { + DiagnosticsExportBuilder().build(context: exportContext(showBackendEvents)) + } +} + +struct EventList: View { + let events: [BackendEvent] + + var body: some View { + List(events) { event in + VStack(alignment: .leading, spacing: 4) { + Text(event.localizedSummary) + .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/Views/Shell/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift new file mode 100644 index 00000000..2547f08c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ActivityView.swift @@ -0,0 +1,222 @@ +import SwiftUI + +struct ActivityCompactView: View { + @ObservedObject var activityStore: ActivityStore + @ObservedObject var registry: DeviceRegistryStore + let context: ActivityDisplayContext + + var body: some View { + let status = activityStore.compactStatus(for: context) + HStack(spacing: 10) { + Image(systemName: icon(for: status)) + .foregroundStyle(iconColor(for: status)) + messageView(status) + Spacer() + if let latestTimelineTitle = status.latestTimelineTitle { + Text(latestTimelineTitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.06)) + } + + @ViewBuilder + private func messageView(_ status: ActivityCompactStatus) -> some View { + if let latestMessage = status.latestMessage, !latestMessage.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text(title(status)) + .font(.caption.weight(.medium)) + .lineLimit(1) + AnimatedProgressText(message: latestMessage, isRunning: status.isRunning) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(height: 30, alignment: .center) + } else { + Text(title(status)) + .font(.caption.weight(.medium)) + .lineLimit(1) + .frame(height: 30, alignment: .center) + } + } + + private func title(_ status: ActivityCompactStatus) -> String { + if case .device(let activeDeviceID) = status.scope, + let profile = registry.profile(id: activeDeviceID) { + return "\(status.operationTitle) - \(profile.title)" + } + return status.operationTitle + } + + private func icon(for status: ActivityCompactStatus) -> String { + if status.requiresAttention { + return "exclamationmark.triangle" + } + return status.isRunning ? "hourglass" : "checkmark.circle" + } + + private func iconColor(for status: ActivityCompactStatus) -> Color { + if status.requiresAttention { + return .yellow + } + return status.isRunning ? Color.accentColor : Color.secondary + } +} + +struct ActivityDetailView: View { + @ObservedObject var activityStore: ActivityStore + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: activityStore.hasActiveActivity ? "hourglass" : "clock") + .font(.title2) + .foregroundStyle(activityStore.hasActiveActivity ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 4) { + Text(L10n.string("sidebar.activity")) + .font(.title2.weight(.semibold)) + Text(activeActivityMessage) + .foregroundStyle(.secondary) + } + Spacer() + } + + if activityStore.activeLaneSnapshots.isEmpty && activityStore.recentLaneSnapshots.isEmpty { + Text(L10n.string("activity.timeline.empty")) + .foregroundStyle(.secondary) + } else { + if !activityStore.activeLaneSnapshots.isEmpty { + ActivityLaneSection( + title: L10n.string("activity.active"), + snapshots: activityStore.activeLaneSnapshots, + registry: registry + ) + } + if !activityStore.recentLaneSnapshots.isEmpty { + ActivityLaneSection( + title: L10n.string("activity.recent"), + snapshots: activityStore.recentLaneSnapshots, + registry: registry + ) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + 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 + } + + private var activeActivityMessage: String { + guard activityStore.hasActiveActivity else { + return L10n.string("activity.no_active_operation") + } + let count = activityStore.activeLaneSnapshots.count + return count == 1 + ? L10n.string("activity.one_active") + : L10n.format("activity.multiple_active", count) + } +} + +private struct ActivityLaneSection: View { + let title: String + let snapshots: [ActivityLaneSnapshot] + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + ForEach(snapshots) { laneSnapshot in + ActivityLaneCard(laneSnapshot: laneSnapshot, registry: registry) + } + } + } +} + +private struct ActivityLaneCard: View { + let laneSnapshot: ActivityLaneSnapshot + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 8) { + headerIcon + VStack(alignment: .leading, spacing: 2) { + Text(title(laneSnapshot.snapshot)) + .font(.body.weight(.medium)) + if let latestMessage = laneSnapshot.snapshot.latestMessage, !latestMessage.isEmpty { + AnimatedProgressText(message: latestMessage, isRunning: laneSnapshot.snapshot.isRunning) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + + VStack(alignment: .leading, spacing: 4) { + if laneSnapshot.snapshot.timeline.isEmpty { + Text(L10n.string("activity.timeline.empty")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(laneSnapshot.snapshot.timeline) { item in + OperationTimelineRow(item: item, showsBackground: false) + } + } + } + .padding(.leading, 26) + } + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + @ViewBuilder + private var headerIcon: some View { + if laneSnapshot.snapshot.isRunning && !laneSnapshot.isPendingConfirmation { + OperationTimelineStateIcon(state: .running) + .frame(width: 18) + } else { + Image(systemName: icon) + .foregroundStyle(iconColor) + .frame(width: 18) + } + } + + private var icon: String { + if laneSnapshot.isPendingConfirmation { + return "exclamationmark.triangle" + } + return laneSnapshot.snapshot.isRunning ? "hourglass" : "clock" + } + + private var iconColor: Color { + if laneSnapshot.isPendingConfirmation { + return .yellow + } + return laneSnapshot.snapshot.isRunning ? Color.accentColor : Color.secondary + } + + 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/Views/Shell/AppSettingsView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift new file mode 100644 index 00000000..6984e01f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/AppSettingsView.swift @@ -0,0 +1,211 @@ +import SwiftUI + +struct AppSettingsView: View { + let appStore: AppStore + @ObservedObject var appSettingsStore: AppSettingsStore + @ObservedObject var appUpdateStore: AppUpdateStore + @ObservedObject var editor: AppSettingsEditorStore + + private let contentWidth: CGFloat = 760 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header + .frame(maxWidth: contentWidth, alignment: .leading) + + SettingsFormSection(title: L10n.string("app_settings.section.general"), contentWidth: contentWidth) { + SettingsFormRow(title: L10n.string("app_settings.language")) { + Picker("", selection: $editor.draft.language) { + ForEach(AppLanguage.allCases) { language in + Text(language.title) + .tag(language) + } + } + .labelsHidden() + .frame(width: 220, alignment: .leading) + } + SettingsFormRow(title: L10n.string("app_settings.appearance")) { + Picker("", selection: $editor.draft.appearance) { + ForEach(AppAppearance.allCases) { appearance in + Text(appearance.title) + .tag(appearance) + } + } + .labelsHidden() + .frame(width: 220, alignment: .leading) + } + } + + SettingsFormSection(title: L10n.string("app_settings.section.defaults"), contentWidth: contentWidth) { + SettingsFormRow(title: L10n.string("app_settings.default_bonjour_timeout")) { + TextField("", text: $editor.draft.defaultBonjourTimeoutSeconds) + .frame(width: 120) + } + Toggle(L10n.string("toggle.enable_nbns"), isOn: $editor.draft.nbnsEnabled) + Toggle(L10n.string("toggle.internal_share_use_disk_root"), isOn: $editor.draft.internalShareUseDiskRoot) + Toggle(L10n.string("toggle.any_protocol"), isOn: $editor.draft.anyProtocol) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $editor.draft.debugLogging) + SettingsFormRow(title: L10n.string("field.mount_wait")) { + TextField("", text: $editor.draft.mountWaitSeconds) + .frame(width: 120) + } + SettingsFormRow(title: L10n.string("field.ata_idle_seconds")) { + TextField("", text: $editor.draft.ataIdleSeconds) + .frame(width: 120) + } + SettingsFormRow(title: L10n.string("field.ata_standby")) { + TextField(L10n.string("app_settings.blank_uses_device_default"), text: $editor.draft.ataStandby) + .frame(width: 180) + } + } + + SettingsFormSection(title: L10n.string("app_settings.section.diagnostics"), contentWidth: contentWidth) { + SettingsFormRow(title: L10n.string("app_settings.helper_path")) { + TextField(L10n.string("value.auto"), text: $editor.draft.helperPathOverride) + .frame(maxWidth: 420) + } + Toggle(L10n.string("app_settings.show_raw_events"), isOn: $editor.draft.showRawBackendEventsByDefault) + } + + SettingsFormSection(title: L10n.string("app_settings.section.updates"), contentWidth: contentWidth) { + Toggle(L10n.string("app_settings.check_updates_on_launch"), isOn: $editor.draft.checkForUpdatesOnLaunch) + SettingsFormRow(title: L10n.string("app_settings.version_url")) { + TextField(L10n.string("value.auto"), text: $editor.draft.versionCheckURL) + .frame(maxWidth: 420) + } + HStack(spacing: 10) { + Button { + appUpdateStore.checkNow(settings: appSettingsStore.settings) + } label: { + Label(L10n.string("app_settings.check_now"), systemImage: "arrow.clockwise") + } + .disabled(appUpdateStore.isChecking) + + if appUpdateStore.isChecking { + ProgressView() + .controlSize(.small) + } + Text(updateStatusText) + .font(.caption) + .foregroundStyle(updateStatusColor) + } + } + + SettingsFormSection(title: L10n.string("app_settings.section.privacy"), contentWidth: contentWidth) { + Toggle(L10n.string("app_settings.telemetry_enabled"), isOn: $editor.draft.telemetryEnabled) + } + + SettingsFormSection(title: L10n.string("app_settings.section.time_machine"), contentWidth: contentWidth) { + Toggle(L10n.string("app_settings.time_machine_warnings"), isOn: $editor.draft.timeMachineWarningsEnabled) + } + + if let message = editor.validationError ?? editor.errorMessage ?? appSettingsStore.error?.localizedDescription { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: contentWidth, alignment: .leading) + } + + actionBar + .frame(maxWidth: contentWidth, alignment: .leading) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.string("app_settings.title")) + .font(.title2.weight(.semibold)) + Text(L10n.string("app_settings.subtitle")) + .foregroundStyle(.secondary) + } + } + + private var actionBar: some View { + HStack(spacing: 10) { + Button { + Task { await editor.save(appStore: appStore) } + } label: { + Label(L10n.string("app_settings.save"), systemImage: "checkmark.circle") + } + .buttonStyle(.borderedProminent) + .disabled(!editor.canSave) + + Button(L10n.string("app_settings.reset_saved")) { + editor.resetDraft() + } + .disabled(editor.isSaving || !editor.hasChanges) + + Button(L10n.string("app_settings.restore_defaults")) { + editor.restoreDefaultsDraft() + } + .disabled(editor.isSaving) + + if editor.isSaving { + ProgressView() + .controlSize(.small) + } + } + } + + private var updateStatusText: String { + if let payload = appUpdateStore.payload { + return payload.localizedSummary + } + if let error = appUpdateStore.error { + return error.message + } + return appUpdateStore.state.title + } + + private var updateStatusColor: Color { + switch appUpdateStore.state { + case .updateAvailable, .unavailable, .failed: + return .yellow + case .current: + return .green + default: + return .secondary + } + } +} + +private struct SettingsFormSection: View { + let title: String + let contentWidth: CGFloat + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.headline) + VStack(alignment: .leading, spacing: 8) { + content() + } + } + .frame(maxWidth: contentWidth, alignment: .leading) + Divider() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct SettingsFormRow: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text(title) + .frame(width: 220, alignment: .leading) + .foregroundStyle(.secondary) + content() + Spacer(minLength: 0) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift new file mode 100644 index 00000000..1328dbd8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/ContentView.swift @@ -0,0 +1,428 @@ +import SwiftUI + +public struct ContentView: View { + @StateObject private var appStore: AppStore + @ObservedObject private var appReadinessStore: AppReadinessStore + @ObservedObject private var appSettingsStore: AppSettingsStore + @ObservedObject private var deviceRegistry: DeviceRegistryStore + @ObservedObject private var operationCoordinator: OperationCoordinator + @ObservedObject private var activityStore: ActivityStore + @ObservedObject private var deviceDiscovery: DeviceDiscoveryStore + @ObservedObject private var appBackend: BackendClient + @StateObject private var addDeviceStore: AddDeviceFlowStore + @StateObject private var appSettingsEditorStore: AppSettingsEditorStore + @StateObject private var dashboardStore: DashboardStore + @State private var diagnosticsPresented = false + @State private var diagnosticsShowBackendEvents = true + @State private var profilePendingDeletion: DeviceProfile? + @State private var deleteErrorMessage: String? + @State private var systemColorScheme = SystemAppearance.currentColorScheme + private let startsAutomatically: Bool + + @MainActor + public init() { + self.init(composition: .production()) + } + + @MainActor + init(composition: AppViewComposition, startsAutomatically: Bool = true) { + _appStore = StateObject(wrappedValue: composition.appStore) + _appReadinessStore = ObservedObject(wrappedValue: composition.appStore.appReadinessStore) + _appSettingsStore = ObservedObject(wrappedValue: composition.appStore.appSettingsStore) + _deviceRegistry = ObservedObject(wrappedValue: composition.appStore.deviceRegistry) + _operationCoordinator = ObservedObject(wrappedValue: composition.appStore.operationCoordinator) + _activityStore = ObservedObject(wrappedValue: composition.appStore.activityStore) + _deviceDiscovery = ObservedObject(wrappedValue: composition.appStore.deviceDiscovery) + _appBackend = ObservedObject(wrappedValue: composition.appStore.backend) + _appSettingsEditorStore = StateObject(wrappedValue: composition.appSettingsEditorStore) + _addDeviceStore = StateObject(wrappedValue: composition.addDeviceStore) + _dashboardStore = StateObject(wrappedValue: composition.dashboardStore) + self.startsAutomatically = startsAutomatically + } + + public var body: some View { + NavigationSplitView { + sidebar + } detail: { + VStack(spacing: 0) { + if case .blocked = appReadinessStore.state { + AppReadinessBlockedView(store: appReadinessStore) { + diagnosticsPresented = true + } + } else { + AppReadinessBannerView(store: appReadinessStore) { + diagnosticsPresented = true + } + detail + Divider() + ActivityCompactView( + activityStore: activityStore, + registry: deviceRegistry, + context: activityDisplayContext + ) + } + } + .toolbar { + ToolbarItemGroup { + ToolbarIconButton( + title: L10n.string("toolbar.add"), + systemImage: "plus" + ) { + appStore.showAddDevice() + } + ToolbarIconButton( + title: L10n.string("toolbar.diagnostics"), + systemImage: "wrench.and.screwdriver" + ) { + diagnosticsPresented = true + } + ToolbarIconButton( + title: L10n.string("toolbar.forget"), + systemImage: "trash", + disabled: selectedProfileIsBusy + ) { + guard let profile = appStore.selectedProfile else { + return + } + profilePendingDeletion = profile + } + ToolbarIconButton( + title: L10n.string("toolbar.cancel"), + systemImage: "xmark.circle", + disabled: !operationCoordinator.canCancel + ) { + operationCoordinator.cancel() + } + } + } + } + .frame(minWidth: 1080, minHeight: 720) + .preferredColorScheme(appSettingsStore.settings.appearance.preferredColorScheme(systemColorScheme: systemColorScheme)) + .background(WindowCloseGuardInstaller()) + .onAppear { + configureCloseGuard() + systemColorScheme = SystemAppearance.currentColorScheme + } + .task { + if startsAutomatically { + await appStore.start() + } + addDeviceStore.applyAppSettings(appSettingsStore.settings) + appSettingsEditorStore.sync(settings: appSettingsStore.settings) + } + .onChange(of: addDeviceStore.savedProfile) { _, profile in + guard let profile else { return } + appStore.select(profile) + } + .onChange(of: appSettingsStore.settings) { _, settings in + systemColorScheme = SystemAppearance.currentColorScheme + addDeviceStore.applyAppSettings(settings) + appSettingsEditorStore.sync(settings: settings) + } + .onReceive(DistributedNotificationCenter.default().publisher(for: SystemAppearance.didChangeNotification)) { _ in + systemColorScheme = SystemAppearance.currentColorScheme + } + .onChange(of: diagnosticsPresented) { _, isPresented in + guard isPresented else { return } + diagnosticsShowBackendEvents = appSettingsStore.settings.showRawBackendEventsByDefault + } + .sheet(isPresented: $diagnosticsPresented) { + AppDiagnosticsView( + store: appReadinessStore, + exportContext: { includeBackendEvents in + appStore.diagnosticsExportContext(includeBackendEvents: includeBackendEvents) + }, + showBackendEvents: $diagnosticsShowBackendEvents, + helperPath: Binding( + get: { appBackend.helperPath }, + set: { appBackend.helperPath = $0 } + ) + ) + } + .confirmationDialog( + L10n.string("dialog.forget.title"), + isPresented: deleteConfirmationPresented, + presenting: profilePendingDeletion + ) { profile in + Button(L10n.format("dialog.forget.action", profile.title), role: .destructive) { + Task { @MainActor in + do { + try await 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( + operationCoordinator.pendingConfirmation?.title ?? "", + isPresented: confirmationPresented, + presenting: operationCoordinator.pendingConfirmation + ) { confirmation in + Button(confirmation.actionTitle, role: .destructive) { + operationCoordinator.confirmPending() + } + Button(L10n.string("action.cancel"), role: .cancel) { + operationCoordinator.cancelPendingConfirmation() + } + } 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: { operationCoordinator.pendingConfirmation != nil }, + set: { isPresented in + if !isPresented { + operationCoordinator.cancelPendingConfirmation() + } + } + ) + } + + private var selectedProfileIsBusy: Bool { + guard let profile = appStore.selectedProfile else { + return true + } + return operationCoordinator.isDeviceBusy(profile) + } + + private func configureCloseGuard() { + AppCloseGuard.shared.configure { [weak operationCoordinator] in + operationCoordinator?.hasActiveWork ?? false + } + } + + private var sidebarSelection: Binding { + Binding( + get: { + appStore.route + }, + set: { value in + guard let value else { return } + appStore.navigate(to: value) + } + ) + } + + private var sidebar: some View { + List(selection: sidebarSelection) { + Label(L10n.string("sidebar.all_airport_devices"), systemImage: "externaldrive.connected.to.line.below") + .tag(AppRoute.allDevices) + Label(L10n.string("sidebar.activity"), systemImage: activityStore.hasActiveActivity ? "hourglass" : "clock") + .tag(AppRoute.activity) + Label(L10n.string("sidebar.settings"), systemImage: "gearshape") + .tag(AppRoute.appSettings) + + Section(L10n.string("sidebar.devices")) { + ForEach(deviceRegistry.profiles) { profile in + let summary = appStore.dashboardSummary(for: profile) + DeviceSidebarRow( + profile: profile, + summary: summary, + lastSeenText: deviceDiscovery.lastSeenText(for: profile) + ) + .contextMenu { + DeviceSidebarContextMenu( + presentation: sidebarContextMenuPresentation(for: profile, summary: summary) + ) { action in + performSidebarContextMenuAction(action, profile: profile) + } + } + .tag(AppRoute.device(profile.id)) + } + } + + Section { + Label(L10n.string("sidebar.add_airport_device"), systemImage: "plus.circle") + .tag(AppRoute.addDevice) + } + } + .navigationTitle("TimeCapsuleSMB") + .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) + } + + private func sidebarContextMenuPresentation( + for profile: DeviceProfile, + summary: DeviceDashboardSummary + ) -> DeviceSidebarContextMenuPresentation { + DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: operationCoordinator.isDeviceBusy(profile) + ) + } + + private func performSidebarContextMenuAction( + _ action: DeviceSidebarContextMenuAction, + profile: DeviceProfile + ) { + switch action { + case .openOverview: + openDashboard(profile, tab: .overview) + case .openFinder: + dashboardStore.session(for: profile).performSecondaryAction(.openFinder, profile: profile) + case .runCheckup: + guard !operationCoordinator.isDeviceBusy(profile) else { + return + } + appStore.select(profile) + dashboardStore.session(for: profile).performSecondaryAction(.runCheckup, profile: profile) + case .viewCheckup: + openDashboard(profile, tab: .checkup) + case .refreshStatus: + guard !operationCoordinator.isDeviceBusy(profile) else { + return + } + dashboardStore.session(for: profile).performSecondaryAction(.refreshStatus, profile: profile) + case .settings: + openDashboard(profile, tab: .settings) + case .copySMBAddress, .copyHostname, .copyIPAddress: + copySidebarValue(action, profile: profile) + case .removeFromThisMac: + guard !operationCoordinator.isDeviceBusy(profile) else { + return + } + profilePendingDeletion = profile + } + } + + private func openDashboard(_ profile: DeviceProfile, tab: DeviceDashboardTab) { + let session = dashboardStore.session(for: profile) + session.selectedTab = tab + appStore.select(profile) + } + + private func copySidebarValue(_ action: DeviceSidebarContextMenuAction, profile: DeviceProfile) { + let summary = appStore.dashboardSummary(for: profile) + let presentation = sidebarContextMenuPresentation(for: profile, summary: summary) + guard let value = presentation.clipboardValue(for: action) else { + return + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(value, forType: .string) + } + + private var activityDisplayContext: ActivityDisplayContext { + ActivityDisplayContext( + selectedDeviceID: appStore.selectedDeviceID, + showingAddDevice: appStore.showingAddDevice, + showingActivity: appStore.showingActivity + ) + } + + @ViewBuilder + private var detail: some View { + switch appStore.route { + case .activity: + ActivityDetailView( + activityStore: activityStore, + registry: deviceRegistry + ) + case .appSettings: + AppSettingsView( + appStore: appStore, + appSettingsStore: appSettingsStore, + appUpdateStore: appStore.appUpdateStore, + editor: appSettingsEditorStore + ) + case .addDevice: + AddDeviceView(store: addDeviceStore) + case .device(let profileID): + if let profile = deviceRegistry.profile(id: profileID) { + DeviceDashboardView( + profile: profile, + session: dashboardStore.session(for: profile), + appStore: appStore, + appSettingsStore: appSettingsStore, + reachabilityStore: appStore.reachabilityStore, + operationCoordinator: operationCoordinator, + backend: appBackend, + showDiagnostics: { + diagnosticsPresented = true + } + ) + } else { + DeviceListOverviewView( + appStore: appStore, + deviceRegistry: deviceRegistry, + deviceDiscovery: deviceDiscovery, + backend: appBackend, + addDiscoveredDevice: { device in + addDeviceStore.select(device) + appStore.showAddDevice() + } + ) + } + case .allDevices: + DeviceListOverviewView( + appStore: appStore, + deviceRegistry: deviceRegistry, + deviceDiscovery: deviceDiscovery, + backend: appBackend, + addDiscoveredDevice: { device in + addDeviceStore.select(device) + appStore.showAddDevice() + } + ) + } + } +} + +private extension AppAppearance { + func preferredColorScheme(systemColorScheme: ColorScheme) -> ColorScheme? { + switch self { + case .system: + return systemColorScheme + case .light: + return .light + case .dark: + return .dark + } + } +} + +private enum SystemAppearance { + static let didChangeNotification = Notification.Name("AppleInterfaceThemeChangedNotification") + + static var currentColorScheme: ColorScheme { + UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" ? .dark : .light + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift new file mode 100644 index 00000000..2334a881 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/DeviceListOverviewView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +struct DeviceListOverviewView: View { + let appStore: AppStore + @ObservedObject var deviceRegistry: DeviceRegistryStore + @ObservedObject var deviceDiscovery: DeviceDiscoveryStore + @ObservedObject var backend: BackendClient + let addDiscoveredDevice: (DiscoveredDevice) -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + savedDevicesSection + discoverySection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + @ViewBuilder + private var savedDevicesSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text(deviceRegistry.profiles.isEmpty + ? L10n.string("overview.empty.title") + : L10n.string("overview.saved_devices.title")) + .font(.title2.weight(.semibold)) + + if deviceRegistry.profiles.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text(L10n.string("overview.empty.message")) + .foregroundStyle(.secondary) + Button { + appStore.showAddDevice() + } label: { + Label(L10n.string("sidebar.add_airport_device"), systemImage: "plus.circle") + } + } + } else { + ForEach(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.addressSummary.isEmpty ? profile.displayTarget : profile.addressSummary) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Label(summary.displayStatus.title, systemImage: summary.displayStatus.systemImage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + Divider() + } + } + } + } + + private var discoverySection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(L10n.string("overview.discovery.title")) + .font(.headline) + Spacer() + Text(deviceDiscovery.state.title) + .font(.caption) + .foregroundStyle(.secondary) + Button { + deviceDiscovery.refresh() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .disabled(backend.isRunning) + .help(L10n.string("overview.discovery.refresh")) + } + + discoveryContent + } + } + + @ViewBuilder + private var discoveryContent: some View { + switch deviceDiscovery.state { + case .idle, .waitingForReadiness: + Text(L10n.string("overview.discovery.waiting")) + .foregroundStyle(.secondary) + case .discovering: + ProgressView(L10n.string("overview.discovery.discovering")) + case .paused: + Text(L10n.string("overview.discovery.paused")) + .foregroundStyle(.secondary) + case .readinessBlocked: + Text(L10n.string("overview.discovery.readiness_blocked")) + .foregroundStyle(.secondary) + case .failed: + VStack(alignment: .leading, spacing: 6) { + Text(deviceDiscovery.error?.message ?? L10n.string("overview.discovery.failed")) + .foregroundStyle(.red) + Button(L10n.string("overview.discovery.refresh")) { + deviceDiscovery.refresh() + } + } + case .empty: + Text(L10n.string("overview.discovery.empty")) + .foregroundStyle(.secondary) + case .ready: + let unsaved = deviceDiscovery.unsavedDevices + let saved = deviceDiscovery.savedDevices + if unsaved.isEmpty && saved.isEmpty { + Text(L10n.string("overview.discovery.empty")) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(unsaved) { device in + OverviewDiscoveredDeviceRow( + device: device, + statusText: L10n.string("overview.discovery.unsaved"), + actionTitle: L10n.string("overview.discovery.add") + ) { + addDiscoveredDevice(device) + } + Divider() + } + ForEach(saved) { device in + OverviewDiscoveredDeviceRow( + device: device, + statusText: L10n.string("overview.discovery.saved"), + actionTitle: nil, + action: nil + ) + Divider() + } + } + } + } + } +} + +private struct OverviewDiscoveredDeviceRow: View { + let device: DiscoveredDevice + let statusText: String + let actionTitle: String? + let action: (() -> Void)? + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text(device.name) + .font(.body.weight(.medium)) + HStack(spacing: 6) { + Text(device.addressSummary.isEmpty ? device.connectionTarget : device.addressSummary) + if !device.discoveryModelText.isEmpty { + Text(device.discoveryModelText) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + if let actionTitle, let action { + Button(actionTitle, action: action) + } + } + .padding(.vertical, 8) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift new file mode 100644 index 00000000..6c8a8c80 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Views/Shell/SidebarView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct DeviceSidebarRow: View { + let profile: DeviceProfile + let summary: DeviceDashboardSummary + var lastSeenText: String? + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "externaldrive") + VStack(alignment: .leading, spacing: 2) { + Text(profile.title) + .lineLimit(1) + HStack(spacing: 4) { + Text(profile.displayTarget) + .lineLimit(1) + if let lastSeenText { + Text("- \(lastSeenText)") + .lineLimit(1) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + 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 + } + } +} + +struct DeviceSidebarContextMenu: View { + let presentation: DeviceSidebarContextMenuPresentation + let performAction: (DeviceSidebarContextMenuAction) -> Void + + var body: some View { + ForEach(presentation.navigationItems) { item in + menuButton(item) + } + + Divider() + + ForEach(presentation.clipboardItems) { item in + menuButton(item) + } + + Divider() + + ForEach(presentation.destructiveItems) { item in + menuButton(item, role: .destructive) + } + } + + private func menuButton( + _ item: DeviceSidebarContextMenuItem, + role: ButtonRole? = nil + ) -> some View { + Button(role: role) { + performAction(item.action) + } label: { + Label(item.title, systemImage: item.systemImage) + } + .disabled(!item.isEnabled) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivationStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivationStore.swift new file mode 100644 index 00000000..5108a106 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivationStore.swift @@ -0,0 +1,231 @@ +import Combine +import Foundation + +@MainActor +final class ActivationStore: ObservableObject { + @Published private(set) var state: MaintenanceOperationState = .idle + @Published private(set) var plan: ActivationPlanPayload? + @Published private(set) var result: ActivationResultPayload? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + + private let operation: MaintenanceWorkflowOperation + + init(backend: BackendClient, coordinator: OperationCoordinator? = nil, laneKey: OperationLaneKey? = nil) { + self.operation = MaintenanceWorkflowOperation( + name: "activate", + backend: backend, + coordinator: coordinator, + laneKey: laneKey + ) + self.operation.bind(onEvent: { [weak self] event, activeOperation in + self?.handle(event, activeOperation: activeOperation) + }, onRunningChanged: { [weak self] in + self?.objectWillChange.send() + }) + } + + var events: [BackendEvent] { operation.events } + var isRunning: Bool { operation.isRunning } + var isBusy: Bool { operation.isBusy } + var canCancel: Bool { operation.canCancel } + var pendingConfirmation: PendingConfirmation? { operation.pendingConfirmation } + + var canPlan: Bool { + !isBusy + } + + var canRun: Bool { + !isBusy && plan != nil && state == .planReady + } + + func confirmPending() { + operation.confirmPending() + } + + func cancelPendingConfirmation() { + operation.cancelPendingConfirmation() + } + + func cancel() { + operation.cancel() + } + + func clear() { + operation.clear() + state = .idle + plan = nil + result = nil + currentStage = nil + error = nil + passwordInvalidProfileID = nil + } + + @discardableResult + func planActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + let start = startRun( + params: OperationParams.Activation.params(dryRun: true), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .planning + plan = nil + result = nil + return start + } + + @discardableResult + func runActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !isBusy else { + return rejectAlreadyRunning() + } + guard canRun else { + failLocally(.activationPlanRequired) + return .rejected(WorkflowLocalError.activationPlanRequired.message) + } + let start = startRun( + params: OperationParams.Activation.params(dryRun: false), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .running + result = nil + return start + } + + @discardableResult + func rejectAlreadyRunning() -> OperationStartResult { + rejectRun(.operationAlreadyRunning) + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + + private func startRun( + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? + ) -> OperationStartResult { + operation.start( + params: params, + profile: profile, + password: password, + rejectAlreadyRunning: { rejectRun(.operationAlreadyRunning) }, + resetRunState: resetRunState, + rejectRun: rejectRun(message:) + ) + } + + private func resetRunState() { + operation.resetForRun() + error = nil + currentStage = nil + passwordInvalidProfileID = nil + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard event.operation == operation.name else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .running + } + return + } + + if event.type == "error" { + applyError(event, activeOperation: activeOperation) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFalseResult(event) + return + } + + if state == .planning { + do { + plan = try event.decodePayload(ActivationPlanPayload.self) + state = .planReady + operation.finishObserver() + } catch { + failContract(error) + } + return + } + + do { + result = try event.decodePayload(ActivationResultPayload.self) + state = .succeeded + error = nil + operation.finishObserver() + } catch { + failContract(error) + } + } + + private func applyError(_ event: BackendEvent, activeOperation: ActiveOperation) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "confirmation_cancelled" { + error = nil + currentStage = nil + operation.finishObserver() + state = plan == nil ? .idle : .planReady + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation.profileID + } + error = BackendErrorViewModel(event: event) + state = .failed + operation.finishObserver() + } + + private func applyFalseResult(_ event: BackendEvent) { + error = operation.falseResultError(from: event) + state = .failed + operation.finishObserver() + } + + private func failContract(_ decodeError: Error) { + error = operation.contractDecodeError(decodeError) + state = .failed + operation.finishObserver() + } + + private func failLocally(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(message: String) { + error = operation.rejectedError(message: message) + currentStage = nil + state = .failed + operation.finishObserver() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift new file mode 100644 index 00000000..51967e0b --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ActivityStore.swift @@ -0,0 +1,364 @@ +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 operation: String? + let operationTitle: String + let latestMessage: String? + let timeline: [OperationTimelineItem] +} + +struct ActivityLaneSnapshot: Equatable, Identifiable { + let laneKey: OperationLaneKey + let snapshot: ActivitySnapshot + let isPendingConfirmation: Bool + let updateSequence: Int + + var id: OperationLaneKey { + laneKey + } +} + +struct ActivityDisplayContext: Equatable { + let selectedDeviceID: DeviceProfile.ID? + let showingAddDevice: Bool + let showingActivity: Bool + + static let none = ActivityDisplayContext( + selectedDeviceID: nil, + showingAddDevice: false, + showingActivity: false + ) +} + +struct ActivityCompactStatus: Equatable { + let isRunning: Bool + let requiresAttention: Bool + let scope: ActivityScope + let operationTitle: String + let latestMessage: String? + let latestTimelineTitle: String? + let activeLaneCount: Int + + static func from(_ laneSnapshot: ActivityLaneSnapshot, activeLaneCount: Int) -> ActivityCompactStatus { + ActivityCompactStatus( + isRunning: laneSnapshot.snapshot.isRunning, + requiresAttention: laneSnapshot.isPendingConfirmation, + scope: laneSnapshot.snapshot.scope, + operationTitle: laneSnapshot.snapshot.operationTitle, + latestMessage: laneSnapshot.snapshot.latestMessage, + latestTimelineTitle: laneSnapshot.snapshot.timeline.last?.title, + activeLaneCount: activeLaneCount + ) + } +} + +@MainActor +final class ActivityStore: ObservableObject { + @Published private(set) var snapshot = ActivitySnapshot( + isRunning: false, + scope: .unknown, + operation: nil, + operationTitle: L10n.string("activity.no_active_operation"), + latestMessage: nil, + timeline: [] + ) + @Published private(set) var laneSnapshots: [ActivityLaneSnapshot] = [] + + private let coordinator: OperationCoordinator + private var cancellables: Set = [] + private var previousSnapshots: [OperationLaneKey: ActivitySnapshot] = [:] + private var previousPendingStates: [OperationLaneKey: Bool] = [:] + private var laneUpdateSequences: [OperationLaneKey: Int] = [:] + private var nextUpdateSequence = 1 + + init(coordinator: OperationCoordinator) { + self.coordinator = coordinator + coordinator.$lanesRevision + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + refresh() + } + + func refresh() { + laneSnapshots = coordinator.allLanes + .map { lane in + let snapshot = snapshot(for: lane) + let isPendingConfirmation = lane.backend.pendingConfirmation != nil + let updateSequence = updateSequence( + for: lane.key, + snapshot: snapshot, + isPendingConfirmation: isPendingConfirmation + ) + return ActivityLaneSnapshot( + laneKey: lane.key, + snapshot: snapshot, + isPendingConfirmation: isPendingConfirmation, + updateSequence: updateSequence + ) + } + .filter { laneSnapshot in + laneSnapshot.snapshot.isRunning + || laneSnapshot.isPendingConfirmation + || !laneSnapshot.snapshot.timeline.isEmpty + } + .sorted(by: sortLaneSnapshots) + snapshot = primarySnapshot(from: laneSnapshots) ?? emptySnapshot() + } + + var activeLaneSnapshots: [ActivityLaneSnapshot] { + laneSnapshots.filter { $0.snapshot.isRunning || $0.isPendingConfirmation } + } + + var recentLaneSnapshots: [ActivityLaneSnapshot] { + laneSnapshots.filter { !$0.snapshot.isRunning && !$0.isPendingConfirmation } + } + + var hasActiveActivity: Bool { + !activeLaneSnapshots.isEmpty + } + + func compactStatus(for context: ActivityDisplayContext) -> ActivityCompactStatus { + let active = activeLaneSnapshots + let activeCount = active.count + + if let selectedDeviceID = context.selectedDeviceID, + let selected = laneSnapshots.first(where: { isDeviceLane($0, selectedDeviceID: selectedDeviceID) }), + selected.snapshot.isRunning || selected.isPendingConfirmation { + return .from(selected, activeLaneCount: activeCount) + } + + if context.showingAddDevice, + let configureLane = active.first(where: { laneSnapshot in + laneSnapshot.laneKey != .app + && laneSnapshot.snapshot.operation == "configure" + }) { + return .from(configureLane, activeLaneCount: activeCount) + } + + if context.showingAddDevice || (context.selectedDeviceID == nil && !context.showingActivity), + let appLane = active.first(where: { $0.laneKey == .app }) { + return .from(appLane, activeLaneCount: activeCount) + } + + if activeCount > 1 { + return ActivityCompactStatus( + isRunning: active.contains { $0.snapshot.isRunning }, + requiresAttention: active.contains { $0.isPendingConfirmation }, + scope: .unknown, + operationTitle: L10n.format("activity.multiple_active", activeCount), + latestMessage: L10n.string("activity.multiple_active.message"), + latestTimelineTitle: nil, + activeLaneCount: activeCount + ) + } + + if let activeLane = active.first { + return .from(activeLane, activeLaneCount: activeCount) + } + + if let selectedDeviceID = context.selectedDeviceID, + let selected = laneSnapshots.first(where: { isDeviceLane($0, selectedDeviceID: selectedDeviceID) }) { + return .from(selected, activeLaneCount: activeCount) + } + + if context.showingAddDevice || (context.selectedDeviceID == nil && !context.showingActivity), + let appLane = laneSnapshots.first(where: { $0.laneKey == .app }) { + return .from(appLane, activeLaneCount: activeCount) + } + + if let recent = laneSnapshots.first { + return .from(recent, activeLaneCount: activeCount) + } + + let empty = emptySnapshot() + return ActivityCompactStatus( + isRunning: empty.isRunning, + requiresAttention: false, + scope: empty.scope, + operationTitle: empty.operationTitle, + latestMessage: empty.latestMessage, + latestTimelineTitle: empty.timeline.last?.title, + activeLaneCount: activeCount + ) + } + + private func snapshot(for lane: OperationLane) -> ActivitySnapshot { + let events = lane.backend.events + let timeline = OperationTimelineBuilder.timeline(from: events) + let operation = lane.activeOperation?.operation + ?? lane.backend.activeOperationName + ?? latestOperation(from: events) + let isRunning = lane.backend.isRunning + let presentation = presentation( + operation: operation, + events: events, + timeline: timeline, + isRunning: isRunning + ) + return ActivitySnapshot( + isRunning: isRunning, + scope: scope(for: lane.key, operation: operation), + operation: operation, + operationTitle: presentation.title, + latestMessage: presentation.message, + timeline: timeline + ) + } + + private func presentation( + operation: String?, + events: [BackendEvent], + timeline: [OperationTimelineItem], + isRunning: Bool + ) -> (title: String, message: String?) { + if appReadinessPassed(operation: operation, events: events, isRunning: isRunning) { + return (L10n.string("activity.app_ready"), nil) + } + + let title = operation.map(OperationTimelineBuilder.operationTitle) + ?? (timeline.isEmpty ? L10n.string("activity.no_active_operation") : L10n.string("activity.last_operation")) + let message = timeline.last?.detail ?? events.last?.localizedSummary + return (title, message) + } + + private func appReadinessPassed(operation: String?, events: [BackendEvent], isRunning: Bool) -> Bool { + guard + !isRunning, + operation == "validate-install", + let latestEvent = events.last, + latestEvent.operation == "validate-install", + latestEvent.type == "result", + latestEvent.ok == true + else { + return false + } + return true + } + + private func latestOperation(from events: [BackendEvent]) -> String? { + events.last?.operation + } + + private func primarySnapshot(from snapshots: [ActivityLaneSnapshot]) -> ActivitySnapshot? { + if let runningDevice = snapshots.first(where: { laneSnapshot in + laneSnapshot.snapshot.isRunning && isDeviceScope(laneSnapshot.snapshot.scope) + }) { + return runningDevice.snapshot + } + if let runningApp = snapshots.first(where: { laneSnapshot in + laneSnapshot.snapshot.isRunning && laneSnapshot.snapshot.scope == .app + }) { + return runningApp.snapshot + } + if let pending = snapshots.first(where: \.isPendingConfirmation) { + return pending.snapshot + } + return snapshots.first?.snapshot + } + + private func scope(for laneKey: OperationLaneKey, operation: String?) -> ActivityScope { + switch laneKey { + case .app, .appWorkflow: + return .app + case .device(let profileID), .deviceWorkflow(let profileID, _): + return .device(profileID) + case .candidateHost, .localPath: + return isAppOperation(operation) ? .app : .unknown + } + } + + private func isDeviceScope(_ scope: ActivityScope) -> Bool { + if case .device = scope { + return true + } + return false + } + + private func emptySnapshot() -> ActivitySnapshot { + ActivitySnapshot( + isRunning: false, + scope: .unknown, + operation: nil, + operationTitle: L10n.string("activity.no_active_operation"), + latestMessage: nil, + timeline: [] + ) + } + + private func isAppOperation(_ operation: String?) -> Bool { + guard let operation else { + return false + } + return [ + "capabilities", + "discover", + "set-telemetry", + "validate-install", + "version-check" + ].contains(operation) + } + + private func updateSequence( + for laneKey: OperationLaneKey, + snapshot: ActivitySnapshot, + isPendingConfirmation: Bool + ) -> Int { + defer { + previousSnapshots[laneKey] = snapshot + previousPendingStates[laneKey] = isPendingConfirmation + } + + if previousSnapshots[laneKey] != snapshot || previousPendingStates[laneKey] != isPendingConfirmation { + let sequence = nextUpdateSequence + laneUpdateSequences[laneKey] = sequence + nextUpdateSequence += 1 + return sequence + } + return laneUpdateSequences[laneKey] ?? 0 + } + + private func sortLaneSnapshots(_ left: ActivityLaneSnapshot, _ right: ActivityLaneSnapshot) -> Bool { + let leftPriority = lanePriority(left) + let rightPriority = lanePriority(right) + if leftPriority != rightPriority { + return leftPriority < rightPriority + } + if left.updateSequence != right.updateSequence { + return left.updateSequence > right.updateSequence + } + return left.laneKey.description < right.laneKey.description + } + + private func lanePriority(_ laneSnapshot: ActivityLaneSnapshot) -> Int { + if laneSnapshot.isPendingConfirmation { + return 0 + } + if laneSnapshot.snapshot.isRunning { + return 1 + } + return 2 + } + + private func isDeviceLane(_ laneSnapshot: ActivityLaneSnapshot, selectedDeviceID: DeviceProfile.ID) -> Bool { + if case .device(let profileID) = laneSnapshot.snapshot.scope { + return profileID == selectedDeviceID + } + if let profileID = laneSnapshot.laneKey.deviceProfileID { + return profileID == selectedDeviceID + } + return false + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDeviceFlowStore.swift new file mode 100644 index 00000000..f1a0fda4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/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 awaitingConfirmation + 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 .awaitingConfirmation: + return L10n.string("add_device.state.awaiting_confirmation") + 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 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 profilePersistence: DeviceProfilePersistenceService + let discovery: DeviceDiscoveryStore + let setupWorkflow: DeviceSetupWorkflow + + private var defaultDeviceSettings: DeviceProfileSettings = AppSettings.default.defaultDeviceSettings + private var appliedDefaultDeviceSettings: DeviceProfileSettings = AppSettings.default.defaultDeviceSettings + private var appliedDefaultBonjourTimeout = AppSettings.default.defaultBonjourTimeoutSeconds + private var cancellables: Set = [] + + init( + coordinator: OperationCoordinator, + registry: DeviceRegistryStore, + passwordStore: PasswordStore, + profilePersistence: DeviceProfilePersistenceService? = nil, + discovery: DeviceDiscoveryStore? = nil, + setupWorkflow: DeviceSetupWorkflow? = nil + ) { + self.coordinator = coordinator + self.registry = registry + self.passwordStore = passwordStore + let resolvedPersistence = profilePersistence ?? DeviceProfilePersistenceService( + registry: registry, + passwordStore: passwordStore + ) + self.profilePersistence = resolvedPersistence + self.discovery = discovery ?? DeviceDiscoveryStore( + coordinator: coordinator, + registry: registry + ) + self.setupWorkflow = setupWorkflow ?? DeviceSetupWorkflow( + coordinator: coordinator, + profilePersistence: resolvedPersistence + ) + observeDiscovery() + observeSetupWorkflow() + } + + var devices: [DiscoveredDevice] { + entryMode == .discover ? discovery.devices : [] + } + + var isRunning: Bool { + discovery.state == .discovering || setupWorkflow.isRunning + } + + var canCancel: Bool { + setupWorkflow.canCancel || discovery.state == .discovering + } + + var selectedDevice: DiscoveredDevice? { + guard let selectedDeviceID else { + return nil + } + return discovery.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? { + ValueParsers.nonNegativeDouble(bonjourTimeout) + } + + var canConfigure: Bool { + !isRunning + && !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && currentTarget()?.isEmpty == false + } + + func setEntryMode(_ mode: AddDeviceEntryMode) { + guard entryMode != mode else { + return + } + switch mode { + case .discover: + entryMode = .discover + selectedDeviceID = nil + manualHost = "" + savedProfile = nil + error = nil + currentStage = nil + syncDiscoveryState() + case .manual: + startManualEntry() + } + } + + func startManualEntry() { + entryMode = .manual + state = .manualEntry + selectedDeviceID = nil + savedProfile = nil + error = nil + currentStage = nil + } + + func runDiscover() { + guard let timeout = bonjourTimeoutValue else { + failLocally(L10n.string("add_device.error.invalid_bonjour_timeout")) + return + } + guard coordinator.appLane.isBusy == false else { + rejectRun(L10n.string("operation.error.already_running")) + return + } + entryMode = .discover + selectedDeviceID = nil + manualHost = "" + savedProfile = nil + error = nil + currentStage = nil + state = .discovering + discovery.refresh(timeout: timeout) + } + + func runConfigure() { + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPassword.isEmpty else { + state = .passwordEntry + failLocally(L10n.string("add_device.error.password_required")) + return + } + guard let target = currentTarget(), !target.isEmpty else { + failLocally(L10n.string("add_device.error.choose_target")) + return + } + + let existing = target.matchingProfile(in: registry) + let profileID = existing?.id ?? UUID().uuidString.lowercased() + let configureSettings = existing?.settings ?? defaultDeviceSettings + error = nil + currentStage = nil + savedProfile = nil + setupWorkflow.start( + target: target, + password: password, + existingProfile: existing, + preferredID: profileID, + settings: configureSettings, + newProfileSettings: defaultDeviceSettings + ) + applySetupState(setupWorkflow.state) + } + + func select(_ device: DiscoveredDevice) { + entryMode = .discover + selectedDeviceID = device.id + manualHost = device.connectionTarget + if let existing = registry.matchingProfile(for: device) { + savedProfile = existing + state = .saved + error = nil + return + } + state = .passwordEntry + } + + func reset() { + setupWorkflow.reset() + selectedDeviceID = nil + entryMode = .discover + manualHost = "" + password = "" + savedProfile = nil + error = nil + currentStage = nil + syncDiscoveryState() + } + + func cancel() { + if setupWorkflow.canCancel { + setupWorkflow.cancel() + } else if discovery.state == .discovering { + coordinator.cancel(laneKey: .app) + } + } + + func applyAppSettings(_ settings: AppSettings) { + let previousDefaultTimeout = Self.timeoutText(appliedDefaultBonjourTimeout) + if bonjourTimeout == previousDefaultTimeout { + bonjourTimeout = Self.timeoutText(settings.defaultBonjourTimeoutSeconds) + } + appliedDefaultBonjourTimeout = settings.defaultBonjourTimeoutSeconds + defaultDeviceSettings = settings.defaultDeviceSettings + if debugLogging == appliedDefaultDeviceSettings.debugLogging { + debugLogging = settings.defaultDeviceSettings.debugLogging + } + appliedDefaultDeviceSettings = settings.defaultDeviceSettings + } + + private func currentTarget() -> AddDeviceTarget? { + switch entryMode { + case .discover: + guard let selectedDevice else { + return nil + } + return .discovered(selectedDevice) + case .manual: + let target = ManualDeviceTarget(host: manualHost) + return target.host.isEmpty ? nil : .manual(target) + } + } + + private func observeDiscovery() { + discovery.$state + .sink { [weak self] _ in + Task { @MainActor in + self?.syncDiscoveryState() + } + } + .store(in: &cancellables) + discovery.$devices + .sink { [weak self] _ in + Task { @MainActor in + self?.syncDiscoveryState() + } + } + .store(in: &cancellables) + discovery.$currentStage + .sink { [weak self] stage in + Task { @MainActor in + guard let self, self.entryMode == .discover, self.state == .discovering else { + return + } + self.currentStage = stage + } + } + .store(in: &cancellables) + discovery.$error + .sink { [weak self] discoveryError in + Task { @MainActor in + guard let self, self.entryMode == .discover, self.discovery.state == .failed else { + return + } + self.error = discoveryError + } + } + .store(in: &cancellables) + } + + private func observeSetupWorkflow() { + setupWorkflow.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + setupWorkflow.$state + .sink { [weak self] workflowState in + Task { @MainActor in + self?.applySetupState(workflowState) + } + } + .store(in: &cancellables) + setupWorkflow.$currentStage + .sink { [weak self] stage in + Task { @MainActor in + guard let self, self.isSetupState else { + return + } + self.currentStage = stage + } + } + .store(in: &cancellables) + setupWorkflow.$error + .sink { [weak self] error in + Task { @MainActor in + guard let self, self.isSetupState else { + return + } + self.error = error + } + } + .store(in: &cancellables) + setupWorkflow.$savedProfile + .sink { [weak self] profile in + Task { @MainActor in + self?.savedProfile = profile + } + } + .store(in: &cancellables) + } + + private var isSetupState: Bool { + switch state { + case .configuring, .awaitingConfirmation, .savingProfile, .saved, .authFailed, .unsupported, .failed: + return true + default: + return false + } + } + + private func syncDiscoveryState() { + guard entryMode == .discover, !isSetupState else { + return + } + switch discovery.state { + case .idle, .waitingForReadiness, .paused, .readinessBlocked: + state = discovery.devices.isEmpty ? .idle : .discoveryReady + case .discovering: + state = .discovering + currentStage = discovery.currentStage + error = nil + case .empty: + selectedDeviceID = nil + manualHost = "" + state = .discoveryEmpty + currentStage = nil + error = nil + case .ready: + if let selectedDeviceID, + !discovery.devices.contains(where: { $0.id == selectedDeviceID }) { + self.selectedDeviceID = nil + } + if selectedDeviceID == nil, discovery.devices.count == 1 { + selectedDeviceID = discovery.devices[0].id + manualHost = discovery.devices[0].connectionTarget + } + state = discovery.devices.isEmpty ? .discoveryEmpty : .discoveryReady + currentStage = nil + error = nil + case .failed: + error = discovery.error + currentStage = nil + state = .failed + } + } + + private func applySetupState(_ workflowState: DeviceSetupWorkflowState) { + switch workflowState { + case .idle: + if state == .awaitingConfirmation { + state = .passwordEntry + } + case .configuring: + error = nil + currentStage = setupWorkflow.currentStage + state = .configuring + case .awaitingConfirmation: + error = nil + state = .awaitingConfirmation + case .savingProfile: + state = .savingProfile + case .saved: + savedProfile = setupWorkflow.savedProfile + error = nil + currentStage = nil + state = .saved + case .authFailed: + error = setupWorkflow.error + currentStage = nil + state = .authFailed + case .unsupported: + error = setupWorkflow.error + currentStage = nil + state = .unsupported + case .failed: + error = setupWorkflow.error + currentStage = nil + state = .failed + } + } + + 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 + } + + private static func timeoutText(_ value: Double) -> String { + guard value.rounded() == value else { + return String(value) + } + return String(Int(value)) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift new file mode 100644 index 00000000..497a8ccb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AddDevicePresentation.swift @@ -0,0 +1,38 @@ +import Foundation + +struct AddDeviceProgressPresentation: Equatable, BlockingProgressPresenting { + let title: String + let message: String + let detail: String? + + init?(state: AddDeviceFlowState, currentStage: OperationStageState?) { + switch state { + case .discovering: + self.title = L10n.string("add_device.progress.discovering.title") + self.message = L10n.string("add_device.progress.discovering.message") + self.detail = nil + case .configuring: + self.title = L10n.string("add_device.progress.configuring.title") + self.message = L10n.string("add_device.progress.configuring.message") + self.detail = currentStage.map { + OperationTimelineBuilder.stageDetail(for: $0.operation, stage: $0.stage, fallback: nil) + ?? OperationTimelineBuilder.stageTitle(for: $0.operation, stage: $0.stage) + } + case .savingProfile: + self.title = L10n.string("add_device.progress.saving.title") + self.message = L10n.string("add_device.progress.saving.message") + self.detail = nil + case .idle, + .discoveryEmpty, + .discoveryReady, + .manualEntry, + .passwordEntry, + .awaitingConfirmation, + .saved, + .authFailed, + .unsupported, + .failed: + return nil + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift new file mode 100644 index 00000000..44ceb160 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppReadinessStore.swift @@ -0,0 +1,405 @@ +import Combine +import Foundation + +enum AppReadinessStateKind: String, CaseIterable, Equatable { + case idle + case resolvingBundle + case checkingVersion + 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 .checkingVersion: + return L10n.string("app_readiness.state.checking_version") + 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 checkingVersion + 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 .checkingVersion: + return .checkingVersion + case .checkingCapabilities: + return .checkingCapabilities + case .validatingInstall: + return .validatingInstall + case .ready: + return .ready + case .degraded: + return .degraded + case .blocked: + return .blocked + } + } +} + +struct AppReadinessVersionCheck: Equatable { + var url: String + + func params() -> [String: JSONValue] { + OperationParams.Readiness.versionCheck(url: url) + } +} + +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 versionCheckPayload: VersionCheckPayload? + @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 versionCheck: AppReadinessVersionCheck? + private var pendingOperation: PendingReadinessOperation? + private let operationObserver = BackendOperationObserver() + 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 + .dropFirst() + .sink { [weak self] isRunning in + self?.objectWillChange.send() + guard !isRunning else { return } + Task { @MainActor in + self?.runPendingOperation() + } + } + .store(in: &cancellables) + } + + var canRetry: Bool { + !backend.isRunning + } + + func applyVersionCheck(_ versionCheck: AppReadinessVersionCheck?) { + self.versionCheck = versionCheck + } + + func start() { + guard !backend.isRunning else { return } + backend.clear() + capabilities = nil + validation = nil + versionCheckPayload = nil + issues = [] + currentStage = nil + pendingOperation = nil + operationObserver.clear() + 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 + )) + return + } + + if let versionCheck { + pendingOperation = PendingReadinessOperation(operation: "version-check", params: versionCheck.params()) + } else { + pendingOperation = PendingReadinessOperation(operation: "capabilities") + } + runPendingOperation() + } + + func clear() { + backend.clear() + capabilities = nil + validation = nil + versionCheckPayload = nil + issues = [] + currentStage = nil + pendingOperation = nil + operationObserver.clear() + state = .idle + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, _ in + handle(event) + } + } + + private func handle(_ event: BackendEvent) { + guard ["version-check", "capabilities", "validate-install"].contains(event.operation) else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + if event.operation == "version-check" { + issues.append(versionMetadataIssue(message: event.message ?? event.localizedSummary)) + pendingOperation = PendingReadinessOperation(operation: "capabilities") + operationObserver.finish() + runPendingOperation() + return + } + operationObserver.finish() + state = .blocked(issue(from: event)) + return + } + + guard event.type == "result" else { + return + } + + switch event.operation { + case "version-check": + applyVersionCheckResult(event) + case "capabilities": + applyCapabilitiesResult(event) + case "validate-install": + applyValidationResult(event) + default: + break + } + } + + private func applyVersionCheckResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(VersionCheckPayload.self) + versionCheckPayload = payload + guard event.ok == true else { + issues.append(versionMetadataIssue(message: payload.localizedSummary)) + pendingOperation = PendingReadinessOperation(operation: "capabilities") + operationObserver.finish() + runPendingOperation() + return + } + if payload.source == "unavailable" { + issues.append(versionMetadataIssue(message: payload.localizedSummary)) + } + guard !payload.shouldBlock else { + state = .blocked(BundleRuntimeIssue( + code: .unsupportedVersion, + severity: .error, + message: payload.message, + context: payload.downloadURL + )) + operationObserver.finish() + return + } + pendingOperation = PendingReadinessOperation(operation: "capabilities") + operationObserver.finish() + runPendingOperation() + } catch { + operationObserver.finish() + state = .blocked(contractIssue(operation: "version-check", error: error)) + } + } + + 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.localizedSummary + )) + operationObserver.finish() + return + } + pendingOperation = PendingReadinessOperation(operation: "validate-install") + operationObserver.finish() + runPendingOperation() + } catch { + operationObserver.finish() + 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.localizedSummary + )) + operationObserver.finish() + return + } + operationObserver.finish() + finishReady(validation: payload) + } catch { + operationObserver.finish() + 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.localizedSummary, + validationCounts: validation.counts + ) + let warnings = issues.filter { $0.severity == .warning } + state = warnings.isEmpty ? .ready(summary) : .degraded(summary, warnings) + } + + private func runPendingOperation() { + guard let pending = pendingOperation, !backend.isRunning else { + return + } + pendingOperation = nil + if pending.operation == "version-check" { + state = .checkingVersion + } else if pending.operation == "capabilities" { + state = .checkingCapabilities + } else if pending.operation == "validate-install" { + state = .validatingInstall + } + let activeOperation = ActiveOperation(operation: pending.operation, profileID: nil, context: nil) + operationObserver.start(activeOperation) + backend.run( + operation: pending.operation, + params: pending.params, + requestID: activeOperation.id.uuidString + ) + } + + 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.localizedSummary, + recovery: BackendErrorViewModel(event: event).recovery?.message + ) + } + + private func contractIssue(operation: String, error: Error) -> BundleRuntimeIssue { + BundleRuntimeIssue( + code: .contractDecodeFailed, + severity: .error, + message: L10n.format("app_readiness.error.unexpected_payload", operation, error.localizedDescription) + ) + } + + private func versionMetadataIssue(message: String) -> BundleRuntimeIssue { + BundleRuntimeIssue( + code: .versionMetadataUnavailable, + severity: .warning, + message: message + ) + } + + private func normalized(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +private struct PendingReadinessOperation { + let operation: String + let params: [String: JSONValue] + + init(operation: String, params: [String: JSONValue] = [:]) { + self.operation = operation + self.params = params + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift new file mode 100644 index 00000000..2457d800 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/AppUpdateStore.swift @@ -0,0 +1,141 @@ +import Combine +import Foundation + +enum AppUpdateState: String, Equatable { + case idle + case checking + case current + case unavailable + case updateAvailable + case failed + + var title: String { + switch self { + case .idle: + return L10n.string("app_update.state.idle") + case .checking: + return L10n.string("app_update.state.checking") + case .current: + return L10n.string("app_update.state.current") + case .unavailable: + return L10n.string("app_update.state.unavailable") + case .updateAvailable: + return L10n.string("app_update.state.update_available") + case .failed: + return L10n.string("app_update.state.failed") + } + } +} + +@MainActor +final class AppUpdateStore: ObservableObject { + @Published private(set) var state: AppUpdateState = .idle + @Published private(set) var payload: VersionCheckPayload? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let lane: OperationLane + + private let operationObserver = BackendOperationObserver() + private var cancellables: Set = [] + + init(coordinator: OperationCoordinator) { + self.lane = coordinator.lane(for: .localPath("app-update")) + lane.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + lane.backend.$isRunning + .dropFirst() + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + var isChecking: Bool { + lane.backend.isRunning + } + + func checkNow(settings: AppSettings) { + guard !lane.isBusy else { + state = .failed + error = BackendErrorViewModel( + operation: "version-check", + code: "operation_rejected", + message: L10n.string("operation.error.already_running") + ) + return + } + lane.clear() + operationObserver.clear() + state = .checking + payload = nil + error = nil + currentStage = nil + + let params = OperationParams.Readiness.versionCheck(url: settings.versionCheckURL) + switch lane.run(operation: "version-check", params: params, context: nil, activeDeviceID: nil) { + case .started(let operation): + operationObserver.start(operation) + process(lane.backend.events) + case .rejected(let message): + state = .failed + operationObserver.clear() + error = BackendErrorViewModel( + operation: "version-check", + code: "operation_rejected", + message: message + ) + } + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, _ in + handle(event) + } + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "version-check" else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + if event.type == "error" { + error = BackendErrorViewModel(event: event) + state = .failed + operationObserver.finish() + return + } + guard event.type == "result" else { + return + } + do { + let result = try event.decodePayload(VersionCheckPayload.self) + payload = result + if result.shouldBlock || result.updateAvailable { + state = .updateAvailable + } else if result.source == "unavailable" { + state = .unavailable + } else { + state = .current + } + error = nil + operationObserver.finish() + } catch { + self.error = BackendErrorViewModel( + operation: "version-check", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + operationObserver.finish() + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BackendOperationObserver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BackendOperationObserver.swift new file mode 100644 index 00000000..cc688cd8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BackendOperationObserver.swift @@ -0,0 +1,52 @@ +import Foundation + +@MainActor +final class BackendOperationObserver { + private(set) var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + + func start(_ operation: ActiveOperation) { + activeOperation = operation + lastProcessedEventCount = 0 + } + + func clear() { + activeOperation = nil + lastProcessedEventCount = 0 + } + + func ignoreExistingEvents(_ events: [BackendEvent]) { + lastProcessedEventCount = events.count + } + + func finish() { + activeOperation = nil + } + + func process( + _ events: [BackendEvent], + handler: (BackendEvent, ActiveOperation) -> Void + ) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + guard let activeOperation else { + lastProcessedEventCount = events.count + return + } + for event in events.dropFirst(lastProcessedEventCount) where accepts(event, for: activeOperation) { + handler(event, activeOperation) + } + lastProcessedEventCount = events.count + } + + private func accepts(_ event: BackendEvent, for activeOperation: ActiveOperation) -> Bool { + if let requestId = event.requestId { + return requestId == activeOperation.id.uuidString + } + return event.operation == activeOperation.operation + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BlockingProgressPresenting.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BlockingProgressPresenting.swift new file mode 100644 index 00000000..fe5d9a04 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/BlockingProgressPresenting.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol BlockingProgressPresenting { + var title: String { get } + var message: String { get } + var detail: String? { get } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift new file mode 100644 index 00000000..e73914eb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/CheckupPresentation.swift @@ -0,0 +1,242 @@ +import Foundation + +enum CheckupUserAction: String, Equatable, Identifiable { + case runCheckup + case installUpdate + case startSMB + case replacePassword + case openFinder + case viewDiagnostics + + var id: String { rawValue } + + var title: String { + switch self { + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .installUpdate: + return L10n.string("dashboard.action.install_update_smb") + case .startSMB: + return L10n.string("dashboard.action.start_smb") + case .replacePassword: + return L10n.string("dashboard.action.replace_password") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .viewDiagnostics: + return L10n.string("recovery.action.open_diagnostics") + } + } + + var systemImage: String { + switch self { + case .runCheckup: + return "stethoscope" + case .installUpdate: + return "square.and.arrow.down.on.square" + case .startSMB: + return "play.circle" + case .replacePassword: + return "key" + case .openFinder: + return "folder" + case .viewDiagnostics: + return "wrench.and.screwdriver" + } + } +} + +enum CheckupStatusPresentation: String, Equatable { + case passed + case warning + case failed + case info + case unknown + + init(status: String) { + switch status.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { + case "PASS": + self = .passed + case "WARN": + self = .warning + case "FAIL": + self = .failed + case "INFO": + self = .info + default: + self = .unknown + } + } + + init(severity: DoctorCheckSeverity) { + switch severity { + case .failed: + self = .failed + case .warning: + self = .warning + case .passed: + self = .passed + case .unknown: + self = .unknown + } + } + + var title: String { + switch self { + case .passed: + return L10n.string("checkup.status.passed") + case .warning: + return L10n.string("checkup.status.warning") + case .failed: + return L10n.string("checkup.status.failed") + case .info: + return L10n.string("checkup.status.info") + case .unknown: + return L10n.string("checkup.status.unknown") + } + } + + var systemImage: String { + switch self { + case .passed: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + case .info: + return "info.circle" + case .unknown: + return "questionmark.circle" + } + } +} + +struct CheckupRowPresentation: Equatable, Identifiable { + let id: String + let status: CheckupStatusPresentation + let statusText: String + let message: String + + init(index: Int, check: DoctorCheckPayload) { + self.id = "\(index):\(check.status):\(check.message)" + self.status = CheckupStatusPresentation(status: check.status) + self.statusText = check.status + self.message = check.message + } +} + +struct CheckupDomainPresentation: Equatable, Identifiable { + let domain: DoctorCheckDomain + let status: CheckupStatusPresentation + let countSummary: String + let rows: [CheckupRowPresentation] + + var id: String { domain.rawValue } + var title: String { domain.title } + + init(signal: DoctorDomainSignal) { + self.domain = signal.domain + self.status = CheckupStatusPresentation(severity: signal.severity) + self.countSummary = signal.countSummary + self.rows = signal.checks.enumerated().map { CheckupRowPresentation(index: $0.offset, check: $0.element) } + } +} + +struct CheckupPresentation: Equatable { + let title: String + let stateTitle: String + let headline: String + let primaryAction: CheckupUserAction? + let summaryRows: [PresentationRow] + let domains: [CheckupDomainPresentation] + let timeline: [OperationTimelineItem] + let hostWarning: HostCompatibilityWarning? + + init( + summary: DoctorSummary?, + state: DoctorWorkflowState, + events: [BackendEvent] = [], + currentStage: OperationStageState? = nil, + hostWarning: HostCompatibilityWarning? = nil + ) { + self.title = L10n.string("dashboard.tab.checkup") + self.stateTitle = state.title + self.headline = Self.headline(for: state) + self.primaryAction = state == .running ? nil : .runCheckup + self.summaryRows = summary.map(Self.summaryRows) ?? [] + self.domains = summary.map { DoctorCheckDomainPolicy.signals(from: $0).map(CheckupDomainPresentation.init) } ?? [] + self.timeline = Self.timeline(events: events, currentStage: currentStage, state: state) + self.hostWarning = hostWarning + } + + private static func headline(for state: DoctorWorkflowState) -> String { + switch state { + case .passed: + return L10n.string("checkup.presentation.headline.passed") + case .warning: + return L10n.string("checkup.presentation.headline.warning") + case .failed: + return L10n.string("checkup.presentation.headline.failed") + case .runFailed: + return L10n.string("checkup.presentation.headline.run_failed") + case .idle: + return L10n.string("checkup.presentation.headline.idle") + case .running: + return L10n.string("checkup.presentation.headline.running") + } + } + + private static func summaryRows(_ summary: DoctorSummary) -> [PresentationRow] { + [ + 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)") + ] + } + + private static func timeline( + events: [BackendEvent], + currentStage: OperationStageState?, + state: DoctorWorkflowState + ) -> [OperationTimelineItem] { + guard state == .running else { + return [] + } + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == "doctor" } + if items.isEmpty, let currentStage { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: OperationTimelineBuilder.stageTitle(for: currentStage.operation, stage: currentStage.stage), + detail: OperationTimelineBuilder.stageDetail( + for: currentStage.operation, + stage: currentStage.stage, + fallback: nil + ), + state: .running, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + return items + } +} + +struct CheckupProgressPresentation: Equatable, BlockingProgressPresenting { + let title: String + let message: String + let detail: String? + + init?(state: DoctorWorkflowState, currentStage: OperationStageState?) { + guard state == .running else { + return nil + } + self.title = L10n.string("checkup.progress.running.title") + self.message = L10n.string("checkup.progress.running.message") + self.detail = nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift new file mode 100644 index 00000000..b3934e40 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardOverviewPresentation.swift @@ -0,0 +1,608 @@ +import Foundation + +enum DashboardSecondaryAction: String, CaseIterable, Equatable, Hashable, Identifiable { + case refreshStatus + case runCheckup + case installUpdate + case openFinder + case replacePassword + case viewCheckup + case startSMB + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .refreshStatus: + return L10n.string("dashboard.action.refresh_status") + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .installUpdate: + return L10n.string("dashboard.action.install_update_smb") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .replacePassword: + return L10n.string("dashboard.action.replace_password") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .startSMB: + return L10n.string("dashboard.action.start_smb") + case .settings: + return L10n.string("dashboard.action.settings") + } + } + + var systemImage: String { + switch self { + case .refreshStatus: + return "arrow.clockwise" + case .runCheckup: + return "stethoscope" + case .installUpdate: + return "square.and.arrow.down.on.square" + case .openFinder: + return "folder" + case .replacePassword: + return "key" + case .viewCheckup: + return "list.bullet.clipboard" + case .startSMB: + return "play.circle" + case .settings: + return "gearshape" + } + } +} + +struct DeviceDashboardHeaderPresentation: Equatable { + let title: String + let connectionTarget: String + let addressSummary: String + let status: DeviceDisplayStatus + let lastChecked: String + let rows: [PresentationRow] + + init(summary: DeviceDashboardSummary) { + let profile = summary.profile + self.title = profile.title + self.connectionTarget = profile.displayTarget + self.addressSummary = profile.addressSummary + self.status = summary.displayStatus + let lastCheckedValue = profile.lastCheckup + .map { Self.formattedDate($0.checkedAt) } + ?? L10n.string("value.never") + self.lastChecked = L10n.format("dashboard.header.last_checked_value", lastCheckedValue) + self.rows = [ + PresentationRow(label: L10n.string("dashboard.overview.connection_target"), value: profile.connectionTarget), + PresentationRow(label: L10n.string("dashboard.overview.addresses"), value: profile.addressSummary.isEmpty ? L10n.string("value.unknown") : profile.addressSummary), + PresentationRow(label: L10n.string("dashboard.overview.model"), value: profile.model ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("dashboard.overview.generation"), value: Self.generationValue(for: profile)), + PresentationRow(label: L10n.string("dashboard.overview.payload"), value: profile.payloadFamily ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("dashboard.overview.password"), value: summary.passwordState.title), + PresentationRow(label: L10n.string("dashboard.overview.last_install"), value: Self.lastInstallValue(for: profile)) + ] + } + + private static func lastInstallValue(for profile: DeviceProfile) -> String { + profile.lastDeployState?.localizedSummary ?? L10n.string("value.never") + } + + private static func generationValue(for profile: DeviceProfile) -> String { + if let syapGeneration = generationFromSyAP(profile.syap) { + return syapGeneration + } + if let modelGeneration = generationFromModel(profile.model) { + return modelGeneration + } + if let coarseGeneration = generationFromCoarseValue(profile.deviceGeneration) { + return coarseGeneration + } + return L10n.string("value.unknown") + } + + private static func generationFromSyAP(_ syap: String?) -> String? { + guard let syap = syap?.trimmingCharacters(in: .whitespacesAndNewlines), !syap.isEmpty else { + return nil + } + return [ + "104": generationLabel(1), + "105": generationLabel(2), + "106": generationLabel(1), + "108": generationLabel(3), + "109": generationLabel(2), + "113": generationLabel(3), + "114": generationLabel(4), + "116": generationLabel(4), + "117": generationLabel(5), + "119": generationLabel(5), + "120": generationLabel(6) + ][syap] + } + + private static func generationFromModel(_ model: String?) -> String? { + guard let model else { + return nil + } + let pattern = #"([0-9]+)(?:st|nd|rd|th) generation"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let range = NSRange(model.startIndex.. String? { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "gen1", "tc_gen1": + return generationLabel(1) + case "gen2", "tc_gen2": + return generationLabel(2) + case "gen3", "tc_gen3": + return generationLabel(3) + case "gen4", "tc_gen4": + return generationLabel(4) + case "gen5", "tc_gen5": + return generationLabel(5) + case "gen6", "tc_gen6": + return generationLabel(6) + default: + return nil + } + } + + private static func generationLabel(_ generation: Int) -> String { + L10n.string("dashboard.generation.\(generation)") + } + + private static func formattedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = L10n.currentLanguage.locale + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } +} + +enum DashboardHealthDomain: String, CaseIterable, Equatable, Identifiable { + case connection + case runtime + case checkup + + var id: String { rawValue } + + var title: String { + switch self { + case .connection: + return L10n.string("dashboard.health.connection") + case .runtime: + return L10n.string("dashboard.health.runtime") + case .checkup: + return L10n.string("dashboard.health.checkup") + } + } +} + +enum DashboardHealthStatus: String, Equatable { + case unknown + case good + case warning + case failed + case running + + var title: String { + switch self { + case .unknown: + return L10n.string("dashboard.health.status.unknown") + case .good: + return L10n.string("dashboard.health.status.good") + case .warning: + return L10n.string("dashboard.health.status.warning") + case .failed: + return L10n.string("dashboard.health.status.failed") + case .running: + return L10n.string("dashboard.health.status.running") + } + } + + var systemImage: String { + switch self { + case .unknown: + return "questionmark.circle" + case .good: + return "checkmark.circle" + case .warning: + return "exclamationmark.triangle" + case .failed: + return "xmark.octagon" + case .running: + return "progress.indicator" + } + } +} + +struct DashboardHealthRow: Equatable, Identifiable { + let id: String + let title: String + let detail: String + let status: DashboardHealthStatus + let action: DashboardSecondaryAction? + + init( + id: String, + title: String, + detail: String, + status: DashboardHealthStatus, + action: DashboardSecondaryAction? = nil + ) { + self.id = id + self.title = title + self.detail = detail + self.status = status + self.action = action + } +} + +struct DashboardHealthSection: Equatable, Identifiable { + let domain: DashboardHealthDomain + let rows: [DashboardHealthRow] + + var id: String { domain.rawValue } + var title: String { domain.title } +} + +struct DeviceDashboardOverviewPresentation: Equatable { + let header: DeviceDashboardHeaderPresentation + let primaryAction: DashboardPrimaryAction + let isPrimaryActionEnabled: Bool + let secondaryActions: [DashboardSecondaryAction] + let disabledSecondaryActions: Set + let healthSections: [DashboardHealthSection] + let hostWarning: HostCompatibilityWarning? + let requiresPasswordReplacement: Bool + + init( + summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? = nil, + reachabilitySnapshot: DeviceReachabilitySnapshot? = nil, + isReachabilityRunning: Bool = false + ) { + let secondaryActions = DashboardActionPolicy.secondaryActions(for: summary) + self.header = DeviceDashboardHeaderPresentation(summary: summary) + self.primaryAction = summary.primaryAction + self.isPrimaryActionEnabled = DashboardActionPolicy.isEnabled(summary.primaryAction, for: summary) + && !(isReachabilityRunning && summary.primaryAction.isMutatingOverviewAction) + self.secondaryActions = secondaryActions + self.disabledSecondaryActions = Set(DashboardSecondaryAction.allCases.filter { + !DashboardActionPolicy.isEnabled($0, for: summary) + || (isReachabilityRunning && $0.isMutatingOverviewAction) + }) + self.healthSections = Self.healthSections( + for: summary, + currentCheckupSummary: currentCheckupSummary, + reachabilitySnapshot: reachabilitySnapshot, + isReachabilityRunning: isReachabilityRunning + ) + self.hostWarning = summary.hostWarning + self.requiresPasswordReplacement = DashboardActionPolicy.requiresPasswordReplacement(summary.passwordState) + } + + func isEnabled(_ action: DashboardSecondaryAction) -> Bool { + !disabledSecondaryActions.contains(action) + } + + private static func healthSections( + for summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary?, + reachabilitySnapshot: DeviceReachabilitySnapshot?, + isReachabilityRunning: Bool + ) -> [DashboardHealthSection] { + [ + DashboardHealthSection(domain: .connection, rows: [ + connectionRow( + for: summary, + reachabilitySnapshot: reachabilitySnapshot, + isReachabilityRunning: isReachabilityRunning + ) + ]), + DashboardHealthSection(domain: .runtime, rows: [runtimeRow(for: summary, currentCheckupSummary: currentCheckupSummary)]), + DashboardHealthSection(domain: .checkup, rows: [ + checkupRow(summary: summary, currentCheckupSummary: currentCheckupSummary) + ]) + ] + } + + private static func connectionRow( + for summary: DeviceDashboardSummary, + reachabilitySnapshot: DeviceReachabilitySnapshot?, + isReachabilityRunning: Bool + ) -> DashboardHealthRow { + switch summary.displayStatus { + case .checking, .installing, .maintaining: + return DashboardHealthRow( + id: "connection-running", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.running"), + status: .running, + action: .viewCheckup + ) + default: + break + } + + if isReachabilityRunning { + return DashboardHealthRow( + id: "connection-refreshing", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.refreshing"), + status: .running + ) + } + + switch summary.passwordState { + case .invalid: + return passwordIssueRow(id: "connection-password-invalid", detailKey: "dashboard.health.connection.password_invalid") + case .keychainUnavailable: + return passwordIssueRow(id: "connection-keychain-unavailable", detailKey: "dashboard.health.connection.keychain_unavailable") + case .available, .unknown, .missing: + break + } + + if let reachabilitySnapshot { + return reachabilityRow(from: reachabilitySnapshot) + } + + switch summary.passwordState { + case .available, .unknown, .missing: + return DashboardHealthRow( + id: "connection-not-refreshed", + title: DashboardHealthDomain.connection.title, + detail: L10n.string("dashboard.health.connection.not_refreshed"), + status: .unknown, + action: .refreshStatus + ) + case .invalid: + return passwordIssueRow(id: "connection-password-invalid", detailKey: "dashboard.health.connection.password_invalid") + case .keychainUnavailable: + return passwordIssueRow(id: "connection-keychain-unavailable", detailKey: "dashboard.health.connection.keychain_unavailable") + } + } + + private static func passwordIssueRow(id: String, detailKey: String) -> DashboardHealthRow { + DashboardHealthRow( + id: id, + title: DashboardHealthDomain.connection.title, + detail: L10n.string(detailKey), + status: .failed, + action: .replacePassword + ) + } + + private static func reachabilityRow(from snapshot: DeviceReachabilitySnapshot) -> DashboardHealthRow { + let status: DashboardHealthStatus + switch snapshot.payload.status.lowercased() { + case "reachable": + status = .good + case "partial": + status = .warning + case "unreachable": + status = .failed + default: + status = .unknown + } + return DashboardHealthRow( + id: "connection-reachability-\(snapshot.payload.status.lowercased())", + title: DashboardHealthDomain.connection.title, + detail: snapshot.payload.localizedSummary, + status: status, + action: .refreshStatus + ) + } + + private static func runtimeRow( + for summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? + ) -> DashboardHealthRow { + if summary.displayStatus == .installing { + return DashboardHealthRow( + id: "runtime-installing", + title: DashboardHealthDomain.runtime.title, + detail: L10n.string("dashboard.health.runtime.installing"), + status: .running + ) + } + if summary.displayStatus == .activationNeeded { + return DashboardHealthRow( + id: "runtime-activation-needed", + title: DashboardHealthDomain.runtime.title, + detail: L10n.string("dashboard.health.runtime.activation_needed"), + status: .warning, + action: .startSMB + ) + } + if let signal = checkupSignal(for: .runtime, summary: currentCheckupSummary) { + return DashboardHealthRow( + id: "runtime-checkup", + title: DashboardHealthDomain.runtime.title, + detail: signal.countSummary, + status: dashboardStatus(signal.severity), + action: dashboardStatus(signal.severity) == .good ? nil : .viewCheckup + ) + } + guard let runtimeState = summary.profile.runtimeState else { + return runtimeNotInstalledRow() + } + switch runtimeState.state { + case .unknown, .notInstalled: + return runtimeNotInstalledRow() + case .installing: + return DashboardHealthRow( + id: "runtime-installing-stored", + title: DashboardHealthDomain.runtime.title, + detail: runtimeState.localizedSummary, + status: .running + ) + case .installedVerified: + return DashboardHealthRow( + id: "runtime-installed", + title: DashboardHealthDomain.runtime.title, + detail: runtimeState.localizedSummary, + status: .good, + action: .openFinder + ) + case .installedUnverified: + return DashboardHealthRow( + id: "runtime-installed-unverified", + title: DashboardHealthDomain.runtime.title, + detail: runtimeState.localizedSummary, + status: .warning, + action: .runCheckup + ) + case .installFailed, .installInterrupted: + return DashboardHealthRow( + id: "runtime-install-failed", + title: DashboardHealthDomain.runtime.title, + detail: runtimeState.localizedSummary, + status: .failed, + action: .installUpdate + ) + case .activationNeeded: + return DashboardHealthRow( + id: "runtime-activation-needed", + title: DashboardHealthDomain.runtime.title, + detail: runtimeState.localizedSummary, + status: .warning, + action: .startSMB + ) + case .unhealthy: + return DashboardHealthRow( + id: "runtime-unhealthy", + title: DashboardHealthDomain.runtime.title, + detail: runtimeState.localizedSummary, + status: .failed, + action: .viewCheckup + ) + } + } + + private static func runtimeNotInstalledRow() -> DashboardHealthRow { + DashboardHealthRow( + id: "runtime-not-installed", + title: DashboardHealthDomain.runtime.title, + detail: L10n.string("dashboard.health.runtime.not_installed"), + status: .warning, + action: .installUpdate + ) + } + + private static func checkupRow( + summary: DeviceDashboardSummary, + currentCheckupSummary: DoctorSummary? + ) -> DashboardHealthRow { + if summary.displayStatus == .checking { + return DashboardHealthRow( + id: "checkup-running", + title: DashboardHealthDomain.checkup.title, + detail: L10n.string("checkup.presentation.headline.running"), + status: .running, + action: .viewCheckup + ) + } + if let signal = serviceCheckupSignal(summary: currentCheckupSummary) { + let status = dashboardStatus(signal.severity) + return DashboardHealthRow( + id: "checkup-current", + title: DashboardHealthDomain.checkup.title, + detail: signal.countSummary, + status: status, + action: status == .good ? nil : .viewCheckup + ) + } + if let hostWarning = summary.hostWarning { + return DashboardHealthRow( + id: "checkup-host-warning", + title: DashboardHealthDomain.checkup.title, + detail: hostWarning.message, + status: .warning + ) + } + guard let lastCheckup = summary.profile.lastCheckup else { + return DashboardHealthRow( + id: "checkup-unchecked", + title: DashboardHealthDomain.checkup.title, + detail: L10n.string("dashboard.health.unchecked"), + status: .unknown, + action: DashboardActionPolicy.checkupAction(for: summary) + ) + } + return DashboardHealthRow( + id: "checkup-snapshot", + title: DashboardHealthDomain.checkup.title, + detail: lastCheckup.localizedSummary, + status: snapshotStatus(lastCheckup), + action: snapshotStatus(lastCheckup) == .good ? nil : .viewCheckup + ) + } + + private static func checkupSignal( + for domain: DoctorCheckDomain, + summary: DoctorSummary? + ) -> DoctorDomainSignal? { + DoctorCheckDomainPolicy.signal(for: domain, summary: summary) + } + + private static func serviceCheckupSignal(summary: DoctorSummary?) -> DoctorDomainSignal? { + let domains: [DoctorCheckDomain] = [.finderBonjour, .smbAuth, .timeMachine] + let signals = domains.compactMap { checkupSignal(for: $0, summary: summary) } + guard !signals.isEmpty else { + return nil + } + return DoctorDomainSignal( + domain: .general, + checks: signals.flatMap(\.checks), + passCount: signals.map(\.passCount).reduce(0, +), + warnCount: signals.map(\.warnCount).reduce(0, +), + failCount: signals.map(\.failCount).reduce(0, +), + infoCount: signals.map(\.infoCount).reduce(0, +) + ) + } + + private static func dashboardStatus(_ severity: DoctorCheckSeverity) -> DashboardHealthStatus { + switch severity { + case .failed: + return .failed + case .warning: + return .warning + case .passed: + return .good + case .unknown: + return .unknown + } + } + + private static func snapshotStatus(_ snapshot: DeviceCheckupSnapshot) -> DashboardHealthStatus { + if snapshot.failCount > 0 || snapshot.state == .failed || snapshot.state == .runFailed { + return .failed + } + if snapshot.warnCount > 0 || snapshot.state == .warning { + return .warning + } + if snapshot.passCount > 0 || snapshot.state == .passed { + return .good + } + return .unknown + } +} + +private extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift new file mode 100644 index 00000000..468a041b --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardPresentation.swift @@ -0,0 +1,56 @@ +import Foundation + +enum DashboardPrimaryAction: String, Equatable { + case replacePassword + case runCheckup + case installSMB + case viewCheckup + case openSMB + + var title: String { + switch self { + 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_update_smb") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .openSMB: + return L10n.string("dashboard.action.open_smb") + } + } + + var systemImage: String { + switch self { + case .replacePassword: + return "key" + case .runCheckup: + return "stethoscope" + case .installSMB: + return "square.and.arrow.down.on.square" + case .viewCheckup: + return "list.bullet.clipboard" + case .openSMB: + return "folder" + } + } +} + +struct DeviceDashboardSummary: Equatable { + let profile: DeviceProfile + let passwordState: DevicePasswordState + let displayStatus: DeviceDisplayStatus + let primaryAction: DashboardPrimaryAction + let hostWarning: HostCompatibilityWarning? +} + +struct PresentationRow: Equatable, Identifiable { + var id: String { + "\(label):\(value)" + } + + let label: String + let value: String +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift new file mode 100644 index 00000000..40be3bfb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DashboardStore.swift @@ -0,0 +1,47 @@ +import Combine +import Foundation + +@MainActor +final class DashboardStore: ObservableObject { + let appStore: AppStore + + private var sessions: [DeviceProfile.ID: DeviceDashboardSession] = [:] + private var cancellables: Set = [] + + init(appStore: AppStore) { + self.appStore = appStore + appStore.deviceRegistry.$profiles + .sink { [weak self] profiles in + Task { @MainActor in + self?.pruneSessions(profiles: profiles) + } + } + .store(in: &cancellables) + appStore.operationCoordinator.$activeOperations + .sink { [weak self] _ in + Task { @MainActor in + guard let self else { return } + self.pruneSessions(profiles: self.appStore.deviceRegistry.profiles) + } + } + .store(in: &cancellables) + } + + func session(for profile: DeviceProfile) -> DeviceDashboardSession { + if let session = sessions[profile.id] { + return session + } + let session = DeviceDashboardSession(profile: profile, appStore: appStore) + sessions[profile.id] = session + objectWillChange.send() + return session + } + + private func pruneSessions(profiles: [DeviceProfile]) { + let existingIDs = Set(profiles.map(\.id)) + let activeProfileIDs = Set(appStore.operationCoordinator.activeOperations.values.compactMap(\.profileID)) + sessions = sessions.filter { id, _ in + existingIDs.contains(id) || activeProfileIDs.contains(id) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift new file mode 100644 index 00000000..90ddb5e9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeployWorkflowStore.swift @@ -0,0 +1,576 @@ +import Combine +import Foundation + +struct DeployOptions: Equatable { + let nbnsEnabled: Bool + let noReboot: Bool + let noWait: Bool + let internalShareUseDiskRoot: Bool + let anyProtocol: Bool + let debugLogging: Bool + let ataIdleSeconds: Int + let ataStandby: Int? + let mountWait: Int + + init( + nbnsEnabled: Bool, + noReboot: Bool, + noWait: Bool, + internalShareUseDiskRoot: Bool, + anyProtocol: Bool, + debugLogging: Bool, + ataIdleSeconds: Int = DeviceProfileSettings.default.ataIdleSeconds, + ataStandby: Int? = DeviceProfileSettings.default.ataStandby, + mountWait: Int + ) { + self.nbnsEnabled = nbnsEnabled + self.noReboot = noReboot + self.noWait = noWait + self.internalShareUseDiskRoot = internalShareUseDiskRoot + self.anyProtocol = anyProtocol + self.debugLogging = debugLogging + self.ataIdleSeconds = ataIdleSeconds + self.ataStandby = ataStandby + self.mountWait = mountWait + } +} + +enum DeployExecutionOptionPolicy { + static func allowsNoReboot(noWait: Bool) -> Bool { + !noWait + } + + static func allowsNoWait(noReboot: Bool) -> Bool { + !noReboot + } + + static func effectiveRebootOptions(noReboot: Bool, noWait: Bool) -> (noReboot: Bool, noWait: Bool) { + if noReboot { + return (true, false) + } + return (false, noWait) + } +} + +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 L10n.string("workflow.state.idle") + case .planning: + return L10n.string("workflow.state.planning") + case .planReady: + return L10n.string("workflow.state.plan_ready") + case .planStale: + return L10n.string("workflow.state.plan_stale") + case .planFailed: + return L10n.string("workflow.state.plan_failed") + case .deploying: + return L10n.string("workflow.state.deploying") + case .awaitingConfirmation: + return L10n.string("workflow.state.awaiting_confirmation") + case .deployed: + return L10n.string("workflow.state.deployed") + case .deployFailed: + return L10n.string("workflow.state.deploy_failed") + } + } +} + +@MainActor +final class DeployWorkflowStore: ObservableObject { + @Published var nbnsEnabled = true { + didSet { reconcilePlanFreshness() } + } + @Published var noReboot = false { + didSet { + if noReboot && noWait { + noWait = false + } + reconcilePlanFreshness() + } + } + @Published var noWait = false { + didSet { + if noWait && noReboot { + noReboot = false + } + reconcilePlanFreshness() + } + } + @Published var internalShareUseDiskRoot = false { + didSet { reconcilePlanFreshness() } + } + @Published var anyProtocol = false { + didSet { reconcilePlanFreshness() } + } + @Published var debugLogging = false { + didSet { reconcilePlanFreshness() } + } + @Published var ataIdleSeconds = String(DeviceProfileSettings.default.ataIdleSeconds) { + didSet { reconcilePlanFreshness() } + } + @Published var ataStandby = DeviceProfileSettings.default.ataStandby.map { String($0) } ?? "" { + didSet { reconcilePlanFreshness() } + } + @Published var mountWait = "30" { + didSet { reconcilePlanFreshness() } + } + + @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 let laneKey: OperationLaneKey? + + private let operationObserver = BackendOperationObserver() + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + self.coordinator = nil + self.laneKey = nil + observeBackend(backend) + } + + convenience init(coordinator: OperationCoordinator) { + self.init(coordinator: coordinator, laneKey: .app) + } + + init(coordinator: OperationCoordinator, laneKey: OperationLaneKey) { + let lane = coordinator.lane(for: laneKey) + self.backend = lane.backend + self.coordinator = coordinator + self.laneKey = laneKey + observeBackend(lane.backend) + } + + private func observeBackend(_ backend: BackendClient) { + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + backend.$isRunning + .dropFirst() + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + + var canCancel: Bool { + backend.canCancel + } + + var mountWaitValue: Int? { + ValueParsers.nonNegativeInteger(mountWait) + } + + var hasValidOptions: Bool { + deployOptionsValidationMessage == nil + } + + var canDeploy: Bool { + !isBusy && state == .planReady && plan != nil && currentOptions == plannedOptions + } + + @discardableResult + func runPlan(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard let options = currentOptions else { + let localError = deployOptionsValidationError ?? .deployOptionsInvalid + failLocally(state: .planFailed, localError: localError) + return .rejected(localError.message) + } + guard !isBusy else { + rejectRun(state: .planFailed, localError: .operationAlreadyRunning) + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + backend.clear() + let start = run( + operation: "deploy", + params: OperationParams.Deploy.params( + dryRun: true, + noReboot: options.noReboot, + noWait: options.noWait, + nbnsEnabled: options.nbnsEnabled, + internalShareUseDiskRoot: options.internalShareUseDiskRoot, + anyProtocol: options.anyProtocol, + debugLogging: options.debugLogging, + ataIdleSeconds: options.ataIdleSeconds, + ataStandby: options.ataStandby, + mountWait: Double(options.mountWait) + ), + profile: profile, + password: password + ) + guard case .started(let operation) = start else { + if let message = start.rejectionMessage { + rejectRun(state: .planFailed, message: message) + } else { + rejectRun(state: .planFailed, localError: .operationCouldNotStart) + } + return start + } + operationObserver.start(operation) + state = .planning + plan = nil + result = nil + error = nil + currentStage = nil + plannedOptions = options + passwordInvalidProfileID = nil + process(backend.events) + 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", localError: .deployPlanStale) + return .rejected(WorkflowLocalError.deployPlanStale.message) + } + guard state == .planReady else { + return .rejected(WorkflowLocalError.deployPlanNotReady.message) + } + guard !isBusy else { + rejectRun(state: .deployFailed, localError: .operationAlreadyRunning) + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + backend.clear() + let start = run( + operation: "deploy", + params: OperationParams.Deploy.params( + dryRun: false, + noReboot: options.noReboot, + noWait: options.noWait, + nbnsEnabled: options.nbnsEnabled, + internalShareUseDiskRoot: options.internalShareUseDiskRoot, + anyProtocol: options.anyProtocol, + debugLogging: options.debugLogging, + ataIdleSeconds: options.ataIdleSeconds, + ataStandby: options.ataStandby, + mountWait: Double(options.mountWait) + ), + profile: profile, + password: password + ) + guard case .started(let operation) = start else { + if let message = start.rejectionMessage { + rejectRun(state: .deployFailed, message: message) + } else { + rejectRun(state: .deployFailed, localError: .operationCouldNotStart) + } + return start + } + operationObserver.start(operation) + state = .deploying + result = nil + error = nil + currentStage = nil + passwordInvalidProfileID = nil + process(backend.events) + return start + } + + func clear() { + backend.clear() + operationObserver.clear() + state = .idle + plan = nil + result = nil + error = nil + currentStage = nil + plannedOptions = nil + passwordInvalidProfileID = nil + operationObserver.finish() + } + + func cancel() { + backend.cancel() + } + + private var currentOptions: DeployOptions? { + guard let mountWaitValue, let ataIdleSecondsValue, hasValidAtaStandby else { + return nil + } + let rebootOptions = DeployExecutionOptionPolicy.effectiveRebootOptions(noReboot: noReboot, noWait: noWait) + return DeployOptions( + nbnsEnabled: nbnsEnabled, + noReboot: rebootOptions.noReboot, + noWait: rebootOptions.noWait, + internalShareUseDiskRoot: internalShareUseDiskRoot, + anyProtocol: anyProtocol, + debugLogging: debugLogging, + ataIdleSeconds: ataIdleSecondsValue, + ataStandby: ataStandbyValue, + mountWait: mountWaitValue + ) + } + + private var ataIdleSecondsValue: Int? { + ValueParsers.nonNegativeInteger(ataIdleSeconds) + } + + private var ataStandbyValue: Int? { + let trimmed = ataStandby.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return ValueParsers.nonNegativeInteger(trimmed) + } + + private var hasValidAtaStandby: Bool { + ataStandby.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || ataStandbyValue != nil + } + + private var deployOptionsValidationError: WorkflowLocalError? { + if mountWaitValue == nil { + return .mountWaitInvalid + } + if ataIdleSecondsValue == nil { + return .ataIdleSecondsInvalid + } + if !hasValidAtaStandby { + return .ataStandbyInvalid + } + return nil + } + + private var deployOptionsValidationMessage: String? { + deployOptionsValidationError?.message + } + + private func reconcilePlanFreshness() { + guard plan != nil, state == .planReady || state == .planStale else { + return + } + if currentOptions == plannedOptions { + state = .planReady + if error?.code == WorkflowLocalError.deployPlanStale.code { + error = nil + } + } else { + state = .planStale + } + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, operation in + handle(event, activeOperation: operation) + } + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard event.operation == "deploy" else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .deploying + } + return + } + + if event.type == "error" { + applyError(event, activeOperation: activeOperation) + 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 + operationObserver.finish() + state = .planReady + reconcilePlanFreshness() + } catch { + failContract(state: .planFailed, error: error) + } + } + + private func applyDeployResult(_ event: BackendEvent) { + do { + result = try event.decodePayload(DeployResultPayload.self) + error = nil + state = .deployed + operationObserver.finish() + } catch { + failContract(state: .deployFailed, error: error) + } + } + + private func applyError(_ event: BackendEvent, activeOperation: ActiveOperation) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "confirmation_cancelled" { + applyConfirmationCancelled() + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation.profileID + } + error = BackendErrorViewModel(event: event) + state = state == .planning ? .planFailed : .deployFailed + operationObserver.finish() + } + + private func applyConfirmationCancelled() { + error = nil + currentStage = nil + operationObserver.finish() + guard plan != nil else { + state = .idle + return + } + state = .planReady + reconcilePlanFreshness() + } + + private func applyFailureResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: "deploy", + code: "operation_failed", + message: event.localizedPayloadSummaryText ?? event.localizedSummary + ) + state = state == .planning ? .planFailed : .deployFailed + operationObserver.finish() + } + + private func failContract(state: DeployWorkflowState, error: Error) { + self.error = BackendErrorViewModel( + operation: "deploy", + code: "contract_decode_failed", + message: error.localizedDescription + ) + self.state = state + operationObserver.finish() + } + + private func failLocally(state: DeployWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: "deploy", + code: "validation_failed", + message: message + ) + currentStage = nil + self.state = state + operationObserver.finish() + } + + private func failLocally(state: DeployWorkflowState, localError: WorkflowLocalError) { + error = BackendErrorViewModel(operation: "deploy", localError: localError) + currentStage = nil + self.state = state + operationObserver.finish() + } + + private func rejectRun(state: DeployWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: "deploy", + code: "operation_rejected", + message: message + ) + currentStage = nil + self.state = state + operationObserver.finish() + } + + private func rejectRun(state: DeployWorkflowState, localError: WorkflowLocalError) { + error = BackendErrorViewModel(operation: "deploy", localError: localError) + currentStage = nil + self.state = state + operationObserver.finish() + } + + private func run( + operation: String, + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + if let coordinator { + return coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + password: password, + laneKey: laneKey + ) + } else { + guard !isBusy else { + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + let updatedParams = OperationCredentialInjector.injectingPassword(password, into: params) + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run( + operation: operation, + params: updatedParams, + context: context, + requestID: activeOperation.id.uuidString + ) + return .started(activeOperation) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift new file mode 100644 index 00000000..becbfe8d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSession.swift @@ -0,0 +1,409 @@ +import Combine +import Foundation + +@MainActor +final class DeviceDashboardSession: ObservableObject, Identifiable { + let id: DeviceProfile.ID + @Published var selectedTab: DeviceDashboardTab = .overview + + let appStore: AppStore + var deployStore: DeployWorkflowStore + var doctorStore: DoctorStore + var maintenanceStore: MaintenanceStore + var flashStore: FlashWorkflowStore + let profileEditorStore: DeviceProfileEditorStore + + private let urlOpener: URLOpening + private let smbAccountResolver: SMBAccountResolving + private let lane: OperationLane + private let stateSynchronizer: DeviceDashboardStateSynchronizer + private var cancellables: Set = [] + + var events: [BackendEvent] { + lane.backend.events + } + + init( + profile: DeviceProfile, + appStore: AppStore, + urlOpener: URLOpening = WorkspaceURLOpener(), + smbAccountResolver: SMBAccountResolving = KeychainSMBAccountResolver() + ) { + self.id = profile.id + self.appStore = appStore + self.urlOpener = urlOpener + self.smbAccountResolver = smbAccountResolver + let configureLaneKey = OperationLaneKey.deviceWorkflow(profile.id, .configure) + self.lane = appStore.operationCoordinator.lane(for: configureLaneKey) + self.deployStore = DeployWorkflowStore( + coordinator: appStore.operationCoordinator, + laneKey: .deviceWorkflow(profile.id, .deploy) + ) + self.doctorStore = DoctorStore( + coordinator: appStore.operationCoordinator, + laneKey: .deviceWorkflow(profile.id, .doctor) + ) + self.maintenanceStore = MaintenanceStore( + coordinator: appStore.operationCoordinator, + laneKey: .deviceWorkflow(profile.id, .maintenance) + ) + self.flashStore = FlashWorkflowStore( + coordinator: appStore.operationCoordinator, + laneKey: .deviceWorkflow(profile.id, .flash) + ) + self.profileEditorStore = DeviceProfileEditorStore(profile: profile, appStore: appStore) + self.stateSynchronizer = DeviceDashboardStateSynchronizer( + appStore: appStore, + doctorStore: doctorStore, + deployStore: deployStore, + maintenanceStore: maintenanceStore, + flashStore: flashStore + ) + applyProfileSettings(profile.settings) + forwardChildChanges() + forwardLaneEvents() + observeProfileEditor() + } + + func summary(for profile: DeviceProfile) -> DeviceDashboardSummary { + appStore.dashboardSummary(for: profile) + } + + func performPrimaryAction(_ action: DashboardPrimaryAction, profile: DeviceProfile) { + switch action { + case .replacePassword: + showPasswordReplacement() + case .runCheckup: + runCheckup(profile: profile) + case .installSMB: + runInstallPlan(profile: profile) + case .viewCheckup: + selectedTab = .checkup + case .openSMB: + openSMBAddress(for: profile) + } + } + + func performSecondaryAction(_ action: DashboardSecondaryAction, profile: DeviceProfile) { + switch action { + case .refreshStatus: + refreshReachability(profile: profile) + case .runCheckup: + runCheckup(profile: profile) + case .installUpdate: + runInstallPlan(profile: profile) + case .openFinder: + openSMBAddress(for: profile) + case .replacePassword: + showPasswordReplacement() + case .viewCheckup: + selectedTab = .checkup + case .startSMB: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .activate + case .settings: + selectedTab = .settings + } + } + + func performInstallAction(_ action: InstallUserAction, profile: DeviceProfile, showDiagnostics: () -> Void) { + switch action { + case .createPlan, .regeneratePlan, .reinstall: + runInstallPlan(profile: profile) + case .installUpdate: + runInstall(profile: profile) + case .openFinder: + openSMBAddress(for: profile) + case .runCheckup: + runCheckup(profile: profile) + case .viewCheckup: + selectedTab = .checkup + case .viewDiagnostics: + showDiagnostics() + } + } + + func performCheckupAction(_ action: CheckupUserAction, profile: DeviceProfile, showDiagnostics: () -> Void) { + switch action { + case .runCheckup: + runCheckup(profile: profile) + case .installUpdate: + runInstallPlan(profile: profile) + case .startSMB: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .activate + case .replacePassword: + showPasswordReplacement() + case .openFinder: + openSMBAddress(for: profile) + case .viewDiagnostics: + showDiagnostics() + } + } + + func performMaintenanceAction(_ action: MaintenanceUserAction, profile: DeviceProfile, showDiagnostics: () -> Void) { + switch action { + case .planActivation: + if let password = maintenancePassword(for: profile) { + maintenanceStore.planActivation(password: password, profile: profile) + } + case .runActivation: + if let password = maintenancePassword(for: profile) { + let start = maintenanceStore.runActivation(password: password, profile: profile) + stateSynchronizer.invalidateCheckupIfStarted(start) + } + case .planUninstall: + if let password = maintenancePassword(for: profile) { + maintenanceStore.planUninstall(password: password, profile: profile) + } + case .runUninstall: + if let password = maintenancePassword(for: profile) { + let start = maintenanceStore.runUninstall(password: password, profile: profile) + if case .started(let operation) = start { + stateSynchronizer.trackUninstallStart(operation) + } + stateSynchronizer.invalidateCheckupIfStarted(start) + } + case .findVolumes: + if let password = maintenancePassword(for: profile) { + maintenanceStore.refreshFsckTargets(password: password, profile: profile) + } + case .planFsck: + if let password = maintenancePassword(for: profile) { + maintenanceStore.planFsck(password: password, profile: profile) + } + case .runFsck: + if let password = maintenancePassword(for: profile) { + let start = maintenanceStore.runFsck(password: password, profile: profile) + stateSynchronizer.invalidateCheckupIfStarted(start) + } + case .scanMetadata: + selectedTab = .maintenance + maintenanceStore.scanRepairXattrs() + case .repairMetadata: + selectedTab = .maintenance + maintenanceStore.runRepairXattrs() + case .viewDiagnostics: + showDiagnostics() + } + } + + func performFlashAction(_ action: FlashUserAction, profile: DeviceProfile) { + switch action { + case .backupAndInspect: + if let password = maintenancePassword(for: profile) { + flashStore.backupAndInspect(password: password, profile: profile) + } + case .planPatch: + flashStore.planFlash(mode: .patch, profile: profile) + case .planRestore: + flashStore.planFlash(mode: .restore, profile: profile) + case .checkApple: + flashStore.planFlash(mode: .checkApple, profile: profile) + case .downloadApple: + flashStore.planFlash(mode: .downloadOnly, profile: profile) + case .writePatch: + if let password = maintenancePassword(for: profile) { + let start = flashStore.write(mode: .patch, password: password, profile: profile) + stateSynchronizer.invalidateCheckupIfStarted(start) + } + case .writeRestore: + if let password = maintenancePassword(for: profile) { + let start = flashStore.write(mode: .restore, password: password, profile: profile) + stateSynchronizer.invalidateCheckupIfStarted(start) + } + } + } + + func viewCheckupAfterFlashNotice() { + flashStore.dismissManualPowerCycleNotice() + selectedTab = .checkup + } + + func runCheckup(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + promptForPasswordReplacement(error: L10n.string("password.error.required")) + return + } + profileEditorStore.clearPasswordAttention() + selectedTab = .checkup + if case .started(let operation) = doctorStore.runDoctor(password: password, profile: profile) { + stateSynchronizer.trackCheckupStart(operation) + } + } + + func runInstallPlan(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + promptForPasswordReplacement(error: L10n.string("password.error.required")) + return + } + profileEditorStore.clearPasswordAttention() + selectedTab = .install + deployStore.runPlan(password: password, profile: profile) + } + + func runInstall(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + promptForPasswordReplacement(error: L10n.string("password.error.required")) + return + } + profileEditorStore.clearPasswordAttention() + selectedTab = .install + if case .started(let operation) = deployStore.runDeploy(password: password, profile: profile) { + stateSynchronizer.trackDeployStart(operation, profile: profile) + } + } + + func refreshReachability(profile: DeviceProfile) { + appStore.reachabilityStore.refresh(profile: profile, password: appStore.password(for: profile)) + } + + func maintenancePassword(for profile: DeviceProfile) -> String? { + guard let password = appStore.password(for: profile) else { + promptForPasswordReplacement(error: L10n.string("password.error.required")) + return nil + } + profileEditorStore.clearPasswordAttention() + 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: + showPasswordReplacement() + return true + case .openFinder: + openSMBAddress(for: profile) + return true + case .diagnostics, .copyDiagnostics, .generic: + return false + } + } + + private func showPasswordReplacement() { + promptForPasswordReplacement(error: nil) + } + + private func promptForPasswordReplacement(error: String?) { + profileEditorStore.requestPasswordReplacement(error: error) + selectedTab = .settings + } + + func applyProfileSettings(_ settings: DeviceProfileSettings) { + deployStore.nbnsEnabled = settings.nbnsEnabled + deployStore.internalShareUseDiskRoot = settings.internalShareUseDiskRoot + deployStore.anyProtocol = settings.anyProtocol + deployStore.debugLogging = settings.debugLogging + deployStore.ataIdleSeconds = String(settings.ataIdleSeconds) + deployStore.ataStandby = settings.ataStandby.map { String($0) } ?? "" + deployStore.mountWait = String(settings.mountWaitSeconds) + maintenanceStore.mountWait = String(settings.mountWaitSeconds) + } + + private func observeProfileEditor() { + profileEditorStore.$savedProfile + .compactMap { $0 } + .sink { [weak self] profile in + self?.applyProfileSettings(profile.settings) + } + .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) { + guard let url = SMBAddressPolicy.url(for: profile, account: smbAccountResolver.account(for: profile)) else { + return + } + urlOpener.open(url) + } + + 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) + flashStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + profileEditorStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + private func forwardLaneEvents() { + lane.backend.$events + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSnapshotMapper.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSnapshotMapper.swift new file mode 100644 index 00000000..1cafd006 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardSnapshotMapper.swift @@ -0,0 +1,247 @@ +import Foundation + +enum DeviceDashboardSnapshotMapper { + static func checkupSnapshot( + state: DoctorWorkflowState, + summary: DoctorSummary, + observedAt: Date + ) -> DeviceCheckupSnapshot { + DeviceCheckupSnapshot( + checkedAt: observedAt, + state: state, + passCount: summary.passCount, + warnCount: summary.warnCount, + failCount: summary.failCount, + summary: "" + ) + } + + static func runtimeStateFromCheckup( + profile: DeviceProfile?, + skipSSH: Bool, + state: DoctorWorkflowState, + summary: DoctorSummary + ) -> DeviceRuntimeStateSnapshot? { + guard !skipSSH, let profile else { + return nil + } + if profile.runtimeState?.state == .installing { + return nil + } + + let countSummary = L10n.format("summary.checkup_counts", summary.passCount, summary.warnCount, summary.failCount) + let payloadFamily = profile.runtimeState?.payloadFamily ?? profile.payloadFamily + switch state { + case .passed: + return DeviceRuntimeStateSnapshot( + state: .installedVerified, + source: .doctor, + stage: nil, + payloadFamily: payloadFamily, + verified: true, + summary: "", + errorCode: nil, + errorMessage: nil, + recovery: nil + ) + case .warning: + let runtimeAlreadyInstalled = profile.runtimeState?.state.isInstalled == true + let nextState: DeviceRuntimeState = profile.traits.needsActivationAfterReboot && runtimeAlreadyInstalled + ? .activationNeeded + : .installedUnverified + return DeviceRuntimeStateSnapshot( + state: nextState, + source: .doctor, + stage: nil, + payloadFamily: payloadFamily, + verified: false, + summary: countSummary, + errorCode: nil, + errorMessage: nil, + recovery: nil + ) + case .failed: + if summary.runtimeNotInstalled { + return DeviceRuntimeStateSnapshot( + state: .notInstalled, + source: .doctor, + stage: nil, + payloadFamily: payloadFamily, + verified: false, + summary: "", + errorCode: DoctorSummary.runtimeNotInstalledResultCode, + errorMessage: nil, + recovery: nil + ) + } + return DeviceRuntimeStateSnapshot( + state: .unhealthy, + source: .doctor, + stage: nil, + payloadFamily: payloadFamily, + verified: false, + summary: countSummary, + errorCode: "doctor_failed", + errorMessage: nil, + recovery: nil + ) + case .idle, .running, .runFailed: + return nil + } + } + + static func startedDeploySnapshots( + operation: ActiveOperation, + payloadFamily: String?, + stage: String?, + startedAt: Date + ) -> (deployState: DeviceDeployStateSnapshot, runtimeState: DeviceRuntimeStateSnapshot) { + ( + deployState: DeviceDeployStateSnapshot( + operationID: operation.id.uuidString, + startedAt: startedAt, + updatedAt: startedAt, + finishedAt: nil, + status: .deploying, + stage: stage, + payloadFamily: payloadFamily, + rebootRequested: nil, + verified: nil, + summary: "", + errorCode: nil, + errorMessage: nil, + recovery: nil + ), + runtimeState: DeviceRuntimeStateSnapshot( + state: .installing, + source: .deploy, + stage: stage, + payloadFamily: payloadFamily, + verified: nil, + summary: "", + errorCode: nil, + errorMessage: nil, + recovery: nil + ) + ) + } + + static func inProgressDeploySnapshots( + current: DeviceDeployStateSnapshot, + runtimeState: DeviceRuntimeStateSnapshot?, + status: DeviceDeployStateStatus, + stage: String?, + observedAt: Date + ) -> (deployState: DeviceDeployStateSnapshot, runtimeState: DeviceRuntimeStateSnapshot) { + ( + deployState: DeviceDeployStateSnapshot( + operationID: current.operationID, + startedAt: current.startedAt, + updatedAt: observedAt, + finishedAt: nil, + status: status, + stage: stage, + payloadFamily: current.payloadFamily, + rebootRequested: current.rebootRequested, + verified: current.verified, + summary: current.summary, + errorCode: current.errorCode, + errorMessage: current.errorMessage, + recovery: current.recovery + ), + runtimeState: DeviceRuntimeStateSnapshot( + state: .installing, + source: .deploy, + stage: stage, + payloadFamily: runtimeState?.payloadFamily ?? current.payloadFamily, + verified: runtimeState?.verified, + summary: runtimeState?.summary ?? "", + errorCode: runtimeState?.errorCode, + errorMessage: runtimeState?.errorMessage, + recovery: runtimeState?.recovery + ) + ) + } + + static func failedDeploySnapshots( + operation: ActiveOperation, + profile: DeviceProfile?, + stage: String?, + payloadFamily: String?, + error: BackendErrorViewModel?, + failedAt: Date + ) -> (deployState: DeviceDeployStateSnapshot, runtimeState: DeviceRuntimeStateSnapshot)? { + let current = profile?.lastDeployState + let errorCode = error?.code + let errorMessage = error?.message ?? L10n.string("install.state.deploy_failed") + let recovery = error?.recovery.map(DeviceRecoverySnapshot.init) + return ( + deployState: DeviceDeployStateSnapshot( + operationID: current?.operationID ?? operation.id.uuidString, + startedAt: current?.startedAt ?? failedAt, + updatedAt: failedAt, + finishedAt: failedAt, + status: .failed, + stage: stage, + payloadFamily: payloadFamily, + rebootRequested: nil, + verified: nil, + summary: "", + errorCode: errorCode, + errorMessage: errorMessage, + recovery: recovery + ), + runtimeState: DeviceRuntimeStateSnapshot( + state: .installFailed, + source: .deploy, + stage: stage, + payloadFamily: payloadFamily, + verified: false, + summary: "", + errorCode: errorCode, + errorMessage: errorMessage, + recovery: recovery + ) + ) + } + + static func succeededDeploySnapshots( + operation: ActiveOperation, + profile: DeviceProfile, + result: DeployResultPayload, + payloadFamily: String?, + stage: String?, + finishedAt: Date + ) -> (deployState: DeviceDeployStateSnapshot, runtimeState: DeviceRuntimeStateSnapshot) { + let runtimeState: DeviceRuntimeState = result.verified == true ? .installedVerified : .installedUnverified + let summary = result.message ?? "" + return ( + deployState: DeviceDeployStateSnapshot( + operationID: profile.lastDeployState?.operationID ?? operation.id.uuidString, + startedAt: profile.lastDeployState?.startedAt ?? finishedAt, + updatedAt: finishedAt, + finishedAt: finishedAt, + status: .succeeded, + stage: stage, + payloadFamily: payloadFamily, + rebootRequested: result.rebootRequested, + verified: result.verified, + summary: summary, + errorCode: nil, + errorMessage: nil, + recovery: nil + ), + runtimeState: DeviceRuntimeStateSnapshot( + state: runtimeState, + source: .deploy, + stage: stage, + payloadFamily: payloadFamily, + verified: result.verified, + summary: summary, + errorCode: nil, + errorMessage: nil, + recovery: nil + ) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardStateSynchronizer.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardStateSynchronizer.swift new file mode 100644 index 00000000..607ed485 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardStateSynchronizer.swift @@ -0,0 +1,302 @@ +import Combine +import Foundation + +@MainActor +final class DeviceDashboardStateSynchronizer { + private let appStore: AppStore + private let doctorStore: DoctorStore + private let deployStore: DeployWorkflowStore + private let maintenanceStore: MaintenanceStore + + private var activeCheckupOperation: ActiveOperation? + private var activeDeployOperation: ActiveOperation? + private var activeUninstallOperation: ActiveOperation? + private var cancellables: Set = [] + + init( + appStore: AppStore, + doctorStore: DoctorStore, + deployStore: DeployWorkflowStore, + maintenanceStore: MaintenanceStore, + flashStore: FlashWorkflowStore + ) { + self.appStore = appStore + self.doctorStore = doctorStore + self.deployStore = deployStore + self.maintenanceStore = maintenanceStore + observeSnapshots() + observeCredentialInvalidProfileIDs(doctorStore.$passwordInvalidProfileID) + observeCredentialInvalidProfileIDs(deployStore.$passwordInvalidProfileID) + observeCredentialInvalidProfileIDs(maintenanceStore.$passwordInvalidProfileID) + observeCredentialInvalidProfileIDs(flashStore.$passwordInvalidProfileID) + } + + func trackCheckupStart(_ operation: ActiveOperation) { + activeCheckupOperation = operation + } + + func trackDeployStart(_ operation: ActiveOperation, profile: DeviceProfile) { + activeDeployOperation = operation + persistStartedDeployState(operation: operation, profile: profile) + invalidateCheckup(for: operation) + } + + func trackUninstallStart(_ operation: ActiveOperation) { + activeUninstallOperation = operation + } + + func invalidateCheckupIfStarted(_ start: OperationStartResult) { + guard case .started(let operation) = start else { + return + } + invalidateCheckup(for: operation) + } + + private func observeSnapshots() { + doctorStore.$state + .sink { [weak self] state in + Task { @MainActor in + self?.updateCheckupSnapshot(state: state) + } + } + .store(in: &cancellables) + deployStore.$state + .sink { [weak self] state in + Task { @MainActor in + self?.updateDeployState(state: state) + } + } + .store(in: &cancellables) + deployStore.$currentStage + .sink { [weak self] _ in + Task { @MainActor in + self?.updateCurrentDeployStage() + } + } + .store(in: &cancellables) + maintenanceStore.$uninstallState + .sink { [weak self] state in + Task { @MainActor in + self?.updateUninstallSnapshot(state: state) + } + } + .store(in: &cancellables) + } + + private func observeCredentialInvalidProfileIDs(_ publisher: Published.Publisher) { + publisher + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor [weak self] in + guard let self else { return } + await self.appStore.profilePersistence.markCredentialInvalid(profileID: profileID) + } + } + .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 + } + let observedAt = Date() + let profile = appStore.deviceRegistry.profile(id: profileID) + let runtimeState = DeviceDashboardSnapshotMapper.runtimeStateFromCheckup( + profile: profile, + skipSSH: doctorStore.skipSSH, + state: state, + summary: summary + ) + Task { + await appStore.deviceRegistry.updateCheckup( + DeviceDashboardSnapshotMapper.checkupSnapshot( + state: state, + summary: summary, + observedAt: observedAt + ), + runtimeState: runtimeState, + for: profileID + ) + } + } + + private func persistStartedDeployState(operation: ActiveOperation, profile: DeviceProfile) { + let startedAt = Date() + let payloadFamily = deployStore.plan?.payloadFamily ?? profile.payloadFamily + let stage = deployStore.currentStage?.stage + let snapshots = DeviceDashboardSnapshotMapper.startedDeploySnapshots( + operation: operation, + payloadFamily: payloadFamily, + stage: stage, + startedAt: startedAt + ) + Task { + await appStore.deviceRegistry.updateInstallOperationState( + deployState: snapshots.deployState, + runtimeState: snapshots.runtimeState, + for: profile.id + ) + } + } + + private func updateCurrentDeployStage() { + guard [.deploying, .awaitingConfirmation].contains(deployStore.state), + let operation = activeDeployOperation, + let profileID = operation.profileID else { + return + } + let observedAt = Date() + Task { + guard let profile = appStore.deviceRegistry.profile(id: profileID), + let current = profile.lastDeployState, + current.operationID == operation.id.uuidString, + current.status.isInProgress else { + return + } + let stage = deployStore.currentStage?.stage ?? current.stage + let snapshots = DeviceDashboardSnapshotMapper.inProgressDeploySnapshots( + current: current, + runtimeState: profile.runtimeState, + status: current.status, + stage: stage, + observedAt: observedAt + ) + await appStore.deviceRegistry.updateInstallOperationState( + deployState: snapshots.deployState, + runtimeState: snapshots.runtimeState, + for: profileID + ) + } + } + + private func updateDeployState(state: DeployWorkflowState) { + guard let operation = activeDeployOperation, + let profileID = operation.profileID else { + return + } + if state == .awaitingConfirmation { + persistAwaitingConfirmationDeployState(profileID: profileID) + return + } + guard [.deployed, .deployFailed].contains(state) else { + return + } + defer { + activeDeployOperation = nil + } + if state == .deployFailed { + persistFailedDeployState(operation: operation, profileID: profileID) + return + } + persistSucceededDeployState(operation: operation, profileID: profileID) + } + + private func persistFailedDeployState(operation: ActiveOperation, profileID: DeviceProfile.ID) { + Task { + let failedAt = Date() + let profile = appStore.deviceRegistry.profile(id: profileID) + let stage = deployStore.currentStage?.stage + let payloadFamily = profile?.lastDeployState?.payloadFamily + ?? deployStore.plan?.payloadFamily + ?? profile?.payloadFamily + guard let snapshots = DeviceDashboardSnapshotMapper.failedDeploySnapshots( + operation: operation, + profile: profile, + stage: stage, + payloadFamily: payloadFamily, + error: deployStore.error, + failedAt: failedAt + ) else { + return + } + await appStore.deviceRegistry.updateInstallOperationState( + deployState: snapshots.deployState, + runtimeState: snapshots.runtimeState, + for: profileID + ) + } + } + + private func persistSucceededDeployState(operation: ActiveOperation, profileID: DeviceProfile.ID) { + guard let profile = appStore.deviceRegistry.profile(id: profileID), + let result = deployStore.result else { + return + } + Task { + let finishedAt = Date() + let stage = deployStore.currentStage?.stage ?? profile.lastDeployState?.stage + let payloadFamily = deployStore.plan?.payloadFamily ?? profile.payloadFamily + let snapshots = DeviceDashboardSnapshotMapper.succeededDeploySnapshots( + operation: operation, + profile: profile, + result: result, + payloadFamily: payloadFamily, + stage: stage, + finishedAt: finishedAt + ) + await appStore.deviceRegistry.updateInstallOperationState( + deployState: snapshots.deployState, + runtimeState: snapshots.runtimeState, + for: profile.id + ) + } + } + + private func persistAwaitingConfirmationDeployState(profileID: DeviceProfile.ID) { + Task { + guard let profile = appStore.deviceRegistry.profile(id: profileID), + let current = profile.lastDeployState, + current.status.isInProgress else { + return + } + let observedAt = Date() + let stage = deployStore.currentStage?.stage ?? current.stage + let snapshots = DeviceDashboardSnapshotMapper.inProgressDeploySnapshots( + current: current, + runtimeState: profile.runtimeState, + status: .awaitingConfirmation, + stage: stage, + observedAt: observedAt + ) + await appStore.deviceRegistry.updateInstallOperationState( + deployState: snapshots.deployState, + runtimeState: snapshots.runtimeState, + for: profileID + ) + } + } + + private func invalidateCheckup(for operation: ActiveOperation) { + guard let profileID = operation.profileID else { + return + } + doctorStore.invalidateResult() + Task { + await appStore.deviceRegistry.clearCheckup(for: profileID) + } + } + + private func updateUninstallSnapshot(state: MaintenanceOperationState) { + guard [.succeeded, .failed].contains(state) else { + return + } + defer { + activeUninstallOperation = nil + } + guard state == .succeeded, + let profileID = activeUninstallOperation?.profileID else { + return + } + Task { + await appStore.deviceRegistry.clearInstallState(for: profileID) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift new file mode 100644 index 00000000..03bda0d0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDashboardTab.swift @@ -0,0 +1,26 @@ +import Foundation + +enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { + case overview + case install + case checkup + case maintenance + case settings + + 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 .settings: + return L10n.string("dashboard.tab.settings") + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryStore.swift new file mode 100644 index 00000000..f8abcad6 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceDiscoveryStore.swift @@ -0,0 +1,279 @@ +import Combine +import Foundation + +enum DeviceDiscoveryState: String, CaseIterable, Equatable { + case idle + case waitingForReadiness + case discovering + case empty + case ready + case paused + case readinessBlocked + case failed + + var title: String { + switch self { + case .idle: + return L10n.string("discovery_monitor.state.idle") + case .waitingForReadiness: + return L10n.string("discovery_monitor.state.waiting_for_readiness") + case .discovering: + return L10n.string("discovery_monitor.state.discovering") + case .empty: + return L10n.string("discovery_monitor.state.empty") + case .ready: + return L10n.string("discovery_monitor.state.ready") + case .paused: + return L10n.string("discovery_monitor.state.paused") + case .readinessBlocked: + return L10n.string("discovery_monitor.state.readiness_blocked") + case .failed: + return L10n.string("discovery_monitor.state.failed") + } + } +} + +@MainActor +final class DeviceDiscoveryStore: ObservableObject { + @Published private(set) var state: DeviceDiscoveryState = .idle + @Published private(set) var devices: [DiscoveredDevice] = [] + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let coordinator: OperationCoordinator + let readinessStore: AppReadinessStore? + let registry: DeviceRegistryStore + private let lane: OperationLane + + private var timeout: Double + private var isMonitoring = false + private var pendingRefresh = false + private let operationObserver = BackendOperationObserver() + private var cancellables: Set = [] + + init( + coordinator: OperationCoordinator, + readinessStore: AppReadinessStore? = nil, + registry: DeviceRegistryStore, + timeout: Double = AppSettings.default.defaultBonjourTimeoutSeconds + ) { + self.coordinator = coordinator + self.readinessStore = readinessStore + self.registry = registry + self.timeout = timeout + self.lane = coordinator.appLane + + readinessStore?.$state + .sink { [weak self] _ in + Task { @MainActor in + self?.handleReadinessChange() + } + } + .store(in: &cancellables) + lane.backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + Task { @MainActor in + self?.resumePendingRefreshIfNeeded() + } + } + .store(in: &cancellables) + lane.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + registry.$profiles + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + var unsavedDevices: [DiscoveredDevice] { + devices.filter { matchingProfile(for: $0) == nil } + } + + var savedDevices: [DiscoveredDevice] { + devices.filter { matchingProfile(for: $0) != nil } + } + + func startMonitoring() { + guard !isMonitoring else { + return + } + isMonitoring = true + handleReadinessChange() + } + + func refresh() { + guard isMonitoring else { + isMonitoring = true + return handleReadinessChange() + } + runDiscoverWhenPossible() + } + + func refresh(timeout: Double) { + self.timeout = timeout + refresh() + } + + func applyAppSettings(_ settings: AppSettings) { + timeout = settings.defaultBonjourTimeoutSeconds + } + + func matchingProfile(for device: DiscoveredDevice) -> DeviceProfile? { + registry.matchingProfile(for: device) + } + + func lastSeenText(for profile: DeviceProfile) -> String? { + guard state == .ready || state == .empty else { + return nil + } + let wasSeen = devices.contains { device in + matchingProfile(for: device)?.id == profile.id + } + return wasSeen ? L10n.string("discovery_monitor.last_seen.now") : nil + } + + private func handleReadinessChange() { + guard isMonitoring else { + return + } + guard let readinessStore else { + if devices.isEmpty && state != .discovering { + runDiscoverWhenPossible() + } + return + } + switch readinessStore.state.kind { + case .ready, .degraded: + if devices.isEmpty && state != .discovering { + runDiscoverWhenPossible() + } + case .blocked: + state = .readinessBlocked + pendingRefresh = false + default: + state = .waitingForReadiness + pendingRefresh = false + } + } + + private func runDiscoverWhenPossible() { + if let readinessStore { + switch readinessStore.state.kind { + case .ready, .degraded: + break + case .blocked: + state = .readinessBlocked + pendingRefresh = false + return + default: + state = .waitingForReadiness + pendingRefresh = false + return + } + } + + guard !lane.isBusy else { + if operationObserver.activeOperation == nil { + pendingRefresh = true + state = .paused + } + return + } + + lane.clear() + operationObserver.clear() + error = nil + currentStage = nil + switch coordinator.run( + operation: "discover", + params: OperationParams.Discovery.discover(timeout: timeout), + context: nil, + activeDeviceID: nil, + laneKey: .app + ) { + case .started(let operation): + operationObserver.start(operation) + state = .discovering + process(lane.backend.events) + case .rejected(let message): + operationObserver.clear() + error = BackendErrorViewModel( + operation: "discover", + code: "operation_rejected", + message: message + ) + state = .failed + } + } + + private func resumePendingRefreshIfNeeded() { + guard pendingRefresh else { + return + } + pendingRefresh = false + runDiscoverWhenPossible() + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, _ in + handle(event) + } + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "discover" else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + if event.type == "error" { + error = BackendErrorViewModel(event: event) + operationObserver.finish() + state = .failed + return + } + guard event.type == "result" else { + return + } + guard event.ok == true else { + error = BackendErrorViewModel( + operation: "discover", + code: "operation_failed", + message: event.localizedPayloadSummaryText ?? event.localizedSummary + ) + operationObserver.finish() + state = .failed + return + } + applyDiscoverResult(event) + } + + 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) + } + error = nil + operationObserver.finish() + state = devices.isEmpty ? .empty : .ready + } catch { + self.error = BackendErrorViewModel( + operation: "discover", + code: "contract_decode_failed", + message: error.localizedDescription + ) + operationObserver.finish() + state = .failed + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceReachabilityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceReachabilityStore.swift new file mode 100644 index 00000000..1c56d52c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceReachabilityStore.swift @@ -0,0 +1,158 @@ +import Combine +import Foundation + +struct DeviceReachabilitySnapshot: Equatable { + let refreshedAt: Date + let payload: ReachabilityPayload +} + +@MainActor +final class DeviceReachabilityStore: ObservableObject { + @Published private(set) var snapshots: [DeviceProfile.ID: DeviceReachabilitySnapshot] = [:] + @Published private(set) var errors: [DeviceProfile.ID: BackendErrorViewModel] = [:] + @Published private(set) var currentStages: [DeviceProfile.ID: OperationStageState] = [:] + + private let coordinator: OperationCoordinator + private let now: () -> Date + private var operationObservers: [DeviceProfile.ID: BackendOperationObserver] = [:] + private var observedProfiles: Set = [] + private var cancellablesByProfile: [DeviceProfile.ID: Set] = [:] + + init(coordinator: OperationCoordinator, now: @escaping () -> Date = Date.init) { + self.coordinator = coordinator + self.now = now + } + + func refresh(profile: DeviceProfile, password: String?) { + let laneKey = OperationLaneKey.deviceWorkflow(profile.id, .reachability) + let lane = coordinator.lane(for: laneKey) + observeLane(for: profile.id, lane: lane) + guard !lane.isBusy else { + operationObservers[profile.id]?.clear() + errors[profile.id] = BackendErrorViewModel( + operation: "reachability", + code: "operation_rejected", + message: L10n.string("operation.error.already_running") + ) + return + } + lane.clear() + errors[profile.id] = nil + currentStages[profile.id] = nil + observer(for: profile.id).clear() + switch coordinator.run( + operation: "reachability", + params: OperationParams.Reachability.check(profile: profile), + context: profile.runtimeContext, + activeDeviceID: profile.id, + password: password, + laneKey: laneKey + ) { + case .started(let operation): + observer(for: profile.id).start(operation) + process(lane.backend.events, profileID: profile.id) + case .rejected(let message): + operationObservers[profile.id]?.clear() + errors[profile.id] = BackendErrorViewModel( + operation: "reachability", + code: "operation_rejected", + message: message + ) + } + } + + func snapshot(for profile: DeviceProfile) -> DeviceReachabilitySnapshot? { + snapshots[profile.id] + } + + func error(for profile: DeviceProfile) -> BackendErrorViewModel? { + errors[profile.id] + } + + func currentStage(for profile: DeviceProfile) -> OperationStageState? { + currentStages[profile.id] + } + + func isRunning(profile: DeviceProfile) -> Bool { + operationObservers[profile.id]?.activeOperation != nil + || coordinator.activeOperation(for: .deviceWorkflow(profile.id, .reachability))?.operation == "reachability" + } + + private func observer(for profileID: DeviceProfile.ID) -> BackendOperationObserver { + if let observer = operationObservers[profileID] { + return observer + } + let observer = BackendOperationObserver() + operationObservers[profileID] = observer + return observer + } + + private func observeLane(for profileID: DeviceProfile.ID, lane: OperationLane) { + guard observedProfiles.insert(profileID).inserted else { + return + } + var cancellables: Set = [] + lane.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events, profileID: profileID) + } + } + .store(in: &cancellables) + lane.backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + Task { @MainActor in + self?.finishIfLaneStopped(profileID: profileID) + } + } + .store(in: &cancellables) + cancellablesByProfile[profileID] = cancellables + } + + private func process(_ events: [BackendEvent], profileID: DeviceProfile.ID) { + observer(for: profileID).process(events) { event, _ in + handle(event, profileID: profileID) + } + } + + private func handle(_ event: BackendEvent, profileID: DeviceProfile.ID) { + guard event.operation == "reachability" else { + return + } + if let stage = OperationStageState(event: event) { + currentStages[profileID] = stage + return + } + switch event.type { + case "result": + applyResult(event, profileID: profileID) + case "error": + errors[profileID] = BackendErrorViewModel(event: event) + operationObservers[profileID]?.finish() + default: + break + } + } + + private func applyResult(_ event: BackendEvent, profileID: DeviceProfile.ID) { + do { + let payload = try event.decodePayload(ReachabilityPayload.self) + snapshots[profileID] = DeviceReachabilitySnapshot(refreshedAt: now(), payload: payload) + errors[profileID] = nil + } catch { + errors[profileID] = BackendErrorViewModel( + operation: "reachability", + code: "contract_error", + message: error.localizedDescription + ) + } + operationObservers[profileID]?.finish() + } + + private func finishIfLaneStopped(profileID: DeviceProfile.ID) { + if coordinator.activeOperation(for: .deviceWorkflow(profileID, .reachability))?.operation != "reachability" { + operationObservers[profileID]?.finish() + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSetupWorkflow.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSetupWorkflow.swift new file mode 100644 index 00000000..c26548bb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSetupWorkflow.swift @@ -0,0 +1,365 @@ +import Combine +import Foundation + +enum DeviceSetupWorkflowState: Equatable { + case idle + case configuring + case awaitingConfirmation + case savingProfile + case saved + case authFailed + case unsupported + case failed +} + +@MainActor +final class DeviceSetupWorkflow: ObservableObject { + @Published private(set) var state: DeviceSetupWorkflowState = .idle + @Published private(set) var savedProfile: DeviceProfile? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let coordinator: OperationCoordinator + let profilePersistence: DeviceProfilePersistenceService + + private var pendingConfigureDraft: ConfigureProfileDraft? + private var activeLaneKey: OperationLaneKey? + private var operationObservers: [OperationLaneKey: BackendOperationObserver] = [:] + private var cancellables: Set = [] + private var observedLaneKeys: Set = [] + + init( + coordinator: OperationCoordinator, + profilePersistence: DeviceProfilePersistenceService + ) { + self.coordinator = coordinator + self.profilePersistence = profilePersistence + } + + var isRunning: Bool { + switch activeLaneKey { + case .some(let key): + return coordinator.lane(for: key).isBusy + case .none: + return false + } + } + + var canCancel: Bool { + guard let activeLaneKey else { + return false + } + return coordinator.lane(for: activeLaneKey).backend.canCancel + } + + func start( + target: AddDeviceTarget, + password: String, + existingProfile: DeviceProfile?, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased(), + settings: DeviceProfileSettings, + newProfileSettings: DeviceProfileSettings + ) { + let profileID = existingProfile?.id ?? preferredID + let laneKey = target.setupLaneKey(existingProfileID: existingProfile?.id) + let lane = coordinator.lane(for: laneKey) + observe(lane: lane) + + guard !lane.isBusy else { + clearPendingConfigureDraft() + rejectRun(L10n.string("operation.error.already_running")) + return + } + + let configureDraft: ConfigureProfileDraft + do { + configureDraft = try profilePersistence.prepareConfigureTarget( + targetHost: target.targetHost, + discoveredDevice: target.discoveredDevice, + existingProfile: existingProfile, + preferredID: profileID, + settings: settings + ) + } catch { + failProfileSave(error) + return + } + + resetRunState() + pendingConfigureDraft = configureDraft + pendingPassword = password + if configureDraft.existingProfileID == nil { + pendingNewProfileSettings = newProfileSettings + } + observer(for: laneKey).clear() + switch coordinator.run( + operation: "configure", + params: OperationParams.Configure.save( + host: target.targetHost, + selectedRecord: target.selectedRecord, + password: password, + debugLogging: settings.debugLogging, + internalShareUseDiskRoot: settings.internalShareUseDiskRoot, + anyProtocol: settings.anyProtocol, + ataIdleSeconds: settings.ataIdleSeconds, + ataStandby: settings.ataStandby, + includeAtaStandby: true + ), + context: configureDraft.context, + activeDeviceID: existingProfile?.id, + laneKey: laneKey + ) { + case .started(let operation): + activeLaneKey = laneKey + observer(for: laneKey).start(operation) + state = .configuring + process(lane.backend.events, laneKey: laneKey) + case .rejected(let message): + clearPendingConfigureDraft() + rejectRun(message) + } + + } + + func cancel() { + guard let activeLaneKey else { + return + } + coordinator.cancel(laneKey: activeLaneKey) + } + + func reset() { + if let activeLaneKey { + let lane = coordinator.lane(for: activeLaneKey) + if !lane.isBusy { + lane.clear() + } + } + savedProfile = nil + error = nil + currentStage = nil + clearPendingConfigureDraft() + activeLaneKey = nil + operationObservers = [:] + pendingNewProfileSettings = nil + state = .idle + } + + private var pendingNewProfileSettings: DeviceProfileSettings? + + private func observe(lane: OperationLane) { + guard observedLaneKeys.insert(lane.key).inserted else { + return + } + lane.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events, laneKey: lane.key) + } + } + .store(in: &cancellables) + lane.backend.$isRunning + .dropFirst() + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + private func resetRunState() { + if let activeLaneKey { + let lane = coordinator.lane(for: activeLaneKey) + if !lane.isBusy { + lane.clear() + } + observer(for: activeLaneKey).clear() + } + error = nil + currentStage = nil + savedProfile = nil + activeLaneKey = nil + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + } + + private func process(_ events: [BackendEvent], laneKey: OperationLaneKey) { + observer(for: laneKey).process(events) { event, _ in + handle(event) + } + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "configure" else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .configuring + } + return + } + if event.type == "error" { + applyError(event) + return + } + guard event.type == "result" else { + return + } + if event.ok == false { + failFromResult(event) + return + } + applyConfigureResult(event) + } + + private func applyConfigureResult(_ event: BackendEvent) { + let configured: ConfiguredDeviceState + do { + configured = ConfiguredDeviceState(payload: try event.decodePayload(ConfigurePayload.self)) + } catch { + failContract(error) + return + } + + state = .savingProfile + guard let configureDraft = pendingConfigureDraft else { + failContract(DeviceRegistryError.profileNotFound("pending")) + return + } + let overrides = ConfiguredDeviceProfileOverrides( + displayName: nil, + settings: pendingNewProfileSettings + ) + let savedPassword = pendingPassword + Task { @MainActor in + do { + savedProfile = try await profilePersistence.commitConfiguredProfile( + configuredDevice: configured, + draft: configureDraft, + password: savedPassword, + overrides: overrides + ) + error = nil + state = .saved + finishActiveOperation() + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + pendingPassword = "" + } catch { + failProfileSave(error) + } + } + } + + private var pendingPassword = "" + + private func applyError(_ event: BackendEvent) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "confirmation_cancelled" { + applyConfirmationCancelled() + return + } + error = BackendErrorViewModel(event: event) + switch event.code { + case "auth_failed": + state = .authFailed + case "unsupported_device": + state = .unsupported + default: + state = .failed + } + finishActiveOperation() + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + pendingPassword = "" + } + + private func applyConfirmationCancelled() { + error = nil + currentStage = nil + savedProfile = nil + finishActiveOperation() + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + state = .idle + } + + private func failFromResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.localizedPayloadSummaryText ?? event.localizedSummary + ) + state = .failed + finishActiveOperation() + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + pendingPassword = "" + } + + private func failContract(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "add-device", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + finishActiveOperation() + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + pendingPassword = "" + } + + private func failProfileSave(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "add-device", + code: "profile_save_failed", + message: error.localizedDescription + ) + state = .failed + finishActiveOperation() + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + pendingPassword = "" + } + + private func rejectRun(_ message: String) { + error = BackendErrorViewModel( + operation: "add-device", + code: "operation_rejected", + message: message + ) + currentStage = nil + state = .failed + finishActiveOperation() + clearPendingConfigureDraft() + pendingNewProfileSettings = nil + pendingPassword = "" + } + + private func observer(for laneKey: OperationLaneKey) -> BackendOperationObserver { + if let observer = operationObservers[laneKey] { + return observer + } + let observer = BackendOperationObserver() + operationObservers[laneKey] = observer + return observer + } + + private func finishActiveOperation() { + if let activeLaneKey { + operationObservers[activeLaneKey]?.finish() + } + activeLaneKey = nil + } + + private func clearPendingConfigureDraft() { + profilePersistence.discardConfigureDraft(pendingConfigureDraft) + pendingConfigureDraft = nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSidebarContextMenuPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSidebarContextMenuPresentation.swift new file mode 100644 index 00000000..d05a6c8f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DeviceSidebarContextMenuPresentation.swift @@ -0,0 +1,150 @@ +import Foundation + +enum DeviceSidebarContextMenuAction: String, Equatable, Hashable, Identifiable { + case openOverview + case openFinder + case runCheckup + case viewCheckup + case refreshStatus + case settings + case copySMBAddress + case copyHostname + case copyIPAddress + case removeFromThisMac + + var id: String { rawValue } + + var title: String { + switch self { + case .openOverview: + return L10n.string("sidebar.menu.open_overview") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .refreshStatus: + return L10n.string("dashboard.action.refresh_status") + case .settings: + return L10n.string("dashboard.action.settings") + case .copySMBAddress: + return L10n.string("sidebar.menu.copy_smb_address") + case .copyHostname: + return L10n.string("sidebar.menu.copy_hostname") + case .copyIPAddress: + return L10n.string("sidebar.menu.copy_ip_address") + case .removeFromThisMac: + return L10n.string("sidebar.menu.remove_from_this_mac") + } + } + + var systemImage: String { + switch self { + case .openOverview: + return "rectangle.grid.1x2" + case .openFinder: + return "folder" + case .runCheckup: + return "stethoscope" + case .viewCheckup: + return "list.bullet.clipboard" + case .refreshStatus: + return "arrow.clockwise" + case .settings: + return "gearshape" + case .copySMBAddress: + return "link" + case .copyHostname: + return "network" + case .copyIPAddress: + return "number" + case .removeFromThisMac: + return "trash" + } + } +} + +struct DeviceSidebarContextMenuItem: Equatable, Identifiable { + let action: DeviceSidebarContextMenuAction + let isEnabled: Bool + + var id: DeviceSidebarContextMenuAction { action } + var title: String { action.title } + var systemImage: String { action.systemImage } +} + +struct DeviceSidebarContextMenuPresentation: Equatable { + let navigationItems: [DeviceSidebarContextMenuItem] + let clipboardItems: [DeviceSidebarContextMenuItem] + let destructiveItems: [DeviceSidebarContextMenuItem] + private let clipboardValues: [DeviceSidebarContextMenuAction: String] + + init(profile: DeviceProfile, summary: DeviceDashboardSummary, isDeviceBusy: Bool) { + var navigationItems = [ + DeviceSidebarContextMenuItem(action: .openOverview, isEnabled: true) + ] + let smbAddress = SMBAddressPolicy.url(for: profile)?.absoluteString + let hostname = Self.hostname(for: profile) + let ipAddress = Self.ipAddress(for: profile) + navigationItems.append(DeviceSidebarContextMenuItem(action: .openFinder, isEnabled: smbAddress != nil)) + navigationItems.append(Self.checkupItem(summary: summary, isDeviceBusy: isDeviceBusy)) + navigationItems.append(DeviceSidebarContextMenuItem( + action: .refreshStatus, + isEnabled: !isDeviceBusy && DashboardActionPolicy.isEnabled(.refreshStatus, for: summary) + )) + navigationItems.append(DeviceSidebarContextMenuItem(action: .settings, isEnabled: true)) + self.navigationItems = navigationItems + + self.clipboardItems = [ + DeviceSidebarContextMenuItem(action: .copySMBAddress, isEnabled: smbAddress != nil), + DeviceSidebarContextMenuItem(action: .copyHostname, isEnabled: hostname != nil), + DeviceSidebarContextMenuItem(action: .copyIPAddress, isEnabled: ipAddress != nil) + ] + self.clipboardValues = [ + .copySMBAddress: smbAddress, + .copyHostname: hostname, + .copyIPAddress: ipAddress + ].compactMapValues { $0 } + + self.destructiveItems = [ + DeviceSidebarContextMenuItem(action: .removeFromThisMac, isEnabled: !isDeviceBusy) + ] + } + + func clipboardValue(for action: DeviceSidebarContextMenuAction) -> String? { + clipboardValues[action] + } + + private static func checkupItem( + summary: DeviceDashboardSummary, + isDeviceBusy: Bool + ) -> DeviceSidebarContextMenuItem { + if summary.displayStatus == .checking { + return DeviceSidebarContextMenuItem(action: .viewCheckup, isEnabled: true) + } + return DeviceSidebarContextMenuItem( + action: .runCheckup, + isEnabled: summary.passwordState == .available + && !isDeviceBusy + && DashboardActionPolicy.isEnabled(DashboardSecondaryAction.runCheckup, for: summary) + ) + } + + private static func hostname(for profile: DeviceProfile) -> String? { + [ + profile.hostname, + profile.host + ] + .compactMap(DeviceEndpointPolicy.normalizedHostname) + .first + } + + private static func ipAddress(for profile: DeviceProfile) -> String? { + let regular = profile.network.addresses.filter { $0.scope == .regular } + return regular.first { $0.family == .ipv4 }?.value + ?? regular.first { $0.family == .ipv6 }?.value + ?? profile.network.addresses.first { $0.family == .ipv4 }?.value + ?? profile.network.addresses.first { $0.family == .ipv6 }?.value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift new file mode 100644 index 00000000..2f4e8b27 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/DoctorStore.swift @@ -0,0 +1,333 @@ +import Combine +import Foundation + +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 L10n.string("workflow.state.idle") + case .running: + return L10n.string("workflow.state.running") + case .passed: + return L10n.string("workflow.state.passed") + case .warning: + return L10n.string("workflow.state.warning") + case .failed: + return L10n.string("workflow.state.failed") + case .runFailed: + return L10n.string("workflow.state.run_failed") + } + } +} + +struct DoctorCheckGroup: Identifiable, Equatable { + let domain: String + let checks: [DoctorCheckPayload] + + var id: String { + domain + } +} + +struct DoctorSummary: Equatable { + static let runtimeNotInstalledResultCode = "runtime_not_installed" + + let passCount: Int + let warnCount: Int + let failCount: Int + let infoCount: Int + let runtimeNotInstalled: Bool + 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.runtimeNotInstalled = Self.containsResultCode(Self.runtimeNotInstalledResultCode, 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 containsResultCode(_ code: String, in payload: DoctorPayload) -> Bool { + payload.results.contains { check in + check.details.stringValue(for: "code") == code + } + } + + 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 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 let laneKey: OperationLaneKey? + + private let operationObserver = BackendOperationObserver() + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + self.coordinator = nil + self.laneKey = nil + observeBackend(backend) + } + + convenience init(coordinator: OperationCoordinator) { + self.init(coordinator: coordinator, laneKey: .app) + } + + init(coordinator: OperationCoordinator, laneKey: OperationLaneKey) { + let lane = coordinator.lane(for: laneKey) + self.backend = lane.backend + self.coordinator = coordinator + self.laneKey = laneKey + observeBackend(lane.backend) + } + + private func observeBackend(_ backend: BackendClient) { + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + backend.$isRunning + .dropFirst() + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + + var canCancel: Bool { + backend.canCancel + } + + @discardableResult + func runDoctor(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !isBusy else { + rejectRun(.operationAlreadyRunning) + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + backend.clear() + let start = run( + operation: "doctor", + params: OperationParams.Doctor.run( + skipSSH: skipSSH, + skipBonjour: skipBonjour, + skipSMB: skipSMB + ), + profile: profile, + password: password + ) + guard case .started(let operation) = start else { + if let message = start.rejectionMessage { + rejectRun(message) + } else { + rejectRun(.operationCouldNotStart) + } + return start + } + operationObserver.start(operation) + state = .running + payload = nil + summary = nil + error = nil + currentStage = nil + passwordInvalidProfileID = nil + process(backend.events) + return start + } + + func clear() { + backend.clear() + operationObserver.clear() + clearResultState() + } + + func invalidateResult() { + operationObserver.ignoreExistingEvents(backend.events) + clearResultState() + } + + private func clearResultState() { + state = .idle + payload = nil + summary = nil + error = nil + currentStage = nil + passwordInvalidProfileID = nil + operationObserver.finish() + } + + func cancel() { + backend.cancel() + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, operation in + handle(event, activeOperation: operation) + } + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard event.operation == "doctor" 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 + operationObserver.finish() + 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 + } + operationObserver.finish() + } catch { + self.error = BackendErrorViewModel( + operation: "doctor", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .runFailed + operationObserver.finish() + } + } + + private func rejectRun(_ message: String) { + error = BackendErrorViewModel( + operation: "doctor", + code: "operation_rejected", + message: message + ) + currentStage = nil + state = .runFailed + operationObserver.finish() + } + + private func rejectRun(_ localError: WorkflowLocalError) { + error = BackendErrorViewModel(operation: "doctor", localError: localError) + currentStage = nil + state = .runFailed + operationObserver.finish() + } + + private func run( + operation: String, + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + if let coordinator { + return coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + password: password, + laneKey: laneKey + ) + } else { + guard !isBusy else { + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + let updatedParams = OperationCredentialInjector.injectingPassword(password, into: params) + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run( + operation: operation, + params: updatedParams, + context: context, + requestID: activeOperation.id.uuidString + ) + return .started(activeOperation) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift new file mode 100644 index 00000000..a219c583 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashPresentation.swift @@ -0,0 +1,214 @@ +import Foundation + +enum FlashUserAction: String, Hashable, Identifiable { + case backupAndInspect + case planPatch + case planRestore + case checkApple + case downloadApple + case writePatch + case writeRestore + + var id: String { rawValue } + + var title: String { + switch self { + case .backupAndInspect: + return L10n.string("flash.action.backup_inspect") + case .planPatch: + return L10n.string("flash.action.plan_patch") + case .planRestore: + return L10n.string("flash.action.plan_restore") + case .checkApple: + return L10n.string("flash.action.check_apple") + case .downloadApple: + return L10n.string("flash.action.download_apple") + case .writePatch: + return L10n.string("flash.action.write_patch") + case .writeRestore: + return L10n.string("flash.action.write_restore") + } + } + + var systemImage: String { + switch self { + case .backupAndInspect: + return "externaldrive.badge.questionmark" + case .planPatch, .planRestore: + return "doc.text.magnifyingglass" + case .checkApple: + return "checkmark.seal" + case .downloadApple: + return "checkmark.shield" + case .writePatch: + return "bolt.trianglebadge.exclamationmark" + case .writeRestore: + return "arrow.uturn.backward.circle" + } + } + + static func planAction(for mode: FlashPlanMode) -> FlashUserAction { + switch mode { + case .patch: + return .planPatch + case .restore: + return .planRestore + case .checkApple: + return .checkApple + case .downloadOnly: + return .downloadApple + } + } +} + +struct FlashPresentation: Equatable { + let title: String + let message: String + let stateTitle: String + let primaryActions: [FlashUserAction] + let secondaryActions: [FlashUserAction] + let enabledActions: Set + let rows: [PresentationRow] + let warnings: [String] + private let backupActionTitle: String + + @MainActor + init(store: FlashWorkflowStore) { + self.title = L10n.string("flash.title") + self.message = store.error?.message ?? store.writeResult?.localizedSummary ?? store.plan?.localizedSummary ?? store.backup?.localizedSummary ?? store.eligibilityMessage + self.stateTitle = store.state.title + self.primaryActions = [.backupAndInspect, .planPatch, .planRestore, .writePatch, .writeRestore] + self.secondaryActions = [.checkApple, .downloadApple] + self.enabledActions = Self.enabledActions(store: store) + self.rows = Self.rows(store: store) + self.warnings = Self.warnings(store: store) + self.backupActionTitle = store.backupSnapshotStale + ? L10n.string("flash.action.backup_inspect_again") + : L10n.string("flash.action.backup_inspect") + } + + func isEnabled(_ action: FlashUserAction) -> Bool { + enabledActions.contains(action) + } + + func title(for action: FlashUserAction) -> String { + action == .backupAndInspect ? backupActionTitle : action.title + } + + @MainActor + private static func enabledActions(store: FlashWorkflowStore) -> Set { + var actions: Set = [] + if store.canBackup { + actions.insert(.backupAndInspect) + } + if store.canPlanWrites { + actions.formUnion([.planPatch, .planRestore]) + } + if store.canPlan { + actions.formUnion([.checkApple, .downloadApple]) + } + if store.canWritePatch { + actions.insert(.writePatch) + } + if store.canWriteRestore { + actions.insert(.writeRestore) + } + return actions + } + + @MainActor + private static func rows(store: FlashWorkflowStore) -> [PresentationRow] { + var rows: [PresentationRow] = [] + if let backup = store.backup { + rows.append(PresentationRow(label: L10n.string("flash.row.backup_dir"), value: backup.backupDir)) + rows.append(PresentationRow(label: L10n.string("flash.row.active_bank"), value: backup.activeBank ?? L10n.string("value.unknown"))) + rows.append(PresentationRow(label: L10n.string("flash.row.banks"), value: "\(backup.banks.count)")) + } + if let plan = store.plan { + rows.append(PresentationRow(label: L10n.string("flash.row.mode"), value: plan.mode.title)) + rows.append(PresentationRow(label: L10n.string("flash.row.write_requested"), value: plan.writeRequested ? L10n.string("value.yes") : L10n.string("value.no"))) + if let match = plan.appleFirmwareMatch { + rows.append(PresentationRow(label: L10n.string("flash.row.apple_match"), value: match.matched ? L10n.string("value.yes") : L10n.string("value.no"))) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_version"), value: match.templateVersion) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_product"), value: match.templateProductID) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_source"), value: match.templateSource) + appendIfPresent(&rows, label: L10n.string("flash.row.apple_payload_sha256"), value: match.innerSHA256) + } + if let payload = plan.firmwarePayload { + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_version"), value: payload.templateVersion) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_product"), value: payload.templateProductID) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_source"), value: payload.templateSource) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_payload_path"), value: plan.firmwarePayloadPath) + appendIfPresent(&rows, label: L10n.string("flash.row.firmware_payload_sha256"), value: payload.payloadSHA256) + if let payloadSize = payload.payloadSize { + rows.append(PresentationRow(label: L10n.string("flash.row.firmware_payload_size"), value: Self.byteCount(payloadSize))) + } + } + } + if let result = store.writeResult { + rows.append(PresentationRow(label: L10n.string("flash.row.write_status"), value: result.writeStatus)) + rows.append(PresentationRow(label: L10n.string("flash.row.write_validated"), value: result.writeValidated ? L10n.string("value.yes") : L10n.string("value.no"))) + } + return rows + } + + private static func appendIfPresent(_ rows: inout [PresentationRow], label: String, value: String?) { + guard let value else { + return + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + rows.append(PresentationRow(label: label, value: trimmed)) + } + + private static func byteCount(_ value: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file) + } + + @MainActor + private static func warnings(store: FlashWorkflowStore) -> [String] { + var warnings: [String] = [] + if store.backupSnapshotStale { + warnings.append(L10n.string("flash.warning.snapshot_stale")) + } + if store.manualPowerCycleRequiredAfterWrite { + warnings.append(L10n.string("flash.warning.manual_power_cycle")) + } + return warnings + } +} + +extension FlashManualPowerCycleNotice { + var title: String { + L10n.string("flash.manual_power_cycle.title") + } + + var message: String { + L10n.string("flash.manual_power_cycle.message") + } + + var actionTitle: String { + L10n.string("action.ok") + } + + var viewCheckupActionTitle: String { + L10n.string("dashboard.action.view_checkup") + } +} + +extension FlashPlanMode { + var title: String { + switch self { + case .patch: + return L10n.string("flash.mode.patch") + case .restore: + return L10n.string("flash.mode.restore") + case .checkApple: + return L10n.string("flash.mode.check_apple") + case .downloadOnly: + return L10n.string("flash.mode.download_only") + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift new file mode 100644 index 00000000..37fd41cf --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FlashWorkflowStore.swift @@ -0,0 +1,702 @@ +import Combine +import Foundation + +enum FlashBuildPolicy: String, CaseIterable, Equatable { + case disabled + case readOnly + case writesEnabled +} + +enum FlashPlanMode: String, Codable, CaseIterable, Equatable, Identifiable { + case patch + case restore + case checkApple = "check_apple" + case downloadOnly = "download_only" + + var id: String { rawValue } + + var writesFirmware: Bool { + self == .patch || self == .restore + } +} + +enum FlashWorkflowState: String, CaseIterable, Equatable { + case unavailable + case disabledInThisBuild + case eligibleForReadOnlyAnalysis + case readingBanks + case savingBackup + case analyzingBanks + case planAvailable + case appleCheckComplete + case appleFirmwareMismatch + case appleFirmwareReady + case writeLocked + case awaitingStrongConfirmation + case writing + case readbackValidating + case writeValidated + case writeValidatedSnapshotStale + case manualPowerCycleRequired + case restoreRebooting + case failed + + var title: String { + switch self { + case .unavailable: + return L10n.string("workflow.state.unavailable") + case .disabledInThisBuild: + return L10n.string("workflow.state.disabled_in_this_build") + case .eligibleForReadOnlyAnalysis: + return L10n.string("workflow.state.read_only_analysis_available") + case .readingBanks: + return L10n.string("workflow.state.reading_firmware_banks") + case .savingBackup: + return L10n.string("workflow.state.saving_backup") + case .analyzingBanks: + return L10n.string("workflow.state.analyzing_firmware") + case .planAvailable: + return L10n.string("workflow.state.plan_available") + case .appleCheckComplete: + return L10n.string("workflow.state.apple_check_complete") + case .appleFirmwareMismatch: + return L10n.string("workflow.state.apple_firmware_mismatch") + case .appleFirmwareReady: + return L10n.string("workflow.state.apple_firmware_ready") + case .writeLocked: + return L10n.string("workflow.state.ready") + case .awaitingStrongConfirmation: + return L10n.string("workflow.state.awaiting_confirmation") + case .writing: + return L10n.string("workflow.state.writing_firmware") + case .readbackValidating: + return L10n.string("workflow.state.validating_write") + case .writeValidated: + return L10n.string("workflow.state.write_validated") + case .writeValidatedSnapshotStale: + return L10n.string("workflow.state.snapshot_stale") + case .manualPowerCycleRequired: + return L10n.string("workflow.state.manual_power_cycle_required") + case .restoreRebooting: + return L10n.string("workflow.state.rebooting_after_restore") + case .failed: + return L10n.string("workflow.state.failed") + } + } +} + +struct FlashEligibility: Equatable { + let state: FlashWorkflowState + let messageKey: String + let readOnlyAllowed: Bool + let writeAllowed: Bool + + var message: String { + L10n.string(messageKey) + } +} + +enum FlashEligibilityPolicy { + static func eligibility(for profile: DeviceProfile, buildPolicy: FlashBuildPolicy = .writesEnabled) -> FlashEligibility { + guard profile.traits.supportsFlashBootHook else { + return FlashEligibility( + state: .unavailable, + messageKey: "flash.eligibility.netbsd4_required", + readOnlyAllowed: false, + writeAllowed: false + ) + } + + switch buildPolicy { + case .disabled: + return FlashEligibility( + state: .disabledInThisBuild, + messageKey: "flash.eligibility.disabled", + readOnlyAllowed: false, + writeAllowed: false + ) + case .readOnly: + return FlashEligibility( + state: .eligibleForReadOnlyAnalysis, + messageKey: "flash.eligibility.read_only", + readOnlyAllowed: true, + writeAllowed: false + ) + case .writesEnabled: + return FlashEligibility( + state: .writeLocked, + messageKey: "flash.eligibility.write_ready", + readOnlyAllowed: true, + writeAllowed: true + ) + } + } +} + +enum FlashBootHookVisibilityPolicy { + static func isVisible(for profile: DeviceProfile) -> Bool { + profile.traits.supportsFlashBootHook + } +} + +struct FlashManualPowerCycleNotice: Identifiable, Equatable { + let id = UUID() + let mode: FlashPlanMode +} + +private struct FlashFirmwareSelection: Equatable { + let version: String + let templatePath: String +} + +@MainActor +final class FlashWorkflowStore: ObservableObject { + @Published private(set) var state: FlashWorkflowState = .writeLocked + @Published private(set) var backup: FlashBackupPayload? + @Published private(set) var plan: FlashPlanPayload? + @Published private(set) var writeResult: FlashWritePayload? + @Published private(set) var backupSnapshotStale = false + @Published private(set) var manualPowerCycleNotice: FlashManualPowerCycleNotice? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + @Published var firmwareVersion = "" { + didSet { + invalidatePlanIfFirmwareSelectionChanged() + } + } + @Published var firmwareTemplatePath = "" { + didSet { + invalidatePlanIfFirmwareSelectionChanged() + } + } + + let buildPolicy: FlashBuildPolicy + let backend: BackendClient + private let coordinator: OperationCoordinator? + private let laneKey: OperationLaneKey? + private var eligibility = FlashEligibility( + state: .writeLocked, + messageKey: "flash.eligibility.write_ready", + readOnlyAllowed: true, + writeAllowed: true + ) + private let operationObserver = BackendOperationObserver() + private var activeAction: FlashUserAction? + private var pendingFirmwareSelection: FlashFirmwareSelection? + private var plannedFirmwareSelection: FlashFirmwareSelection? + private var cancellables: Set = [] + + convenience init(buildPolicy: FlashBuildPolicy = .writesEnabled) { + self.init(backend: BackendClient(), buildPolicy: buildPolicy) + } + + init(backend: BackendClient, buildPolicy: FlashBuildPolicy = .writesEnabled) { + self.backend = backend + self.coordinator = nil + self.laneKey = nil + self.buildPolicy = buildPolicy + observeBackend(backend) + } + + convenience init(coordinator: OperationCoordinator, laneKey: OperationLaneKey, buildPolicy: FlashBuildPolicy = .writesEnabled) { + let lane = coordinator.lane(for: laneKey) + self.init(backend: lane.backend, coordinator: coordinator, laneKey: laneKey, buildPolicy: buildPolicy) + } + + private init( + backend: BackendClient, + coordinator: OperationCoordinator?, + laneKey: OperationLaneKey?, + buildPolicy: FlashBuildPolicy + ) { + self.backend = backend + self.coordinator = coordinator + self.laneKey = laneKey + self.buildPolicy = buildPolicy + observeBackend(backend) + } + + private func observeBackend(_ backend: BackendClient) { + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + backend.$isRunning + .dropFirst() + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + + var canBackup: Bool { + !isBusy && eligibility.readOnlyAllowed + } + + var canPlan: Bool { + !isBusy && eligibility.readOnlyAllowed && backup != nil && !backupSnapshotStale + } + + var canPlanWrites: Bool { + canPlan && eligibility.writeAllowed + } + + var canWritePatch: Bool { + canWrite(mode: .patch) + } + + var canWriteRestore: Bool { + canWrite(mode: .restore) + } + + var eligibilityMessage: String { + eligibility.message + } + + var manualPowerCycleRequiredAfterWrite: Bool { + guard let writeResult else { + return false + } + return Self.requiresManualPowerCycleAfterWrite(writeResult) + } + + func refresh(profile: DeviceProfile) { + eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: buildPolicy) + if backup == nil, plan == nil, writeResult == nil, operationObserver.activeOperation == nil { + state = eligibility.state + } + } + + @discardableResult + func backupAndInspect(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard canBackup else { + return reject(.flashBackupUnavailable) + } + let start = startRun( + action: .backupAndInspect, + params: OperationParams.Flash.backup(), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .readingBanks + backup = nil + plan = nil + writeResult = nil + backupSnapshotStale = false + pendingFirmwareSelection = nil + plannedFirmwareSelection = nil + process(backend.events) + return start + } + + @discardableResult + func planFlash(mode: FlashPlanMode, profile: DeviceProfile? = nil) -> OperationStartResult { + guard canPlan else { + return reject(.flashBackupRequired) + } + if mode.writesFirmware, !canPlanWrites { + return reject(.flashWritesDisabled) + } + guard let backupDir = backup?.backupDir else { + return reject(.flashBackupRequired) + } + let action = FlashUserAction.planAction(for: mode) + let selection = currentFirmwareSelection + let start = startRun( + action: action, + params: OperationParams.Flash.plan( + backupDir: backupDir, + mode: mode, + firmwareVersion: selection.version, + firmwareTemplate: selection.templatePath + ), + profile: profile + ) + guard case .started = start else { + return start + } + pendingFirmwareSelection = selection + state = .analyzingBanks + plan = nil + writeResult = nil + process(backend.events) + return start + } + + @discardableResult + func write(mode: FlashPlanMode, password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard mode.writesFirmware else { + return reject(.flashModeReadOnly) + } + guard !isBusy else { + return reject(.operationAlreadyRunning) + } + guard let plan, plan.mode == mode, plan.writeRequested, let backupDir = backup?.backupDir else { + state = .writeLocked + return reject(.flashPlanRequired) + } + let selection = currentFirmwareSelection + guard plannedFirmwareSelection == selection else { + return reject(.flashPlanStale) + } + let action = mode == .patch ? FlashUserAction.writePatch : .writeRestore + let start = startRun( + action: action, + params: OperationParams.Flash.write( + backupDir: backupDir, + mode: mode, + firmwareVersion: selection.version, + firmwareTemplate: selection.templatePath + ), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .writing + writeResult = nil + process(backend.events) + return start + } + + func clear() { + backend.clear() + operationObserver.clear() + state = eligibility.state + backup = nil + plan = nil + writeResult = nil + backupSnapshotStale = false + manualPowerCycleNotice = nil + currentStage = nil + error = nil + passwordInvalidProfileID = nil + operationObserver.finish() + activeAction = nil + pendingFirmwareSelection = nil + plannedFirmwareSelection = nil + } + + func dismissManualPowerCycleNotice() { + manualPowerCycleNotice = nil + } + + private func canWrite(mode: FlashPlanMode) -> Bool { + !isBusy + && eligibility.writeAllowed + && plan?.mode == mode + && plan?.writeRequested == true + && backup != nil + && !backupSnapshotStale + && plannedFirmwareSelection == currentFirmwareSelection + && [.planAvailable, .failed].contains(state) + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, operation in + handle(event, activeOperation: operation) + } + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard event.operation == "flash" else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + state = stateForStage(stage.stage) + return + } + + if event.type == "error" { + applyError(event, activeOperation: activeOperation) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFalseResult(event) + return + } + applyResult(event) + } + + private func applyResult(_ event: BackendEvent) { + do { + switch activeAction { + case .backupAndInspect: + backup = try event.decodePayload(FlashBackupPayload.self) + backupSnapshotStale = false + plannedFirmwareSelection = nil + state = .planAvailable + case .planPatch, .planRestore, .checkApple, .downloadApple: + plan = try event.decodePayload(FlashPlanPayload.self) + if let plan { + plannedFirmwareSelection = pendingFirmwareSelection ?? currentFirmwareSelection + pendingFirmwareSelection = nil + if plannedFirmwareSelection == currentFirmwareSelection { + state = Self.stateAfterPlan(plan) + } else { + self.plan = nil + state = backup == nil ? eligibility.state : .planAvailable + } + } + case .writePatch, .writeRestore: + let result = try event.decodePayload(FlashWritePayload.self) + writeResult = result + if Self.writeMayHaveModifiedFirmware(result) { + markSnapshotStaleAfterWrite() + if Self.requiresManualPowerCycleAfterWrite(result) { + manualPowerCycleNotice = FlashManualPowerCycleNotice(mode: result.mode) + } + } else { + state = .writeValidated + } + case nil: + break + } + error = nil + currentStage = nil + operationObserver.finish() + activeAction = nil + pendingFirmwareSelection = nil + } catch { + self.error = BackendErrorViewModel( + operation: "flash", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + operationObserver.finish() + activeAction = nil + pendingFirmwareSelection = nil + } + } + + private func markSnapshotStaleAfterWrite() { + backupSnapshotStale = true + plan = nil + plannedFirmwareSelection = nil + state = .writeValidatedSnapshotStale + } + + private static func writeMayHaveModifiedFirmware(_ result: FlashWritePayload) -> Bool { + result.writeMayHaveModifiedDevice + || (result.writeValidated && (result.mode == .patch || result.mode == .restore)) + } + + private static func requiresManualPowerCycleAfterWrite(_ result: FlashWritePayload) -> Bool { + guard writeMayHaveModifiedFirmware(result) else { + return false + } + switch result.mode { + case .patch: + return true + case .restore: + return !result.rebootRequested + case .checkApple, .downloadOnly: + return false + } + } + + private static func stateAfterPlan(_ plan: FlashPlanPayload) -> FlashWorkflowState { + switch plan.mode { + case .checkApple: + return plan.appleFirmwareMatch?.matched == false ? .appleFirmwareMismatch : .appleCheckComplete + case .downloadOnly: + return .appleFirmwareReady + case .patch, .restore: + return .planAvailable + } + } + + private func applyError(_ event: BackendEvent, activeOperation: ActiveOperation) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingStrongConfirmation + return + } + if event.code == "confirmation_cancelled" { + error = nil + currentStage = nil + operationObserver.finish() + activeAction = nil + pendingFirmwareSelection = nil + state = plan == nil ? (backup == nil ? eligibility.state : .writeLocked) : .planAvailable + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation.profileID + } + if activeAction == .writePatch || activeAction == .writeRestore, + currentStageMayHaveModifiedFirmware() { + markSnapshotStaleAfterWrite() + } + error = BackendErrorViewModel(event: event) + state = .failed + operationObserver.finish() + activeAction = nil + pendingFirmwareSelection = nil + } + + private var currentFirmwareSelection: FlashFirmwareSelection { + FlashFirmwareSelection( + version: firmwareVersion.trimmingCharacters(in: .whitespacesAndNewlines), + templatePath: firmwareTemplatePath.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + private func invalidatePlanIfFirmwareSelectionChanged() { + guard !isBusy, plan != nil, plannedFirmwareSelection != currentFirmwareSelection else { + return + } + plan = nil + writeResult = nil + plannedFirmwareSelection = nil + if backup != nil, !backupSnapshotStale { + state = .planAvailable + } + } + + private func currentStageMayHaveModifiedFirmware() -> Bool { + guard currentStage?.operation == "flash" else { + return false + } + return currentStage?.stage == "write_primary_bank" + || currentStage?.stage == "write_active_bank" + || currentStage?.stage == "post_write_validation" + || currentStage?.stage == "reboot" + || currentStage?.stage == "wait_for_reboot_down" + || currentStage?.stage == "wait_for_reboot_up" + } + + private func applyFalseResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.localizedPayloadSummaryText ?? event.localizedSummary + ) + state = .failed + operationObserver.finish() + activeAction = nil + } + + private func stateForStage(_ stage: String) -> FlashWorkflowState { + switch stage { + case "read_flash": + return .readingBanks + case "save_raw_backup": + return .savingBackup + case "inspect_backup", "analyze_flash", "plan_flash": + return .analyzingBanks + case "confirm_write": + return .awaitingStrongConfirmation + case "pre_write_validation", "post_write_validation": + return .readbackValidating + case "write_primary_bank", "write_active_bank": + return .writing + case "reboot", "wait_for_reboot_down", "wait_for_reboot_up": + return .restoreRebooting + default: + return state + } + } + + private func reject(_ message: String) -> OperationStartResult { + error = BackendErrorViewModel(operation: "flash", code: "operation_rejected", message: message) + state = .failed + return .rejected(message) + } + + private func reject(_ localError: WorkflowLocalError) -> OperationStartResult { + error = BackendErrorViewModel(operation: "flash", localError: localError) + state = .failed + return .rejected(localError.message) + } + + private func startRun( + action: FlashUserAction, + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + guard !isBusy else { + return reject(.operationAlreadyRunning) + } + resetRunState() + let start = run(operation: "flash", params: params, profile: profile, password: password) + switch start { + case .started(let operation): + operationObserver.start(operation) + activeAction = action + case .rejected(let message): + return reject(message) + } + return start + } + + private func resetRunState() { + backend.clear() + operationObserver.clear() + error = nil + manualPowerCycleNotice = nil + currentStage = nil + passwordInvalidProfileID = nil + operationObserver.finish() + activeAction = nil + } + + private func run( + operation: String, + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + if let coordinator { + return coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + password: password, + laneKey: laneKey + ) + } + guard !isBusy else { + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + let context = profile?.runtimeContext + let updatedParams = OperationCredentialInjector.injectingPassword(password, into: params) + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run( + operation: operation, + params: updatedParams, + context: context, + requestID: activeOperation.id.uuidString + ) + return .started(activeOperation) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FsckStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FsckStore.swift new file mode 100644 index 00000000..69caedeb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/FsckStore.swift @@ -0,0 +1,374 @@ +import Combine +import Foundation + +@MainActor +final class FsckStore: ObservableObject { + @Published private(set) var state: MaintenanceOperationState = .idle + @Published private(set) var targets: [FsckTargetViewModel] = [] + @Published private(set) var selectedTargetID: FsckTargetViewModel.ID? + @Published private(set) var plan: FsckPlanPayload? + @Published private(set) var result: FsckResultPayload? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + + private let operation: MaintenanceWorkflowOperation + private var plannedOptions: MaintenanceOptions? + private var plannedTargetID: FsckTargetViewModel.ID? + private var latestOptions: MaintenanceOptions? + + init(backend: BackendClient, coordinator: OperationCoordinator? = nil, laneKey: OperationLaneKey? = nil) { + self.operation = MaintenanceWorkflowOperation( + name: "fsck", + backend: backend, + coordinator: coordinator, + laneKey: laneKey + ) + self.operation.bind(onEvent: { [weak self] event, activeOperation in + self?.handle(event, activeOperation: activeOperation) + }, onRunningChanged: { [weak self] in + self?.objectWillChange.send() + }) + } + + var events: [BackendEvent] { operation.events } + var isRunning: Bool { operation.isRunning } + var isBusy: Bool { operation.isBusy } + var canCancel: Bool { operation.canCancel } + var pendingConfirmation: PendingConfirmation? { operation.pendingConfirmation } + + var selectedTarget: FsckTargetViewModel? { + guard let selectedTargetID else { + return nil + } + return targets.first { $0.id == selectedTargetID } + } + + func canFindVolumes(mountWaitValue: Int?) -> Bool { + !isBusy && mountWaitValue != nil + } + + func canPlan(options: MaintenanceOptions?) -> Bool { + return !isBusy && selectedTarget != nil && options != nil + } + + func canRun(options: MaintenanceOptions?) -> Bool { + return !isBusy + && plan != nil + && state == .planReady + && options == plannedOptions + && selectedTargetID == plannedTargetID + } + + func selectTarget(id: FsckTargetViewModel.ID?, options: MaintenanceOptions?) { + selectedTargetID = id + markPlanStaleIfNeeded(options: options) + } + + func markPlanStaleIfNeeded(options: MaintenanceOptions?) { + latestOptions = options + if state == .planReady, + options != plannedOptions || selectedTargetID != plannedTargetID { + state = .planStale + } + } + + func confirmPending() { + operation.confirmPending() + } + + func cancelPendingConfirmation(options: MaintenanceOptions?) { + latestOptions = options + operation.cancelPendingConfirmation() + restoreStateAfterCancellation(options: options) + } + + func cancel() { + operation.cancel() + } + + func clear() { + operation.clear() + state = .idle + targets = [] + selectedTargetID = nil + plan = nil + result = nil + currentStage = nil + error = nil + passwordInvalidProfileID = nil + plannedOptions = nil + plannedTargetID = nil + latestOptions = nil + } + + @discardableResult + func refreshTargets( + mountWaitValue: Int?, + password: String, + profile: DeviceProfile? = nil + ) -> OperationStartResult { + guard let mountWaitValue else { + failLocally(.mountWaitInvalid) + return .rejected(WorkflowLocalError.mountWaitInvalid.message) + } + let start = startRun( + params: OperationParams.Fsck.listVolumes(mountWait: Double(mountWaitValue)), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .loading + targets = [] + selectedTargetID = nil + plan = nil + result = nil + return start + } + + @discardableResult + func planFsck( + options: MaintenanceOptions?, + password: String, + profile: DeviceProfile? = nil + ) -> OperationStartResult { + latestOptions = options + guard let options else { + failLocally(.mountWaitInvalid) + return .rejected(WorkflowLocalError.mountWaitInvalid.message) + } + guard let target = selectedTarget else { + failLocally(.fsckTargetRequired) + return .rejected(WorkflowLocalError.fsckTargetRequired.message) + } + let start = startRun( + params: OperationParams.Fsck.run( + dryRun: true, + volume: target.volumeParam, + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait) + ), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .planning + plan = nil + result = nil + plannedOptions = options + plannedTargetID = target.id + return start + } + + @discardableResult + func runFsck( + options: MaintenanceOptions?, + password: String, + profile: DeviceProfile? = nil + ) -> OperationStartResult { + latestOptions = options + guard !isBusy else { + return rejectAlreadyRunning() + } + guard let options, + let plannedOptions, + options == plannedOptions, + let target = selectedTarget, + selectedTargetID == plannedTargetID, + plan != nil else { + markStale(.fsckPlanStale) + return .rejected(WorkflowLocalError.fsckPlanStale.message) + } + guard state == .planReady else { + return .rejected(WorkflowLocalError.fsckPlanNotReady.message) + } + let start = startRun( + params: OperationParams.Fsck.run( + dryRun: false, + volume: target.volumeParam, + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait) + ), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .running + result = nil + return start + } + + @discardableResult + func rejectAlreadyRunning() -> OperationStartResult { + rejectRun(.operationAlreadyRunning) + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + + private func startRun( + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? + ) -> OperationStartResult { + operation.start( + params: params, + profile: profile, + password: password, + rejectAlreadyRunning: { rejectRun(.operationAlreadyRunning) }, + resetRunState: resetRunState, + rejectRun: rejectRun(message:) + ) + } + + private func resetRunState() { + operation.resetForRun() + error = nil + currentStage = nil + passwordInvalidProfileID = nil + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard event.operation == operation.name else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .running + } + return + } + + if event.type == "error" { + applyError(event, activeOperation: activeOperation) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFalseResult(event) + return + } + + switch state { + case .loading: + handleListResult(event) + case .planning: + handlePlanResult(event) + default: + handleRunResult(event) + } + } + + private func handleListResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(FsckVolumeListPayload.self) + targets = payload.targets.map(FsckTargetViewModel.init) + selectedTargetID = targets.count == 1 ? targets[0].id : nil + state = .listReady + error = nil + operation.finishObserver() + } catch { + failContract(error) + } + } + + private func handlePlanResult(_ event: BackendEvent) { + do { + plan = try event.decodePayload(FsckPlanPayload.self) + state = .planReady + error = nil + operation.finishObserver() + } catch { + failContract(error) + } + } + + private func handleRunResult(_ event: BackendEvent) { + do { + result = try event.decodePayload(FsckResultPayload.self) + state = .succeeded + error = nil + operation.finishObserver() + } catch { + failContract(error) + } + } + + private func applyError(_ event: BackendEvent, activeOperation: ActiveOperation) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "confirmation_cancelled" { + error = nil + currentStage = nil + operation.finishObserver() + restoreStateAfterCancellation(options: latestOptions) + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation.profileID + } + error = BackendErrorViewModel(event: event) + state = .failed + operation.finishObserver() + } + + private func restoreStateAfterCancellation(options: MaintenanceOptions?) { + guard plan != nil else { + state = targets.isEmpty ? .idle : .listReady + return + } + state = options == plannedOptions && selectedTargetID == plannedTargetID ? .planReady : .planStale + } + + private func markStale(_ localError: WorkflowLocalError) { + state = .planStale + error = operation.localError(localError) + } + + private func applyFalseResult(_ event: BackendEvent) { + error = operation.falseResultError(from: event) + state = .failed + operation.finishObserver() + } + + private func failContract(_ decodeError: Error) { + error = operation.contractDecodeError(decodeError) + state = .failed + operation.finishObserver() + } + + private func failLocally(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(message: String) { + error = operation.rejectedError(message: message) + currentStage = nil + state = .failed + operation.finishObserver() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift new file mode 100644 index 00000000..e5c9fc7b --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/InstallPresentation.swift @@ -0,0 +1,492 @@ +import Foundation + +struct InstallPlanSection: Equatable, Identifiable { + let title: String + let rows: [PresentationRow] + + var id: String { title } +} + +struct InstallPlanPresentation: Equatable { + let title: String + let sections: [InstallPlanSection] + let warnings: [String] + + init( + plan: DeployPlanPayload, + profile: DeviceProfile, + options: DeployOptions? = nil, + hostWarning: HostCompatibilityWarning? = nil + ) { + let returnsAfterRebootRequest = Self.returnsAfterRebootRequest(plan: plan, options: options) + self.title = Self.title(for: plan, returnsAfterRebootRequest: returnsAfterRebootRequest) + self.sections = [ + InstallPlanSection(title: L10n.string("install.plan.section.target"), rows: [ + 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")) + ]), + InstallPlanSection(title: L10n.string("install.plan.section.device_actions"), rows: [ + PresentationRow(label: L10n.string("install.plan.row.uploads"), value: "\(plan.uploads.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), + PresentationRow(label: L10n.string("install.plan.row.expected_downtime"), value: Self.expectedDowntime(plan: plan, returnsAfterRebootRequest: returnsAfterRebootRequest)), + PresentationRow(label: L10n.string("install.plan.row.remote_actions"), value: "\(plan.preUploadActions.count + plan.postUploadActions.count + plan.activationActions.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.post_install_checks"), value: "\(plan.postDeployChecks.count)") + ]) + ] + var warnings: [String] = [] + if returnsAfterRebootRequest { + warnings.append(Self.noWaitWarning(for: plan)) + } + if plan.netbsd4 && !returnsAfterRebootRequest { + warnings.append(Self.netbsd4Warning(for: plan)) + } + if let hostWarning { + warnings.append(hostWarning.message) + } + self.warnings = warnings + } + + private static func returnsAfterRebootRequest(plan: DeployPlanPayload, options: DeployOptions?) -> Bool { + plan.requiresReboot && options?.noWait == true + } + + private static func expectedDowntime(plan: DeployPlanPayload, returnsAfterRebootRequest: Bool) -> String { + if returnsAfterRebootRequest { + return L10n.string("install.plan.downtime.no_wait") + } + switch plan.startupMode { + case .rebootThenVerify, .rebootThenActivate: + return L10n.string("install.plan.downtime.reboot") + case .activateNow: + return L10n.string("install.plan.downtime.activate_now") + } + } + + private static func title(for plan: DeployPlanPayload, returnsAfterRebootRequest: Bool) -> String { + if returnsAfterRebootRequest { + return L10n.string("install.plan.title.reboot_no_wait") + } + switch plan.startupMode { + case .rebootThenActivate: + return L10n.string("install.plan.title.reboot_then_activate") + case .activateNow: + return L10n.string("install.plan.title.activate_now") + case .rebootThenVerify: + return L10n.string("install.plan.title.standard") + } + } + + private static func noWaitWarning(for plan: DeployPlanPayload) -> String { + if plan.startupMode == .rebootThenActivate { + return L10n.string("deploy.presentation.warning.no_wait_post_reboot_activation") + } + return L10n.string("deploy.presentation.warning.no_wait_post_reboot_verification") + } + + private static func netbsd4Warning(for plan: DeployPlanPayload) -> String { + switch plan.startupMode { + case .rebootThenActivate: + return L10n.string("deploy.presentation.warning.netbsd4_reboot_then_activate") + case .activateNow: + return L10n.string("deploy.presentation.warning.netbsd4_activate_now") + case .rebootThenVerify: + return L10n.string("deploy.presentation.warning.netbsd4_activation") + } + } +} + +enum InstallUserAction: String, Equatable, Identifiable { + case createPlan + case regeneratePlan + case installUpdate + case reinstall + case openFinder + case runCheckup + case viewCheckup + case viewDiagnostics + + var id: String { rawValue } + + var title: String { + switch self { + case .createPlan: + return L10n.string("install.action.create_plan") + case .regeneratePlan: + return L10n.string("install.action.regenerate_plan") + case .installUpdate: + return L10n.string("install.action.install_update") + case .reinstall: + return L10n.string("install.action.reinstall") + case .openFinder: + return L10n.string("dashboard.action.open_finder") + case .runCheckup: + return L10n.string("dashboard.action.run_checkup") + case .viewCheckup: + return L10n.string("dashboard.action.view_checkup") + case .viewDiagnostics: + return L10n.string("recovery.action.open_diagnostics") + } + } + + var systemImage: String { + switch self { + case .createPlan, .regeneratePlan: + return "doc.text.magnifyingglass" + case .installUpdate: + return "square.and.arrow.down.on.square" + case .reinstall: + return "arrow.clockwise" + case .openFinder: + return "folder" + case .runCheckup: + return "stethoscope" + case .viewCheckup: + return "list.bullet.clipboard" + case .viewDiagnostics: + return "wrench.and.screwdriver" + } + } +} + +enum InstallCompletionActionPolicy { + static func actions(isCheckupRunning: Bool) -> [InstallUserAction] { + [.reinstall, .openFinder, isCheckupRunning ? .viewCheckup : .runCheckup, .viewDiagnostics] + } +} + +enum InstallActionAvailabilityPolicy { + @MainActor + static func isEnabled( + _ action: InstallUserAction, + store: DeployWorkflowStore, + isDeviceBusy: Bool = false + ) -> Bool { + switch action { + case .createPlan, .regeneratePlan, .reinstall: + return !isDeviceBusy && !store.isBusy && store.hasValidOptions + case .installUpdate: + return !isDeviceBusy && store.canDeploy + case .runCheckup: + return !isDeviceBusy && !store.isBusy + case .openFinder, .viewCheckup, .viewDiagnostics: + return true + } + } +} + +struct InstallTimelinePresentation: Equatable { + let items: [OperationTimelineItem] + + init(restoredDeploySuccess snapshot: DeviceDeployStateSnapshot) { + self.items = [ + OperationTimelineItem( + id: "restored:deploy:result", + operation: "deploy", + title: L10n.string("timeline.result.done"), + detail: Self.restoredDeploySuccessDetail(snapshot), + state: .succeeded, + risk: nil, + cancellable: nil + ) + ] + } + + init( + events: [BackendEvent], + currentStage: OperationStageState?, + fallbackState: OperationTimelineItem.State = .running + ) { + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == "deploy" } + if items.isEmpty, let currentStage { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: OperationTimelineBuilder.stageTitle(for: currentStage.operation, stage: currentStage.stage), + detail: OperationTimelineBuilder.stageDetail( + for: currentStage.operation, + stage: currentStage.stage, + fallback: currentStage.description + ), + state: fallbackState, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + self.items = items + } + + private static func restoredDeploySuccessDetail(_ snapshot: DeviceDeployStateSnapshot) -> String { + let trimmed = snapshot.summary.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + ? L10n.string("timeline.deploy.result.completed") + : BackendSummaryLocalization.localized(trimmed, operation: "deploy") + } +} + +enum DeployFailureGuidancePolicy { + static func guidance(for error: BackendErrorViewModel?) -> String? { + guard let error, error.operation == "deploy" else { + return nil + } + switch error.code { + case "auth_failed", "validation_failed", "confirmation_required", "confirmation_cancelled": + return nil + default: + if error.recovery?.hasGuidanceText == true { + return nil + } + return L10n.string("deploy.failure.reboot_guidance") + } + } +} + +struct InstallCompletionPresentation: Equatable { + let title: String + let rows: [PresentationRow] + let warnings: [String] + let actions: [InstallUserAction] + + init(result: DeployResultPayload, isCheckupRunning: Bool = false) { + self.init( + verified: result.verified, + rebootRequested: result.rebootRequested, + message: result.localizedMessage, + netbsd4: result.netbsd4, + isCheckupRunning: isCheckupRunning + ) + } + + init(snapshot: DeviceDeployStateSnapshot, profile: DeviceProfile, isCheckupRunning: Bool = false) { + self.init( + verified: snapshot.verified, + rebootRequested: snapshot.rebootRequested, + message: snapshot.localizedSummary, + netbsd4: Self.isNetBSD4(snapshot: snapshot, profile: profile), + isCheckupRunning: isCheckupRunning + ) + } + + private init(verified: Bool?, rebootRequested: Bool?, message: String, netbsd4: Bool, isCheckupRunning: Bool) { + self.title = verified == true + ? L10n.string("install.completion.title.verified") + : L10n.string("install.completion.title.finished") + self.rows = [ + PresentationRow(label: L10n.string("deploy.result.verified"), value: verified == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.reboot_requested"), value: rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.message"), value: message) + ] + var warnings: [String] = [] + if netbsd4 { + warnings.append(L10n.string("install.completion.warning.netbsd4")) + } + self.warnings = warnings + self.actions = InstallCompletionActionPolicy.actions(isCheckupRunning: isCheckupRunning) + } + + private static func isNetBSD4(snapshot: DeviceDeployStateSnapshot, profile: DeviceProfile) -> Bool { + snapshot.payloadFamily?.localizedCaseInsensitiveContains("netbsd4") == true || profile.traits.isNetBSD4 + } +} + +struct InstallProgressPresentation: Equatable, BlockingProgressPresenting { + let title: String + let message: String + let detail: String? + + init?(state: DeployWorkflowState, currentStage: OperationStageState?) { + switch state { + case .deploying: + self.title = L10n.string("install.progress.deploying.title") + self.message = L10n.string("install.progress.deploying.message") + case .idle, + .planning, + .planReady, + .planStale, + .planFailed, + .awaitingConfirmation, + .deployed, + .deployFailed: + return nil + } + if let currentStage { + self.detail = OperationTimelineBuilder.stageDetail( + for: currentStage.operation, + stage: currentStage.stage, + fallback: currentStage.description ?? currentStage.stage + ) + } else { + self.detail = nil + } + } +} + +struct InstallWorkflowPresentation: Equatable { + let title: String + let stateTitle: String + let statusMessage: String + let actions: [InstallUserAction] + let notices: [String] + let plan: InstallPlanPresentation? + let timeline: InstallTimelinePresentation? + let completion: InstallCompletionPresentation? + let error: BackendErrorViewModel? + let failureGuidance: String? + + init( + state: DeployWorkflowState, + plan: DeployPlanPayload?, + result: DeployResultPayload?, + error: BackendErrorViewModel?, + events: [BackendEvent], + currentStage: OperationStageState?, + plannedOptions: DeployOptions? = nil, + profile: DeviceProfile, + hostWarning: HostCompatibilityWarning? = nil, + isCheckupRunning: Bool = false + ) { + let restoredFailure = Self.restoredDeployFailure(state: state, result: result, error: error, profile: profile) + let restoredSuccess = Self.restoredDeploySuccess(state: state, result: result, error: error, profile: profile) + let effectiveState = restoredFailure == nil ? state : DeployWorkflowState.deployFailed + let effectiveError = error ?? restoredFailure.map { BackendErrorViewModel(operation: "deploy", deployState: $0) } + self.title = L10n.string("dashboard.tab.install") + self.plan = plan.map { + InstallPlanPresentation(plan: $0, profile: profile, options: plannedOptions, hostWarning: hostWarning) + } + self.timeline = Self.timeline( + for: effectiveState, + events: events, + currentStage: currentStage, + restoredFailure: restoredFailure, + restoredSuccess: restoredSuccess + ) + let persistedCompletion = restoredSuccess.map { + InstallCompletionPresentation(snapshot: $0, profile: profile, isCheckupRunning: isCheckupRunning) + } + self.completion = result.map { InstallCompletionPresentation(result: $0, isCheckupRunning: isCheckupRunning) } + ?? persistedCompletion + self.stateTitle = persistedCompletion == nil ? effectiveState.title : DeployWorkflowState.deployed.title + self.error = effectiveError + self.failureGuidance = DeployFailureGuidancePolicy.guidance(for: effectiveError) + + switch effectiveState { + case .idle: + if persistedCompletion == nil { + self.statusMessage = L10n.string("install.state.idle") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + } else { + self.statusMessage = L10n.string("install.state.deployed") + self.actions = [] + } + self.notices = [] + case .planning: + self.statusMessage = L10n.string("install.state.planning") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + self.notices = [] + case .planReady: + self.statusMessage = L10n.string("install.state.plan_ready") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + self.notices = [] + case .planStale: + self.statusMessage = L10n.string("install.state.plan_stale") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + self.notices = [L10n.string("install.warning.plan_stale")] + case .planFailed: + self.statusMessage = effectiveError?.message ?? L10n.string("install.state.plan_failed") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + self.notices = [] + case .deploying: + self.statusMessage = L10n.string("install.state.deploying") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + self.notices = [] + case .awaitingConfirmation: + self.statusMessage = L10n.string("install.state.awaiting_confirmation") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + self.notices = [L10n.string("install.warning.awaiting_confirmation")] + case .deployed: + self.statusMessage = L10n.string("install.state.deployed") + self.actions = [] + self.notices = [] + case .deployFailed: + self.statusMessage = effectiveError?.message ?? L10n.string("install.state.deploy_failed") + self.actions = Self.planAndDeployActions(state: effectiveState, plan: plan) + self.notices = [] + } + } + + private static func restoredDeployFailure( + state: DeployWorkflowState, + result: DeployResultPayload?, + error: BackendErrorViewModel?, + profile: DeviceProfile + ) -> DeviceDeployStateSnapshot? { + guard state == .idle, result == nil, error == nil, + let deployState = profile.lastDeployState, + deployState.status.isFailure else { + return nil + } + return deployState + } + + private static func restoredDeploySuccess( + state: DeployWorkflowState, + result: DeployResultPayload?, + error: BackendErrorViewModel?, + profile: DeviceProfile + ) -> DeviceDeployStateSnapshot? { + guard state == .idle, result == nil, error == nil, + let deployState = profile.lastDeployState, + deployState.status == .succeeded else { + return nil + } + return deployState + } + + private static func planAndDeployActions(state: DeployWorkflowState, plan: DeployPlanPayload?) -> [InstallUserAction] { + let planAction: InstallUserAction + if plan != nil || state == .planStale || state == .deployFailed { + planAction = .regeneratePlan + } else { + planAction = .createPlan + } + return [planAction, .installUpdate] + } + + private static func timeline( + for state: DeployWorkflowState, + events: [BackendEvent], + currentStage: OperationStageState?, + restoredFailure: DeviceDeployStateSnapshot?, + restoredSuccess: DeviceDeployStateSnapshot? + ) -> InstallTimelinePresentation? { + let hasDeployEvents = events.contains { $0.operation == "deploy" } + switch state { + case .idle: + guard let restoredSuccess, currentStage == nil, !hasDeployEvents else { + return nil + } + return InstallTimelinePresentation(restoredDeploySuccess: restoredSuccess) + case .planning, .deploying, .awaitingConfirmation, .deployFailed, .deployed: + if let restoredFailure, currentStage == nil, !hasDeployEvents { + return InstallTimelinePresentation( + events: [], + currentStage: restoredFailure.stage.map { + OperationStageState(operation: "deploy", stage: $0, risk: nil, cancellable: nil, description: nil) + }, + fallbackState: .failed + ) + } + let presentation = InstallTimelinePresentation( + events: events, + currentStage: currentStage, + fallbackState: state == .deployed ? .succeeded : .running + ) + return state == .deployed && presentation.items.isEmpty ? nil : presentation + case .planReady, .planStale, .planFailed: + return nil + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceModels.swift new file mode 100644 index 00000000..09cb5072 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceModels.swift @@ -0,0 +1,115 @@ +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 L10n.string("maintenance.workflow.activate") + case .uninstall: + return L10n.string("maintenance.workflow.uninstall") + case .fsck: + return L10n.string("maintenance.workflow.fsck") + case .repairXattrs: + return L10n.string("maintenance.workflow.repair_xattrs") + } + } + + var deviceWorkflowLane: DeviceWorkflowLane { + switch self { + case .activate: + return .activate + case .uninstall: + return .uninstall + case .fsck: + return .fsck + case .repairXattrs: + return .repairXattrs + } + } +} + +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 L10n.string("workflow.state.idle") + case .loading: + return L10n.string("workflow.state.loading") + case .listReady: + return L10n.string("workflow.state.list_ready") + case .planning: + return L10n.string("workflow.state.planning") + case .planReady: + return L10n.string("workflow.state.plan_ready") + case .planStale: + return L10n.string("workflow.state.plan_stale") + case .scanning: + return L10n.string("workflow.state.scanning") + case .scanReady: + return L10n.string("workflow.state.scan_ready") + case .scanStale: + return L10n.string("workflow.state.scan_stale") + case .awaitingConfirmation: + return L10n.string("workflow.state.awaiting_confirmation") + case .running: + return L10n.string("workflow.state.running") + case .repairing: + return L10n.string("workflow.state.repairing") + case .succeeded: + return L10n.string("workflow.state.succeeded") + case .repaired: + return L10n.string("workflow.state.repaired") + case .failed: + return L10n.string("workflow.state.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 + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceOperationRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceOperationRunner.swift new file mode 100644 index 00000000..112ac746 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceOperationRunner.swift @@ -0,0 +1,143 @@ +import Combine +import Foundation + +@MainActor +final class MaintenanceOperationRunner { + let backend: BackendClient + + private let coordinator: OperationCoordinator? + private let laneKey: OperationLaneKey? + private let operationObserver = BackendOperationObserver() + private var cancellables: Set = [] + private var eventHandler: (BackendEvent, ActiveOperation) -> Void + private var runningChangedHandler: () -> Void + + init( + backend: BackendClient, + coordinator: OperationCoordinator?, + laneKey: OperationLaneKey?, + onEvent: @escaping (BackendEvent, ActiveOperation) -> Void, + onRunningChanged: @escaping () -> Void + ) { + self.backend = backend + self.coordinator = coordinator + self.laneKey = laneKey + self.eventHandler = onEvent + self.runningChangedHandler = onRunningChanged + + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + backend.$isRunning + .dropFirst() + .sink { [weak self] _ in + self?.runningChangedHandler() + } + .store(in: &cancellables) + } + + func rebind( + onEvent: @escaping (BackendEvent, ActiveOperation) -> Void, + onRunningChanged: @escaping () -> Void + ) { + self.eventHandler = onEvent + self.runningChangedHandler = onRunningChanged + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + + var canCancel: Bool { + backend.canCancel + } + + var pendingConfirmation: PendingConfirmation? { + backend.pendingConfirmation + } + + func confirmPending() { + backend.confirmPending() + } + + func cancelPendingConfirmation() { + backend.cancelPendingConfirmation() + } + + func cancel() { + backend.cancel() + } + + func clear() { + backend.clear() + operationObserver.clear() + operationObserver.finish() + } + + func resetForRun() { + backend.clear() + operationObserver.clear() + operationObserver.finish() + } + + func finishObserver() { + operationObserver.finish() + } + + @discardableResult + func start( + operation: String, + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + if let coordinator { + let start = coordinator.run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + password: password, + laneKey: laneKey + ) + if case .started(let operation) = start { + operationObserver.start(operation) + } + return start + } + + guard !isBusy else { + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + + let context = profile?.runtimeContext + let updatedParams = OperationCredentialInjector.injectingPassword(password, into: params) + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run( + operation: operation, + params: updatedParams, + context: context, + requestID: activeOperation.id.uuidString + ) + operationObserver.start(activeOperation) + return .started(activeOperation) + } + + private func process(_ events: [BackendEvent]) { + operationObserver.process(events) { event, operation in + eventHandler(event, operation) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift new file mode 100644 index 00000000..b7946106 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenancePresentation.swift @@ -0,0 +1,466 @@ +import Foundation + +enum MaintenanceUserAction: String, Equatable, Identifiable { + case planActivation + case runActivation + case planUninstall + case runUninstall + case findVolumes + case planFsck + case runFsck + case scanMetadata + case repairMetadata + case viewDiagnostics + + var id: String { rawValue } + + var title: String { + switch self { + case .planActivation: + return L10n.string("maintenance.action.plan_start_smb") + case .runActivation: + return L10n.string("maintenance.action.start_smb") + case .planUninstall: + return L10n.string("maintenance.action.plan_uninstall") + case .runUninstall: + return L10n.string("maintenance.action.uninstall") + case .findVolumes: + return L10n.string("maintenance.action.find_volumes") + case .planFsck: + return L10n.string("maintenance.action.plan_disk_repair") + case .runFsck: + return L10n.string("maintenance.action.run_disk_repair") + case .scanMetadata: + return L10n.string("maintenance.action.scan_metadata") + case .repairMetadata: + return L10n.string("maintenance.action.repair_metadata") + case .viewDiagnostics: + return L10n.string("recovery.action.open_diagnostics") + } + } + + var systemImage: String { + switch self { + case .planActivation, .planUninstall, .planFsck: + return "doc.text.magnifyingglass" + case .runActivation: + return "play.circle" + case .runUninstall: + return "trash" + case .findVolumes: + return "externaldrive" + case .runFsck: + return "externaldrive.badge.exclamationmark" + case .scanMetadata: + return "magnifyingglass" + case .repairMetadata: + return "tag" + case .viewDiagnostics: + return "wrench.and.screwdriver" + } + } + + var isCommitAction: Bool { + switch self { + case .runActivation, .runUninstall, .runFsck, .repairMetadata: + return true + case .planActivation, .planUninstall, .findVolumes, .planFsck, .scanMetadata, .viewDiagnostics: + return false + } + } +} + +struct MaintenanceWorkflowCardPresentation: Equatable, Identifiable { + let workflow: MaintenanceWorkflow + let title: String + let subtitle: String + let stateTitle: String + let isSelected: Bool + + var id: MaintenanceWorkflow.ID { workflow.id } +} + +extension MaintenanceWorkflow { + var presentationTitle: String { + switch self { + case .activate: + return L10n.string("maintenance.presentation.activate.title") + case .uninstall: + return L10n.string("maintenance.presentation.uninstall.title") + case .fsck: + return L10n.string("maintenance.presentation.fsck.title") + case .repairXattrs: + return L10n.string("maintenance.presentation.repair_xattrs.title") + } + } + + var presentationSubtitle: String { + switch self { + case .activate: + return L10n.string("maintenance.presentation.activate.subtitle") + case .uninstall: + return L10n.string("maintenance.presentation.uninstall.subtitle") + case .fsck: + return L10n.string("maintenance.presentation.fsck.subtitle") + case .repairXattrs: + return L10n.string("maintenance.presentation.repair_xattrs.subtitle") + } + } + + var presentationRisk: String { + switch self { + case .activate: + return L10n.string("maintenance.presentation.risk.remote_write") + case .uninstall, .fsck: + return L10n.string("maintenance.presentation.risk.destructive") + case .repairXattrs: + return L10n.string("maintenance.presentation.risk.local_destructive") + } + } +} + +struct MaintenancePlanPresentation: Equatable { + let title: String + let rows: [PresentationRow] + let warnings: [String] +} + +struct MaintenanceCompletionPresentation: Equatable { + let title: String + let rows: [PresentationRow] +} + +struct MaintenanceTimelinePresentation: Equatable { + let items: [OperationTimelineItem] + + init(items: [OperationTimelineItem]) { + self.items = items + } + + init(events: [BackendEvent], currentStage: OperationStageState?, workflow: MaintenanceWorkflow) { + let operation = workflow.operationName + var items = OperationTimelineBuilder.timeline(from: events) + .filter { $0.operation == operation } + if items.isEmpty, let currentStage, currentStage.operation == operation { + items = [ + OperationTimelineItem( + id: "current:\(currentStage.operation):\(currentStage.stage)", + operation: currentStage.operation, + title: OperationTimelineBuilder.stageTitle(for: currentStage.operation, stage: currentStage.stage), + detail: OperationTimelineBuilder.stageDetail( + for: currentStage.operation, + stage: currentStage.stage, + fallback: nil + ), + state: .running, + risk: currentStage.risk, + cancellable: currentStage.cancellable + ) + ] + } + self.items = items + } +} + +enum MaintenanceActionPolicy { + static func actions(for workflow: MaintenanceWorkflow) -> [MaintenanceUserAction] { + switch workflow { + case .activate: + return [.planActivation, .runActivation] + case .uninstall: + return [.planUninstall, .runUninstall] + case .fsck: + return [.findVolumes, .planFsck, .runFsck] + case .repairXattrs: + return [.scanMetadata, .repairMetadata] + } + } + + @MainActor + static func enabledActions(workflow: MaintenanceWorkflow, store: MaintenanceStore) -> Set { + switch workflow { + case .activate: + return enabled([ + (.planActivation, store.canPlanActivation), + (.runActivation, store.canRunActivation) + ]) + case .uninstall: + return enabled([ + (.planUninstall, store.canPlanUninstall), + (.runUninstall, store.canRunUninstall) + ]) + case .fsck: + return enabled([ + (.findVolumes, store.canFindFsckVolumes), + (.planFsck, store.canPlanFsck), + (.runFsck, store.canRunFsck) + ]) + case .repairXattrs: + return enabled([ + (.scanMetadata, store.canScanRepairXattrs), + (.repairMetadata, store.canRepairXattrs) + ]) + } + } + + private static func enabled(_ pairs: [(MaintenanceUserAction, Bool)]) -> Set { + Set(pairs.compactMap { action, isEnabled in + isEnabled ? action : nil + }) + } +} + +extension MaintenanceOperationState { + func maintenanceStatusMessage(for workflow: MaintenanceWorkflow) -> String { + switch (workflow, self) { + case (_, .idle): + return L10n.string("maintenance.state.idle") + case (_, .loading): + return L10n.string("maintenance.state.loading") + case (.fsck, .listReady): + return L10n.string("maintenance.state.fsck_list_ready") + case (_, .planning): + return L10n.string("maintenance.state.planning") + case (_, .planReady): + return L10n.string("maintenance.state.plan_ready") + case (_, .planStale): + return L10n.string("maintenance.state.plan_stale") + case (.repairXattrs, .scanning): + return L10n.string("maintenance.state.scanning") + case (.repairXattrs, .scanReady): + return L10n.string("maintenance.state.scan_ready") + case (.repairXattrs, .scanStale): + return L10n.string("maintenance.state.scan_stale") + case (_, .awaitingConfirmation): + return L10n.string("maintenance.state.awaiting_confirmation") + case (_, .running), (_, .repairing): + return L10n.string("maintenance.state.running") + case (_, .succeeded), (_, .repaired): + return L10n.string("maintenance.state.succeeded") + case (_, .failed): + return L10n.string("maintenance.state.failed") + default: + return title + } + } +} + +struct MaintenanceWorkflowDetailPresentation: Equatable { + let workflow: MaintenanceWorkflow + let title: String + let subtitle: String + let risk: String + let stateTitle: String + let statusMessage: String + let actions: [MaintenanceUserAction] + let enabledActions: Set + let plan: MaintenancePlanPresentation? + let completion: MaintenanceCompletionPresentation? + let timeline: MaintenanceTimelinePresentation? + + @MainActor + init(store: MaintenanceStore, profile: DeviceProfile, workflow selectedWorkflow: MaintenanceWorkflow? = nil) { + let workflow = selectedWorkflow ?? store.selectedWorkflow + let state = store.state(for: workflow) + self.workflow = workflow + self.title = workflow.presentationTitle + self.subtitle = workflow.presentationSubtitle + self.risk = workflow.presentationRisk + self.stateTitle = state.title + self.statusMessage = state.maintenanceStatusMessage(for: workflow) + self.actions = MaintenanceActionPolicy.actions(for: workflow) + self.enabledActions = MaintenanceActionPolicy.enabledActions(workflow: workflow, store: store) + self.plan = Self.plan(workflow: workflow, store: store, profile: profile) + self.completion = Self.completion(workflow: workflow, store: store) + self.timeline = Self.timeline(workflow: workflow, state: state, store: store) + } + + func isEnabled(_ action: MaintenanceUserAction) -> Bool { + enabledActions.contains(action) + } + + @MainActor + private static func plan( + workflow: MaintenanceWorkflow, + store: MaintenanceStore, + profile: DeviceProfile + ) -> MaintenancePlanPresentation? { + switch workflow { + case .activate: + guard let plan = store.activationPlan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.activate"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.device"), value: profile.title), + PresentationRow(label: L10n.string("maintenance.plan.row.actions"), value: "\(plan.actions.count)"), + PresentationRow(label: L10n.string("maintenance.plan.row.post_checks"), value: "\(plan.postActivationChecks.count)") + ], + warnings: [] + ) + case .uninstall: + guard let plan = store.uninstallPlan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.uninstall"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.host"), value: plan.host), + PresentationRow(label: L10n.string("maintenance.plan.row.payload_dirs"), value: "\(plan.payloadDirs.count)"), + PresentationRow(label: L10n.string("maintenance.plan.row.remote_actions"), value: "\(plan.remoteActions.count)"), + PresentationRow(label: L10n.string("maintenance.plan.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), + PresentationRow(label: L10n.string("maintenance.plan.row.post_checks"), value: "\(plan.postUninstallChecks.count)") + ], + warnings: [L10n.string("maintenance.warning.destructive_uninstall")] + ) + case .fsck: + guard let plan = store.fsckPlan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.fsck"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.device"), value: plan.device), + PresentationRow(label: L10n.string("maintenance.plan.row.mountpoint"), value: plan.mountpoint), + PresentationRow(label: L10n.string("maintenance.plan.row.reboot"), value: plan.rebootRequired ? L10n.string("value.required") : L10n.string("value.not_required")), + PresentationRow(label: L10n.string("maintenance.plan.row.wait_after_reboot"), value: plan.waitAfterReboot ? L10n.string("value.yes") : L10n.string("value.no")) + ], + warnings: [L10n.string("maintenance.warning.destructive_fsck")] + ) + case .repairXattrs: + guard let scan = store.repairScan else { return nil } + return MaintenancePlanPresentation( + title: L10n.string("maintenance.plan.repair_xattrs"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.path"), value: scan.root ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("maintenance.plan.row.findings"), value: "\(scan.findingCount)"), + PresentationRow(label: L10n.string("maintenance.plan.row.repairable"), value: "\(scan.repairableCount)") + ], + warnings: scan.repairableCount > 0 ? [L10n.string("maintenance.warning.local_metadata_repair")] : [] + ) + } + } + + @MainActor + private static func completion( + workflow: MaintenanceWorkflow, + store: MaintenanceStore + ) -> MaintenanceCompletionPresentation? { + switch workflow { + case .activate: + guard let result = store.activationResult else { return nil } + return MaintenanceCompletionPresentation( + title: L10n.string("maintenance.completion.activate"), + rows: [ + PresentationRow(label: L10n.string("maintenance.result.already_active"), value: result.alreadyActive ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.message"), value: result.localizedMessage) + ] + ) + case .uninstall: + guard let result = store.uninstallResult else { return nil } + return MaintenanceCompletionPresentation(title: L10n.string("maintenance.completion.uninstall"), rows: resultRows(result)) + case .fsck: + guard let result = store.fsckResult else { return nil } + return MaintenanceCompletionPresentation( + title: L10n.string("maintenance.completion.fsck"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.device"), value: result.device), + PresentationRow(label: L10n.string("maintenance.result.returncode"), value: result.returncode.map(String.init) ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("deploy.result.verified"), value: result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")) + ] + ) + case .repairXattrs: + guard let result = store.repairResult else { return nil } + return MaintenanceCompletionPresentation( + title: L10n.string("maintenance.completion.repair_xattrs"), + rows: [ + PresentationRow(label: L10n.string("maintenance.plan.row.findings"), value: "\(result.findingCount)"), + PresentationRow(label: L10n.string("maintenance.plan.row.repairable"), value: "\(result.repairableCount)"), + PresentationRow(label: L10n.string("maintenance.result.returncode"), value: result.returncode.map(String.init) ?? L10n.string("value.unknown")) + ] + ) + } + } + + private static func resultRows(_ result: MaintenanceResultPayload) -> [PresentationRow] { + [ + PresentationRow(label: L10n.string("deploy.result.reboot_requested"), value: result.rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.verified"), value: result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")), + PresentationRow(label: L10n.string("deploy.result.message"), value: result.localizedUninstallSummary) + ] + } + + @MainActor + private static func timeline( + workflow: MaintenanceWorkflow, + state: MaintenanceOperationState, + store: MaintenanceStore + ) -> MaintenanceTimelinePresentation? { + switch state { + case .loading, .planning, .scanning, .awaitingConfirmation, .running, .repairing, .succeeded, .repaired, .failed: + return MaintenanceTimelinePresentation( + events: store.timelineEvents(for: workflow), + currentStage: store.currentStage(for: workflow), + workflow: workflow + ) + default: + return nil + } + } +} + +enum MaintenanceWorkflowAvailability { + static func workflows(for profile: DeviceProfile) -> [MaintenanceWorkflow] { + MaintenanceWorkflow.allCases.filter { workflow in + workflow != .activate || profile.traits.needsActivationAfterReboot + } + } +} + +struct MaintenanceDashboardPresentation: Equatable { + let cards: [MaintenanceWorkflowCardPresentation] + let detail: MaintenanceWorkflowDetailPresentation + + @MainActor + init(store: MaintenanceStore, profile: DeviceProfile) { + let workflows = MaintenanceWorkflowAvailability.workflows(for: profile) + let selectedWorkflow = workflows.contains(store.selectedWorkflow) + ? store.selectedWorkflow + : workflows.first ?? store.selectedWorkflow + self.cards = workflows.map { workflow in + return MaintenanceWorkflowCardPresentation( + workflow: workflow, + title: workflow.presentationTitle, + subtitle: workflow.presentationSubtitle, + stateTitle: store.state(for: workflow).title, + isSelected: workflow == selectedWorkflow + ) + } + self.detail = MaintenanceWorkflowDetailPresentation(store: store, profile: profile, workflow: selectedWorkflow) + } +} + +extension MaintenanceWorkflow { + var operationName: String { + switch self { + case .activate: + return "activate" + case .uninstall: + return "uninstall" + case .fsck: + return "fsck" + case .repairXattrs: + return "repair-xattrs" + } + } +} + +extension MaintenanceStore { + func state(for workflow: MaintenanceWorkflow) -> MaintenanceOperationState { + switch workflow { + case .activate: + return activateState + case .uninstall: + return uninstallState + case .fsck: + return fsckState + case .repairXattrs: + return repairState + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift new file mode 100644 index 00000000..de87c126 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceStore.swift @@ -0,0 +1,523 @@ +import Combine +import Foundation + +@MainActor +final class MaintenanceStore: ObservableObject { + @Published var selectedWorkflow: MaintenanceWorkflow = .activate { + didSet { syncFromWorkflowStores() } + } + @Published var mountWait = "30" { + didSet { markPlansStaleForOptionChange() } + } + @Published var noReboot = false { + didSet { markPlansStaleForOptionChange() } + } + @Published var noWait = false { + didSet { markPlansStaleForOptionChange() } + } + @Published var repairPath = "" { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairRecursive = true { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairMaxDepth = "" { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairIncludeHidden = false { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairIncludeTimeMachine = false { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairFixPermissions = false { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var repairVerbose = false { + didSet { markRepairScanStaleIfNeeded() } + } + @Published var selectedFsckTargetID: FsckTargetViewModel.ID? { + didSet { + guard selectedFsckTargetID != fsckStore.selectedTargetID else { + return + } + fsckStore.selectTarget(id: selectedFsckTargetID, options: currentOptions) + syncFromWorkflowStores() + } + } + + @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? + @Published private(set) var currentStagesByWorkflow: [MaintenanceWorkflow: OperationStageState] = [:] + @Published private(set) var errorsByWorkflow: [MaintenanceWorkflow: BackendErrorViewModel] = [:] + + let backend: BackendClient + let activationStore: ActivationStore + let uninstallStore: UninstallStore + let fsckStore: FsckStore + let repairXattrsStore: RepairXattrsStore + + private let coordinator: OperationCoordinator? + private let laneKeysByWorkflow: [MaintenanceWorkflow: OperationLaneKey] + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + let backendsByWorkflow = Self.standaloneBackends(primary: backend) + self.backend = backend + self.coordinator = nil + self.laneKeysByWorkflow = [:] + self.activationStore = ActivationStore(backend: backendsByWorkflow[.activate] ?? backend) + self.uninstallStore = UninstallStore(backend: backendsByWorkflow[.uninstall] ?? backend.makeSibling()) + self.fsckStore = FsckStore(backend: backendsByWorkflow[.fsck] ?? backend.makeSibling()) + self.repairXattrsStore = RepairXattrsStore(backend: backendsByWorkflow[.repairXattrs] ?? backend.makeSibling()) + observeWorkflowStores() + syncFromWorkflowStores() + } + + convenience init(coordinator: OperationCoordinator) { + self.init(coordinator: coordinator, laneKey: .app) + } + + init(coordinator: OperationCoordinator, laneKey: OperationLaneKey) { + let laneKeysByWorkflow = Self.laneKeysByWorkflow(from: laneKey) + let backendsByWorkflow = Self.coordinatedBackends( + coordinator: coordinator, + laneKeysByWorkflow: laneKeysByWorkflow + ) + self.backend = backendsByWorkflow[.activate] ?? coordinator.lane(for: laneKey).backend + self.coordinator = coordinator + self.laneKeysByWorkflow = laneKeysByWorkflow + self.activationStore = ActivationStore( + backend: backendsByWorkflow[.activate] ?? coordinator.lane(for: laneKey).backend, + coordinator: coordinator, + laneKey: laneKeysByWorkflow[.activate] + ) + self.uninstallStore = UninstallStore( + backend: backendsByWorkflow[.uninstall] ?? coordinator.lane(for: laneKey).backend, + coordinator: coordinator, + laneKey: laneKeysByWorkflow[.uninstall] + ) + self.fsckStore = FsckStore( + backend: backendsByWorkflow[.fsck] ?? coordinator.lane(for: laneKey).backend, + coordinator: coordinator, + laneKey: laneKeysByWorkflow[.fsck] + ) + self.repairXattrsStore = RepairXattrsStore( + backend: backendsByWorkflow[.repairXattrs] ?? coordinator.lane(for: laneKey).backend, + coordinator: coordinator, + laneKey: laneKeysByWorkflow[.repairXattrs] + ) + observeWorkflowStores() + syncFromWorkflowStores() + } + + private static func standaloneBackends(primary backend: BackendClient) -> [MaintenanceWorkflow: BackendClient] { + Dictionary(uniqueKeysWithValues: MaintenanceWorkflow.allCases.map { workflow in + workflow == .activate ? (workflow, backend) : (workflow, backend.makeSibling()) + }) + } + + private static func coordinatedBackends( + coordinator: OperationCoordinator, + laneKeysByWorkflow: [MaintenanceWorkflow: OperationLaneKey] + ) -> [MaintenanceWorkflow: BackendClient] { + Dictionary(uniqueKeysWithValues: MaintenanceWorkflow.allCases.map { workflow in + let laneKey = laneKeysByWorkflow[workflow] ?? .app + return (workflow, coordinator.lane(for: laneKey).backend) + }) + } + + private static func laneKeysByWorkflow(from laneKey: OperationLaneKey) -> [MaintenanceWorkflow: OperationLaneKey] { + switch laneKey { + case .app: + return Dictionary(uniqueKeysWithValues: MaintenanceWorkflow.allCases.map { workflow in + (workflow, .appWorkflow(workflow.deviceWorkflowLane)) + }) + case .device(let profileID), .deviceWorkflow(let profileID, .maintenance): + return Dictionary(uniqueKeysWithValues: MaintenanceWorkflow.allCases.map { workflow in + (workflow, .deviceWorkflow(profileID, workflow.deviceWorkflowLane)) + }) + default: + return Dictionary(uniqueKeysWithValues: MaintenanceWorkflow.allCases.map { workflow in + (workflow, laneKey) + }) + } + } + + private func observeWorkflowStores() { + observe(activationStore) + observe(uninstallStore) + observe(fsckStore) + observe(repairXattrsStore) + } + + private func observe(_ store: Store) where Store.ObjectWillChangePublisher == ObservableObjectPublisher { + store.objectWillChange + .sink { [weak self] _ in + Task { @MainActor in + self?.syncFromWorkflowStores() + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + workflowStores.flatMap(\.events) + } + + var isRunning: Bool { + workflowStores.contains { $0.isRunning } + } + + var isBusy: Bool { + let maintenanceBusy = workflowStores.contains { $0.isBusy } + let deviceBusy = deviceProfileID.map { coordinator?.isDeviceBusy($0) ?? false } == true + return maintenanceBusy || deviceBusy + } + + var canCancel: Bool { + activeWorkflowStore?.canCancel ?? false + } + + func timelineEvents(for workflow: MaintenanceWorkflow) -> [BackendEvent] { + workflowStore(for: workflow).events + } + + func currentStage(for workflow: MaintenanceWorkflow) -> OperationStageState? { + currentStagesByWorkflow[workflow] + } + + func error(for workflow: MaintenanceWorkflow) -> BackendErrorViewModel? { + errorsByWorkflow[workflow] + } + + func pendingConfirmation(for workflow: MaintenanceWorkflow) -> PendingConfirmation? { + workflowStore(for: workflow).pendingConfirmation + } + + func confirmPending(for workflow: MaintenanceWorkflow) { + workflowStore(for: workflow).confirmPending() + } + + func cancelPendingConfirmation(for workflow: MaintenanceWorkflow) { + switch workflow { + case .activate: + activationStore.cancelPendingConfirmation() + case .uninstall: + uninstallStore.cancelPendingConfirmation(options: currentOptions) + case .fsck: + fsckStore.cancelPendingConfirmation(options: currentOptions) + case .repairXattrs: + repairXattrsStore.cancelPendingConfirmation(path: trimmedRepairPath, options: currentRepairOptions) + } + syncFromWorkflowStores() + } + + var mountWaitValue: Int? { + ValueParsers.nonNegativeInteger(mountWait) + } + + var selectedFsckTarget: FsckTargetViewModel? { + fsckStore.selectedTarget + } + + var canPlanActivation: Bool { + !isBusy && activationStore.canPlan + } + + var canRunActivation: Bool { + !isBusy && activationStore.canRun + } + + var canPlanUninstall: Bool { + !isBusy && uninstallStore.canPlan(options: currentOptions) + } + + var canRunUninstall: Bool { + !isBusy && uninstallStore.canRun(options: currentOptions) + } + + var canFindFsckVolumes: Bool { + !isBusy && fsckStore.canFindVolumes(mountWaitValue: mountWaitValue) + } + + var canPlanFsck: Bool { + !isBusy && fsckStore.canPlan(options: currentOptions) + } + + var canRunFsck: Bool { + !isBusy && fsckStore.canRun(options: currentOptions) + } + + var canRepairXattrs: Bool { + !isBusy && repairXattrsStore.canRepair(path: trimmedRepairPath, options: currentRepairOptions) + } + + var canScanRepairXattrs: Bool { + !isBusy && repairXattrsStore.canScan(path: trimmedRepairPath, options: currentRepairOptions) + } + + @discardableResult + func planActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + startMaintenanceWorkflow( + .activate, + rejectAlreadyRunning: { activationStore.rejectAlreadyRunning() }, + start: { activationStore.planActivation(password: password, profile: profile) } + ) + } + + @discardableResult + func runActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + startMaintenanceWorkflow( + .activate, + rejectAlreadyRunning: { activationStore.rejectAlreadyRunning() }, + start: { activationStore.runActivation(password: password, profile: profile) } + ) + } + + @discardableResult + func planUninstall(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + startMaintenanceWorkflow( + .uninstall, + rejectAlreadyRunning: { uninstallStore.rejectAlreadyRunning() }, + start: { uninstallStore.planUninstall(options: currentOptions, password: password, profile: profile) } + ) + } + + @discardableResult + func runUninstall(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + startMaintenanceWorkflow( + .uninstall, + rejectAlreadyRunning: { uninstallStore.rejectAlreadyRunning() }, + start: { uninstallStore.runUninstall(options: currentOptions, password: password, profile: profile) } + ) + } + + @discardableResult + func refreshFsckTargets(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + startMaintenanceWorkflow( + .fsck, + rejectAlreadyRunning: { fsckStore.rejectAlreadyRunning() }, + start: { fsckStore.refreshTargets(mountWaitValue: mountWaitValue, password: password, profile: profile) } + ) + } + + @discardableResult + func planFsck(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + startMaintenanceWorkflow( + .fsck, + rejectAlreadyRunning: { fsckStore.rejectAlreadyRunning() }, + start: { fsckStore.planFsck(options: currentOptions, password: password, profile: profile) } + ) + } + + @discardableResult + func runFsck(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + startMaintenanceWorkflow( + .fsck, + rejectAlreadyRunning: { fsckStore.rejectAlreadyRunning() }, + start: { fsckStore.runFsck(options: currentOptions, password: password, profile: profile) } + ) + } + + @discardableResult + func scanRepairXattrs() -> OperationStartResult { + startMaintenanceWorkflow( + .repairXattrs, + rejectAlreadyRunning: { repairXattrsStore.rejectAlreadyRunning() }, + start: { repairXattrsStore.scanRepairXattrs(path: trimmedRepairPath, options: currentRepairOptions) } + ) + } + + @discardableResult + func runRepairXattrs() -> OperationStartResult { + startMaintenanceWorkflow( + .repairXattrs, + rejectAlreadyRunning: { repairXattrsStore.rejectAlreadyRunning() }, + start: { repairXattrsStore.runRepairXattrs(path: trimmedRepairPath, options: currentRepairOptions) } + ) + } + + func clear() { + activationStore.clear() + uninstallStore.clear() + fsckStore.clear() + repairXattrsStore.clear() + syncFromWorkflowStores() + } + + func cancel() { + activeWorkflowStore?.cancel() + } + + private func begin(workflow: MaintenanceWorkflow) -> Bool { + selectedWorkflow = workflow + return !isBusy + } + + private func startMaintenanceWorkflow( + _ workflow: MaintenanceWorkflow, + rejectAlreadyRunning: () -> OperationStartResult, + start: () -> OperationStartResult + ) -> OperationStartResult { + let result = begin(workflow: workflow) ? start() : rejectAlreadyRunning() + syncFromWorkflowStores() + return result + } + + private var workflowStores: [any MaintenanceWorkflowStore] { + [activationStore, uninstallStore, fsckStore, repairXattrsStore] + } + + private var activeWorkflowStore: (any MaintenanceWorkflowStore)? { + workflowStores.first { $0.isBusy } + } + + private var deviceProfileID: DeviceProfile.ID? { + laneKeysByWorkflow.values.lazy.compactMap(\.deviceProfileID).first + } + + private func workflowStore(for workflow: MaintenanceWorkflow) -> any MaintenanceWorkflowStore { + switch workflow { + case .activate: + return activationStore + case .uninstall: + return uninstallStore + case .fsck: + return fsckStore + case .repairXattrs: + return repairXattrsStore + } + } + + 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 var repairMaxDepthValue: Int? { + let trimmed = repairMaxDepth.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + return ValueParsers.nonNegativeInteger(trimmed) + } + + private var currentRepairOptions: RepairXattrsOptions? { + let trimmed = repairMaxDepth.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, repairMaxDepthValue == nil { + return nil + } + return RepairXattrsOptions( + recursive: repairRecursive, + maxDepth: repairMaxDepthValue, + includeHidden: repairIncludeHidden, + includeTimeMachine: repairIncludeTimeMachine, + fixPermissions: repairFixPermissions, + verbose: repairVerbose + ) + } + + private func markPlansStaleForOptionChange() { + uninstallStore.markPlanStaleIfNeeded(options: currentOptions) + fsckStore.markPlanStaleIfNeeded(options: currentOptions) + syncFromWorkflowStores() + } + + private func markRepairScanStaleIfNeeded() { + repairXattrsStore.markScanStaleIfNeeded(path: trimmedRepairPath, options: currentRepairOptions) + syncFromWorkflowStores() + } + + private func syncFromWorkflowStores() { + activateState = activationStore.state + activationPlan = activationStore.plan + activationResult = activationStore.result + + uninstallState = uninstallStore.state + uninstallPlan = uninstallStore.plan + uninstallResult = uninstallStore.result + + fsckState = fsckStore.state + fsckTargets = fsckStore.targets + if selectedFsckTargetID != fsckStore.selectedTargetID { + selectedFsckTargetID = fsckStore.selectedTargetID + } + fsckPlan = fsckStore.plan + fsckResult = fsckStore.result + + repairState = repairXattrsStore.state + repairScan = repairXattrsStore.scan + repairResult = repairXattrsStore.result + + currentStagesByWorkflow = workflowStages + errorsByWorkflow = workflowErrors + currentStage = currentStagesByWorkflow[selectedWorkflow] ?? currentStagesByWorkflow.values.first + error = errorsByWorkflow[selectedWorkflow] ?? errorsByWorkflow.values.first + passwordInvalidProfileID = [ + activationStore.passwordInvalidProfileID, + uninstallStore.passwordInvalidProfileID, + fsckStore.passwordInvalidProfileID, + repairXattrsStore.passwordInvalidProfileID + ].compactMap { $0 }.first + } + + private var workflowStages: [MaintenanceWorkflow: OperationStageState] { + var stages: [MaintenanceWorkflow: OperationStageState] = [:] + stages[.activate] = activationStore.currentStage + stages[.uninstall] = uninstallStore.currentStage + stages[.fsck] = fsckStore.currentStage + stages[.repairXattrs] = repairXattrsStore.currentStage + return stages + } + + private var workflowErrors: [MaintenanceWorkflow: BackendErrorViewModel] { + var errors: [MaintenanceWorkflow: BackendErrorViewModel] = [:] + errors[.activate] = activationStore.error + errors[.uninstall] = uninstallStore.error + errors[.fsck] = fsckStore.error + errors[.repairXattrs] = repairXattrsStore.error + return errors + } +} + +@MainActor +private protocol MaintenanceWorkflowStore: ObservableObject { + var events: [BackendEvent] { get } + var isRunning: Bool { get } + var isBusy: Bool { get } + var canCancel: Bool { get } + var pendingConfirmation: PendingConfirmation? { get } + func confirmPending() + func cancel() +} + +extension ActivationStore: MaintenanceWorkflowStore {} +extension UninstallStore: MaintenanceWorkflowStore {} +extension FsckStore: MaintenanceWorkflowStore {} +extension RepairXattrsStore: MaintenanceWorkflowStore {} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceWorkflowOperation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceWorkflowOperation.swift new file mode 100644 index 00000000..766472f1 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/MaintenanceWorkflowOperation.swift @@ -0,0 +1,106 @@ +import Foundation + +@MainActor +final class MaintenanceWorkflowOperation { + let name: String + + private let runner: MaintenanceOperationRunner + + init( + name: String, + backend: BackendClient, + coordinator: OperationCoordinator? = nil, + laneKey: OperationLaneKey? = nil + ) { + self.name = name + self.runner = MaintenanceOperationRunner( + backend: backend, + coordinator: coordinator, + laneKey: laneKey, + onEvent: { _, _ in }, + onRunningChanged: {} + ) + } + + func bind( + onEvent: @escaping (BackendEvent, ActiveOperation) -> Void, + onRunningChanged: @escaping () -> Void + ) { + runner.rebind(onEvent: onEvent, onRunningChanged: onRunningChanged) + } + + var events: [BackendEvent] { runner.events } + var isRunning: Bool { runner.isRunning } + var isBusy: Bool { runner.isBusy } + var canCancel: Bool { runner.canCancel } + var pendingConfirmation: PendingConfirmation? { runner.pendingConfirmation } + + func confirmPending() { + runner.confirmPending() + } + + func cancelPendingConfirmation() { + runner.cancelPendingConfirmation() + } + + func cancel() { + runner.cancel() + } + + func clear() { + runner.clear() + } + + func resetForRun() { + runner.resetForRun() + } + + func finishObserver() { + runner.finishObserver() + } + + @discardableResult + func start( + params: [String: JSONValue], + profile: DeviceProfile?, + password: String?, + rejectAlreadyRunning: () -> Void, + resetRunState: () -> Void, + rejectRun: (String) -> Void + ) -> OperationStartResult { + guard !isBusy else { + rejectAlreadyRunning() + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + resetRunState() + let start = runner.start(operation: name, params: params, profile: profile, password: password) + if case .rejected(let message) = start { + rejectRun(message) + } + return start + } + + func localError(_ localError: WorkflowLocalError) -> BackendErrorViewModel { + BackendErrorViewModel(operation: name, localError: localError) + } + + func rejectedError(message: String) -> BackendErrorViewModel { + BackendErrorViewModel(operation: name, code: "operation_rejected", message: message) + } + + func falseResultError(from event: BackendEvent) -> BackendErrorViewModel { + BackendErrorViewModel( + operation: name, + code: "operation_failed", + message: event.localizedPayloadSummaryText ?? event.localizedSummary + ) + } + + func contractDecodeError(_ decodeError: Error) -> BackendErrorViewModel { + BackendErrorViewModel( + operation: name, + code: "contract_decode_failed", + message: decodeError.localizedDescription + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift new file mode 100644 index 00000000..1e6897fd --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationCoordinator.swift @@ -0,0 +1,545 @@ +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 + } +} + +enum DeviceWorkflowLane: String, Hashable, Equatable, CaseIterable { + case configure + case deploy + case doctor + case reachability + case maintenance + case activate + case uninstall + case fsck + case repairXattrs = "repair_xattrs" + case flash + + static func lane(for operation: String) -> DeviceWorkflowLane? { + switch operation { + case "configure": + return .configure + case "deploy": + return .deploy + case "doctor": + return .doctor + case "reachability": + return .reachability + case "activate": + return .activate + case "uninstall": + return .uninstall + case "fsck": + return .fsck + case "repair-xattrs": + return .repairXattrs + case "flash": + return .flash + default: + return nil + } + } +} + +enum OperationLaneKey: Hashable, Equatable, Identifiable, CustomStringConvertible { + case app + case appWorkflow(DeviceWorkflowLane) + case device(DeviceProfile.ID) + case deviceWorkflow(DeviceProfile.ID, DeviceWorkflowLane) + case candidateHost(String) + case localPath(String) + + var id: String { + switch self { + case .app: + return "app" + case .appWorkflow(let workflow): + return "app:\(workflow.rawValue)" + case .device(let profileID): + return "device:\(profileID)" + case .deviceWorkflow(let profileID, let workflow): + return "device:\(profileID):\(workflow.rawValue)" + case .candidateHost(let host): + return "candidate:\(host)" + case .localPath(let path): + return "local-path:\(path)" + } + } + + var description: String { + id + } + + var deviceProfileID: DeviceProfile.ID? { + switch self { + case .device(let profileID), .deviceWorkflow(let profileID, _): + return profileID + case .app, .appWorkflow, .candidateHost, .localPath: + return nil + } + } +} + +private enum OperationResourceKey: Hashable, Equatable { + case device(DeviceProfile.ID) +} + +@MainActor +final class OperationLane: ObservableObject { + let key: OperationLaneKey + let backend: BackendClient + + @Published private(set) var activeOperation: ActiveOperation? + @Published private(set) var rejectedOperationMessage: String? + + var onStateChanged: (() -> Void)? + + private var isReplayingConfirmation = false + private var cancellables: Set = [] + + init(key: OperationLaneKey, backend: BackendClient) { + self.key = key + self.backend = backend + + Publishers.CombineLatest(backend.$isRunning, backend.$pendingConfirmation) + .sink { [weak self] isRunning, pendingConfirmation in + guard let self else { return } + if !isRunning && pendingConfirmation == nil && !self.isReplayingConfirmation { + self.activeOperation = nil + self.onStateChanged?() + } + } + .store(in: &cancellables) + } + + var isBusy: Bool { + backend.isRunning || backend.pendingConfirmation != nil + } + + var canCancel: Bool { + backend.canCancel + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + context: DeviceRuntimeContext?, + activeDeviceID: DeviceProfile.ID?, + password: String? = nil + ) -> OperationStartResult { + guard !isBusy else { + let message = L10n.string("operation.error.already_running") + rejectedOperationMessage = message + onStateChanged?() + return .rejected(message) + } + + let updatedParams = OperationCredentialInjector.injectingPassword(password, into: params) + + let activeOperation = ActiveOperation( + operation: operation, + profileID: activeDeviceID, + context: context + ) + rejectedOperationMessage = nil + self.activeOperation = activeOperation + backend.run( + operation: operation, + params: updatedParams, + context: context, + requestID: activeOperation.id.uuidString + ) + onStateChanged?() + return .started(activeOperation) + } + + func reject(_ message: String) { + rejectedOperationMessage = message + onStateChanged?() + } + + func confirmPending() { + guard backend.pendingConfirmation != nil else { + return + } + isReplayingConfirmation = true + backend.confirmPending() + isReplayingConfirmation = false + onStateChanged?() + } + + func cancelPendingConfirmation() { + backend.cancelPendingConfirmation() + onStateChanged?() + } + + func cancel() { + backend.cancel() + } + + func clear() { + backend.clear() + rejectedOperationMessage = nil + activeOperation = nil + onStateChanged?() + } +} + +@MainActor +final class OperationCoordinator: ObservableObject { + @Published private(set) var activeOperations: [OperationLaneKey: ActiveOperation] = [:] + @Published private(set) var activeOperation: ActiveOperation? + @Published private(set) var activeDeviceID: DeviceProfile.ID? + @Published private(set) var rejectedOperationMessages: [OperationLaneKey: String] = [:] + @Published private(set) var rejectedOperationMessage: String? + @Published private(set) var lanesRevision = 0 + + let appLane: OperationLane + + private var lanes: [OperationLaneKey: OperationLane] = [:] + private var laneCancellables: [OperationLaneKey: Set] = [:] + private var helperPathCancellable: AnyCancellable? + + var backend: BackendClient { + appLane.backend + } + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.appLane = OperationLane(key: .app, backend: backend) + lanes[.app] = appLane + observe(lane: appLane) + helperPathCancellable = backend.$helperPath + .sink { [weak self] helperPath in + Task { @MainActor in + self?.syncHelperPath(helperPath) + } + } + } + + func lane(for key: OperationLaneKey) -> OperationLane { + if let lane = lanes[key] { + return lane + } + let lane = OperationLane(key: key, backend: backend.makeSibling()) + lanes[key] = lane + observe(lane: lane) + refreshLaneState() + return lane + } + + func lane(for profile: DeviceProfile) -> OperationLane { + lane(for: .device(profile.id)) + } + + var allLanes: [OperationLane] { + lanes.values.sorted { left, right in + laneSortKey(left.key) < laneSortKey(right.key) + } + } + + var pendingConfirmation: PendingConfirmation? { + pendingConfirmationLane?.backend.pendingConfirmation + } + + var pendingConfirmationLane: OperationLane? { + if let primary = primaryLane(), primary.backend.pendingConfirmation != nil { + return primary + } + return allLanes.first { $0.backend.pendingConfirmation != nil } + } + + var canCancel: Bool { + primaryLane()?.canCancel ?? false + } + + var hasActiveWork: Bool { + allLanes.contains { $0.isBusy } + } + + func activeOperation(for key: OperationLaneKey) -> ActiveOperation? { + lane(for: key).activeOperation + } + + func activeOperation(for profile: DeviceProfile) -> ActiveOperation? { + activeDeviceLane(for: profile.id)?.activeOperation + } + + func isDeviceBusy(_ profileID: DeviceProfile.ID) -> Bool { + allLanes.contains { lane in + resourceKey(for: lane) == .device(profileID) && lane.isBusy + } + } + + func isDeviceBusy(_ profile: DeviceProfile) -> Bool { + isDeviceBusy(profile.id) + } + + @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, + laneKey: profile.map { defaultLaneKey(operation: operation, activeDeviceID: $0.id) } ?? .app + ) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + laneKey: OperationLaneKey + ) -> OperationStartResult { + run( + operation: operation, + params: params, + context: nil, + activeDeviceID: nil, + laneKey: laneKey + ) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + context: DeviceRuntimeContext?, + activeDeviceID: DeviceProfile.ID?, + password: String? = nil, + laneKey: OperationLaneKey? = nil + ) -> OperationStartResult { + let resolvedLaneKey = laneKey ?? defaultLaneKey(operation: operation, activeDeviceID: activeDeviceID) + let lane = lane(for: resolvedLaneKey) + if let resourceKey = resourceKey(for: resolvedLaneKey, activeDeviceID: activeDeviceID), + conflictingLane(for: resourceKey, excluding: resolvedLaneKey) != nil { + let message = L10n.string("operation.error.already_running") + lane.reject(message) + refreshLaneState() + return .rejected(message) + } + let result = lane.run( + operation: operation, + params: params, + context: context, + activeDeviceID: activeDeviceID, + password: password + ) + refreshLaneState() + return result + } + + func confirmPending() { + pendingConfirmationLane?.confirmPending() + refreshLaneState() + } + + func cancelPendingConfirmation() { + pendingConfirmationLane?.cancelPendingConfirmation() + refreshLaneState() + } + + func cancel() { + primaryLane()?.cancel() + } + + func cancel(laneKey: OperationLaneKey) { + lane(for: laneKey).cancel() + } + + func clear() { + for lane in lanes.values { + lane.clear() + } + refreshLaneState() + } + + func clear(laneKey: OperationLaneKey) { + lane(for: laneKey).clear() + refreshLaneState() + } + + private func observe(lane: OperationLane) { + var cancellables: Set = [] + + lane.onStateChanged = { [weak self] in + self?.refreshLaneState() + } + lane.backend.$events + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + lane.backend.$isRunning + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + lane.backend.$activeOperationName + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + lane.backend.$pendingConfirmation + .sink { [weak self] _ in + Task { @MainActor in + self?.refreshLaneState() + } + } + .store(in: &cancellables) + + laneCancellables[lane.key] = cancellables + } + + private func refreshLaneState() { + let active = lanes.compactMapValues(\.activeOperation) + activeOperations = active + rejectedOperationMessages = lanes.compactMapValues(\.rejectedOperationMessage) + rejectedOperationMessage = primaryRejection(from: rejectedOperationMessages) + let primary = primaryLane() + activeOperation = primary?.activeOperation + activeDeviceID = activeOperation?.profileID + lanesRevision += 1 + } + + private func primaryLane() -> OperationLane? { + if let runningDevice = allLanes.first(where: { lane in + lane.key != .app && lane.backend.isRunning + }) { + return runningDevice + } + if appLane.backend.isRunning { + return appLane + } + if let pendingDevice = allLanes.first(where: { lane in + lane.key != .app && lane.backend.pendingConfirmation != nil + }) { + return pendingDevice + } + if appLane.backend.pendingConfirmation != nil { + return appLane + } + return allLanes.first { $0.activeOperation != nil } + } + + private func activeDeviceLane(for profileID: DeviceProfile.ID) -> OperationLane? { + allLanes.first { lane in + resourceKey(for: lane) == .device(profileID) && lane.activeOperation != nil + } + } + + private func conflictingLane(for resourceKey: OperationResourceKey, excluding laneKey: OperationLaneKey) -> OperationLane? { + allLanes.first { lane in + lane.key != laneKey && self.resourceKey(for: lane) == resourceKey && lane.isBusy + } + } + + private func defaultLaneKey(operation: String, activeDeviceID: DeviceProfile.ID?) -> OperationLaneKey { + guard let activeDeviceID else { + return .app + } + if let workflow = DeviceWorkflowLane.lane(for: operation) { + return .deviceWorkflow(activeDeviceID, workflow) + } + return .device(activeDeviceID) + } + + private func resourceKey(for lane: OperationLane) -> OperationResourceKey? { + resourceKey(for: lane.key, activeDeviceID: lane.activeOperation?.profileID) + } + + private func resourceKey( + for laneKey: OperationLaneKey, + activeDeviceID: DeviceProfile.ID? + ) -> OperationResourceKey? { + if let profileID = laneKey.deviceProfileID ?? activeDeviceID { + return .device(profileID) + } + return nil + } + + private func primaryRejection(from messages: [OperationLaneKey: String]) -> String? { + if let primaryKey = primaryLane()?.key, let message = messages[primaryKey] { + return message + } + return messages.values.first + } + + private func syncHelperPath(_ helperPath: String) { + for lane in lanes.values where lane.backend !== appLane.backend { + if lane.backend.helperPath != helperPath { + lane.backend.helperPath = helperPath + } + } + } + + private func laneSortKey(_ key: OperationLaneKey) -> String { + switch key { + case .app: + return "0:app" + case .appWorkflow(let workflow): + return "0:app:\(workflow.rawValue)" + case .device(let profileID): + return "1:\(profileID)" + case .deviceWorkflow(let profileID, let workflow): + return "1:\(profileID):\(workflow.rawValue)" + case .candidateHost(let host): + return "2:\(host)" + case .localPath(let path): + return "3:\(path)" + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift new file mode 100644 index 00000000..a34fe763 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/OperationTimeline.swift @@ -0,0 +1,238 @@ +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? +} + +private struct OperationStageLocalization { + let titleKey: String + let detailKey: String +} + +enum OperationTimelineBuilder { + private static let activateStageLocalizations: [String: OperationStageLocalization] = [ + "probe_runtime": .init(titleKey: "timeline.activate.title.probe_runtime", detailKey: "timeline.activate.detail.probe_runtime") + ] + + private static let deployStageLocalizations: [String: OperationStageLocalization] = [ + "load_config": .init(titleKey: "timeline.deploy.title.load_config", detailKey: "timeline.deploy.detail.load_config"), + "resolve_managed_target": .init(titleKey: "timeline.deploy.title.resolve_managed_target", detailKey: "timeline.deploy.detail.resolve_managed_target"), + "validate_artifacts": .init(titleKey: "timeline.deploy.title.validate_artifacts", detailKey: "timeline.deploy.detail.validate_artifacts"), + "check_compatibility": .init(titleKey: "timeline.deploy.title.check_compatibility", detailKey: "timeline.deploy.detail.check_compatibility"), + "read_mast": .init(titleKey: "timeline.deploy.title.read_mast", detailKey: "timeline.deploy.detail.read_mast"), + "select_payload_home": .init(titleKey: "timeline.deploy.title.select_payload_home", detailKey: "timeline.deploy.detail.select_payload_home"), + "build_deployment_plan": .init(titleKey: "timeline.deploy.title.build_deployment_plan", detailKey: "timeline.deploy.detail.build_deployment_plan"), + "pre_upload_actions": .init(titleKey: "timeline.deploy.title.pre_upload_actions", detailKey: "timeline.deploy.detail.pre_upload_actions"), + "prepare_deployment_files": .init(titleKey: "timeline.deploy.title.prepare_deployment_files", detailKey: "timeline.deploy.detail.prepare_deployment_files"), + "upload_payload": .init(titleKey: "timeline.deploy.title.upload_payload", detailKey: "timeline.deploy.detail.upload_payload"), + "upload_smbd": .init(titleKey: "timeline.deploy.title.upload_smbd", detailKey: "timeline.deploy.detail.upload_smbd"), + "upload_mdns_advertiser": .init(titleKey: "timeline.deploy.title.upload_mdns_advertiser", detailKey: "timeline.deploy.detail.upload_mdns_advertiser"), + "upload_nbns_advertiser": .init(titleKey: "timeline.deploy.title.upload_nbns_advertiser", detailKey: "timeline.deploy.detail.upload_nbns_advertiser"), + "upload_boot_files": .init(titleKey: "timeline.deploy.title.upload_boot_files", detailKey: "timeline.deploy.detail.upload_boot_files"), + "upload_runtime_config": .init(titleKey: "timeline.deploy.title.upload_runtime_config", detailKey: "timeline.deploy.detail.upload_runtime_config"), + "upload_samba_accounts": .init(titleKey: "timeline.deploy.title.upload_samba_accounts", detailKey: "timeline.deploy.detail.upload_samba_accounts"), + "post_upload_actions": .init(titleKey: "timeline.deploy.title.post_upload_actions", detailKey: "timeline.deploy.detail.post_upload_actions"), + "verify_payload_upload": .init(titleKey: "timeline.deploy.title.verify_payload_upload", detailKey: "timeline.deploy.detail.verify_payload_upload"), + "flush_payload_upload": .init(titleKey: "timeline.deploy.title.flush_payload_upload", detailKey: "timeline.deploy.detail.flush_payload_upload"), + "verify_payload_upload_after_sync": .init(titleKey: "timeline.deploy.title.verify_payload_upload_after_sync", detailKey: "timeline.deploy.detail.verify_payload_upload_after_sync"), + "reboot": .init(titleKey: "timeline.deploy.title.reboot", detailKey: "timeline.deploy.detail.reboot"), + "wait_for_reboot_down": .init(titleKey: "timeline.deploy.title.wait_for_reboot_down", detailKey: "timeline.deploy.detail.wait_for_reboot_down"), + "wait_for_reboot_up": .init(titleKey: "timeline.deploy.title.wait_for_reboot_up", detailKey: "timeline.deploy.detail.wait_for_reboot_up"), + "probe_runtime": .init(titleKey: "timeline.deploy.title.probe_runtime", detailKey: "timeline.deploy.detail.probe_runtime"), + "activate_runtime": .init(titleKey: "timeline.deploy.title.activate_runtime", detailKey: "timeline.deploy.detail.activate_runtime"), + "post_reboot_activation": .init(titleKey: "timeline.deploy.title.post_reboot_activation", detailKey: "timeline.deploy.detail.post_reboot_activation"), + "verify_runtime_activation": .init(titleKey: "timeline.deploy.title.verify_runtime_activation", detailKey: "timeline.deploy.detail.verify_runtime_activation"), + "verify_runtime_reboot": .init(titleKey: "timeline.deploy.title.verify_runtime_reboot", detailKey: "timeline.deploy.detail.verify_runtime_reboot") + ] + + private static let stageLocalizations: [String: [String: OperationStageLocalization]] = [ + "activate": activateStageLocalizations, + "deploy": deployStageLocalizations + ] + + 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: stageTitle(for: event.operation, stage: event.stage), + detail: stageDetail(for: event.operation, stage: event.stage, fallback: event.description), + state: stageState(forEventAt: index, in: events), + 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.localizedPayloadSummaryText ?? event.localizedSummary, + 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 + } + } + } + + private static func stageState(forEventAt index: Int, in events: [BackendEvent]) -> OperationTimelineItem.State { + let event = events[index] + let laterEvents = events.dropFirst(index + 1).filter { $0.operation == event.operation } + for laterEvent in laterEvents { + switch laterEvent.type { + case "stage": + return .succeeded + case "result": + return laterEvent.ok == true ? .succeeded : .failed + case "error" where laterEvent.code != "confirmation_required": + return .failed + default: + continue + } + } + return .running + } + + static func operationTitle(_ operation: String) -> String { + switch operation { + case "discover": + return L10n.string("timeline.operation.discovery") + case "reachability": + return L10n.string("timeline.operation.reachability") + 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": + return L10n.string("timeline.operation.readiness") + case "set-telemetry": + return L10n.string("timeline.operation.telemetry") + case "version-check": + return L10n.string("timeline.operation.version_check") + case "flash": + return L10n.string("timeline.operation.flash") + default: + return operation + } + } + + static func stageTitle(for operation: String, stage: String?) -> String { + guard let stage else { + return operationTitle(operation) + } + if let title = localizedStageTitle(for: operation, stage: stage) { + return title + } + switch (operation, stage) { + case ("discover", "bonjour_discovery"): + return L10n.string("timeline.stage.finding_devices") + case ("reachability", "build_candidates"): + return L10n.string("timeline.stage.reachability_candidates") + case ("reachability", "check_dns"): + return L10n.string("timeline.stage.reachability_dns") + case ("reachability", "check_ping"): + return L10n.string("timeline.stage.reachability_ping") + case ("reachability", "check_ssh_port"): + return L10n.string("timeline.stage.reachability_ssh_port") + case ("reachability", "check_ssh_auth"): + return L10n.string("timeline.stage.reachability_ssh_auth") + case ("reachability", "check_smb_port"): + return L10n.string("timeline.stage.reachability_smb_port") + case ("configure", "ssh_probe"), ("configure", "ssh_probe_after_acp"): + return L10n.string("timeline.stage.checking_ssh") + case ("configure", "confirm_enable_ssh"): + return L10n.string("timeline.stage.confirming_ssh_enable") + case ("configure", "acp_identity_probe"): + return L10n.string("timeline.stage.checking_airport_identity") + 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 ("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: " ") + } + } + + static func stageDetail(for operation: String, stage: String?, fallback: String?) -> String? { + guard let stage else { + return fallback + } + if let detail = localizedStageDetail(for: operation, stage: stage) { + return detail + } + return fallback + } + + private static func localizedStageTitle(for operation: String, stage: String) -> String? { + stageLocalizations[operation]?[stage].map { L10n.string($0.titleKey) } + } + + private static func localizedStageDetail(for operation: String, stage: String) -> String? { + stageLocalizations[operation]?[stage].map { L10n.string($0.detailKey) } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ProgressTextAnimator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ProgressTextAnimator.swift new file mode 100644 index 00000000..da7a92fe --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/ProgressTextAnimator.swift @@ -0,0 +1,41 @@ +import Foundation + +enum ProgressTextAnimator { + static let frameInterval: TimeInterval = 0.3 + static let frameIntervalNanoseconds: UInt64 = UInt64(frameInterval * 1_000_000_000) + static let frameCount = 3 + + static func message(_ message: String?, isRunning: Bool, phase: Int) -> String? { + guard shouldAnimate(message, isRunning: isRunning), + let base = animationBase(message) else { + return message + } + return base + String(repeating: ".", count: frameIndex(phase) + 1) + } + + static func shouldAnimate(_ message: String?, isRunning: Bool) -> Bool { + guard isRunning, + let message, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + return true + } + + static func nextPhase(after phase: Int) -> Int { + (frameIndex(phase) + 1) % frameCount + } + + private static func animationBase(_ message: String?) -> String? { + guard let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + let stripped = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return stripped.isEmpty ? nil : stripped + } + + private static func frameIndex(_ phase: Int) -> Int { + ((phase % frameCount) + frameCount) % frameCount + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/RecoveryGuidancePresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/RecoveryGuidancePresentation.swift new file mode 100644 index 00000000..7badc5dc --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/RecoveryGuidancePresentation.swift @@ -0,0 +1,80 @@ +import Foundation + +struct RecoveryGuidancePresentation: Equatable { + let title: String + let errorMessage: String + let detail: String? + let steps: [String] + + init(error: BackendErrorViewModel) { + let localizedRecovery = BackendRecoveryLocalization.localized(error.recovery) + self.title = localizedRecovery?.title ?? error.code + self.errorMessage = error.message + self.detail = Self.uniqueDetail(localizedRecovery?.message, title: title, errorMessage: error.message) + self.steps = localizedRecovery?.actions ?? [] + } + + var hasStructuredGuidance: Bool { + detail != nil || !steps.isEmpty + } + + private static func uniqueDetail(_ detail: String?, title: String, errorMessage: String) -> String? { + guard let detail else { + return nil + } + let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + let normalized = trimmed.normalizedRecoveryPresentationText + guard normalized != title.normalizedRecoveryPresentationText, + normalized != errorMessage.normalizedRecoveryPresentationText else { + return nil + } + return trimmed + } +} + +private struct LocalizedBackendRecovery: Equatable { + let title: String + let message: String? + let actions: [String] +} + +private enum BackendRecoveryLocalization { + static func localized(_ recovery: BackendRecoveryPayload?) -> LocalizedBackendRecovery? { + guard let recovery else { + return nil + } + guard let localizationKey = recovery.localizationKey else { + return LocalizedBackendRecovery( + title: recovery.title, + message: recovery.message, + actions: recovery.actions + ) + } + let keyPrefix = "backend.recovery.\(localizationKey)" + return LocalizedBackendRecovery( + title: localizedStringIfPresent("\(keyPrefix).title") ?? recovery.title, + message: localizedStringIfPresent("\(keyPrefix).message") ?? recovery.message, + actions: localizedActions(keyPrefix: keyPrefix, fallback: recovery.actions) + ) + } + + private static func localizedActions(keyPrefix: String, fallback: [String]) -> [String] { + fallback.enumerated().map { index, action in + localizedStringIfPresent("\(keyPrefix).action.\(index + 1)") ?? action + } + } + + private static func localizedStringIfPresent(_ key: String) -> String? { + let value = L10n.string(key) + return value == key ? nil : value + } +} + +private extension String { + var normalizedRecoveryPresentationText: String { + trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/RepairXattrsStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/RepairXattrsStore.swift new file mode 100644 index 00000000..1dc3d724 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/RepairXattrsStore.swift @@ -0,0 +1,273 @@ +import Combine +import Foundation + +@MainActor +final class RepairXattrsStore: ObservableObject { + @Published private(set) var state: MaintenanceOperationState = .idle + @Published private(set) var scan: RepairXattrsPayload? + @Published private(set) var result: RepairXattrsPayload? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + + private let operation: MaintenanceWorkflowOperation + private var scannedPath: String? + private var scannedOptions: RepairXattrsOptions? + private var latestPath: String? + private var latestOptions: RepairXattrsOptions? + + init(backend: BackendClient, coordinator: OperationCoordinator? = nil, laneKey: OperationLaneKey? = nil) { + self.operation = MaintenanceWorkflowOperation( + name: "repair-xattrs", + backend: backend, + coordinator: coordinator, + laneKey: laneKey + ) + self.operation.bind(onEvent: { [weak self] event, activeOperation in + self?.handle(event, activeOperation: activeOperation) + }, onRunningChanged: { [weak self] in + self?.objectWillChange.send() + }) + } + + var events: [BackendEvent] { operation.events } + var isRunning: Bool { operation.isRunning } + var isBusy: Bool { operation.isBusy } + var canCancel: Bool { operation.canCancel } + var pendingConfirmation: PendingConfirmation? { operation.pendingConfirmation } + + func canScan(path: String, options: RepairXattrsOptions?) -> Bool { + return !isBusy && !path.isEmpty && options != nil + } + + func canRepair(path: String, options: RepairXattrsOptions?) -> Bool { + return !isBusy + && state == .scanReady + && scan?.repairableCount ?? 0 > 0 + && scannedPath == path + && scannedOptions == options + } + + func markScanStaleIfNeeded(path: String, options: RepairXattrsOptions?) { + latestPath = path + latestOptions = options + if state == .scanReady, + scannedPath != path || scannedOptions != options { + state = .scanStale + } + } + + func confirmPending() { + operation.confirmPending() + } + + func cancelPendingConfirmation(path: String, options: RepairXattrsOptions?) { + latestPath = path + latestOptions = options + operation.cancelPendingConfirmation() + restoreStateAfterCancellation(path: path, options: options) + } + + func cancel() { + operation.cancel() + } + + func clear() { + operation.clear() + state = .idle + scan = nil + result = nil + currentStage = nil + error = nil + passwordInvalidProfileID = nil + scannedPath = nil + scannedOptions = nil + latestPath = nil + latestOptions = nil + } + + @discardableResult + func scanRepairXattrs(path: String, options: RepairXattrsOptions?) -> OperationStartResult { + latestPath = path + latestOptions = options + guard let options else { + failLocally(.repairXattrsDepthInvalid) + return .rejected(WorkflowLocalError.repairXattrsDepthInvalid.message) + } + guard !path.isEmpty else { + failLocally(.repairXattrsPathRequired) + return .rejected(WorkflowLocalError.repairXattrsPathRequired.message) + } + let start = startRun( + params: OperationParams.RepairXattrs.params(dryRun: true, path: path, options: options), + profile: nil, + password: nil + ) + guard case .started = start else { + return start + } + state = .scanning + scan = nil + result = nil + scannedPath = path + scannedOptions = options + return start + } + + @discardableResult + func runRepairXattrs(path: String, options: RepairXattrsOptions?) -> OperationStartResult { + latestPath = path + latestOptions = options + guard !isBusy else { + return rejectAlreadyRunning() + } + guard canRepair(path: path, options: options), let scannedOptions else { + state = .scanStale + error = operation.localError(.repairXattrsScanStale) + return .rejected(WorkflowLocalError.repairXattrsScanStale.message) + } + let start = startRun( + params: OperationParams.RepairXattrs.params(dryRun: false, path: path, options: scannedOptions), + profile: nil, + password: nil + ) + guard case .started = start else { + return start + } + state = .repairing + result = nil + return start + } + + @discardableResult + func rejectAlreadyRunning() -> OperationStartResult { + rejectRun(.operationAlreadyRunning) + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + + private func startRun( + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? + ) -> OperationStartResult { + operation.start( + params: params, + profile: profile, + password: password, + rejectAlreadyRunning: { rejectRun(.operationAlreadyRunning) }, + resetRunState: resetRunState, + rejectRun: rejectRun(message:) + ) + } + + private func resetRunState() { + operation.resetForRun() + error = nil + currentStage = nil + passwordInvalidProfileID = nil + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard event.operation == operation.name else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .repairing + } + return + } + + if event.type == "error" { + applyError(event, activeOperation: activeOperation) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFalseResult(event) + return + } + + do { + let payload = try event.decodePayload(RepairXattrsPayload.self) + if state == .scanning { + scan = payload + state = .scanReady + } else { + result = payload + state = .repaired + } + error = nil + operation.finishObserver() + } catch { + failContract(error) + } + } + + private func applyError(_ event: BackendEvent, activeOperation: ActiveOperation) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "confirmation_cancelled" { + error = nil + currentStage = nil + operation.finishObserver() + restoreStateAfterCancellation(path: latestPath ?? "", options: latestOptions) + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation.profileID + } + error = BackendErrorViewModel(event: event) + state = .failed + operation.finishObserver() + } + + private func restoreStateAfterCancellation(path: String, options: RepairXattrsOptions?) { + guard scan != nil else { + state = .idle + return + } + state = scannedPath == path && scannedOptions == options ? .scanReady : .scanStale + } + + private func applyFalseResult(_ event: BackendEvent) { + error = operation.falseResultError(from: event) + state = .failed + operation.finishObserver() + } + + private func failContract(_ decodeError: Error) { + error = operation.contractDecodeError(decodeError) + state = .failed + operation.finishObserver() + } + + private func failLocally(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(message: String) { + error = operation.rejectedError(message: message) + currentStage = nil + state = .failed + operation.finishObserver() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/UninstallStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/UninstallStore.swift new file mode 100644 index 00000000..23a55541 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Workflows/UninstallStore.swift @@ -0,0 +1,288 @@ +import Combine +import Foundation + +@MainActor +final class UninstallStore: ObservableObject { + @Published private(set) var state: MaintenanceOperationState = .idle + @Published private(set) var plan: UninstallPlanPayload? + @Published private(set) var result: MaintenanceResultPayload? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? + + private let operation: MaintenanceWorkflowOperation + private var plannedOptions: MaintenanceOptions? + private var latestOptions: MaintenanceOptions? + + init(backend: BackendClient, coordinator: OperationCoordinator? = nil, laneKey: OperationLaneKey? = nil) { + self.operation = MaintenanceWorkflowOperation( + name: "uninstall", + backend: backend, + coordinator: coordinator, + laneKey: laneKey + ) + self.operation.bind(onEvent: { [weak self] event, activeOperation in + self?.handle(event, activeOperation: activeOperation) + }, onRunningChanged: { [weak self] in + self?.objectWillChange.send() + }) + } + + var events: [BackendEvent] { operation.events } + var isRunning: Bool { operation.isRunning } + var isBusy: Bool { operation.isBusy } + var canCancel: Bool { operation.canCancel } + var pendingConfirmation: PendingConfirmation? { operation.pendingConfirmation } + + func canPlan(options: MaintenanceOptions?) -> Bool { + return !isBusy && options != nil + } + + func canRun(options: MaintenanceOptions?) -> Bool { + return !isBusy && plan != nil && state == .planReady && options == plannedOptions + } + + func markPlanStaleIfNeeded(options: MaintenanceOptions?) { + latestOptions = options + if state == .planReady, options != plannedOptions { + state = .planStale + } + } + + func confirmPending() { + operation.confirmPending() + } + + func cancelPendingConfirmation(options: MaintenanceOptions?) { + latestOptions = options + operation.cancelPendingConfirmation() + restoreStateAfterCancellation(options: options) + } + + func cancel() { + operation.cancel() + } + + func clear() { + operation.clear() + state = .idle + plan = nil + result = nil + currentStage = nil + error = nil + passwordInvalidProfileID = nil + plannedOptions = nil + latestOptions = nil + } + + @discardableResult + func planUninstall( + options: MaintenanceOptions?, + password: String, + profile: DeviceProfile? = nil + ) -> OperationStartResult { + latestOptions = options + guard let options else { + failLocally(.mountWaitInvalid) + return .rejected(WorkflowLocalError.mountWaitInvalid.message) + } + let start = startRun( + params: OperationParams.Uninstall.params( + dryRun: true, + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait) + ), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .planning + plan = nil + result = nil + plannedOptions = options + return start + } + + @discardableResult + func runUninstall( + options: MaintenanceOptions?, + password: String, + profile: DeviceProfile? = nil + ) -> OperationStartResult { + latestOptions = options + guard !isBusy else { + return rejectAlreadyRunning() + } + guard let currentOptions = options, + let plannedOptions, + currentOptions == plannedOptions, + plan != nil else { + markStale(.uninstallPlanStale) + return .rejected(WorkflowLocalError.uninstallPlanStale.message) + } + guard state == .planReady else { + return .rejected(WorkflowLocalError.uninstallPlanNotReady.message) + } + let start = startRun( + params: OperationParams.Uninstall.params( + dryRun: false, + noReboot: currentOptions.noReboot, + noWait: currentOptions.noWait, + mountWait: Double(currentOptions.mountWait) + ), + profile: profile, + password: password + ) + guard case .started = start else { + return start + } + state = .running + result = nil + return start + } + + @discardableResult + func rejectAlreadyRunning() -> OperationStartResult { + rejectRun(.operationAlreadyRunning) + return .rejected(WorkflowLocalError.operationAlreadyRunning.message) + } + + private func startRun( + params: [String: JSONValue], + profile: DeviceProfile?, + password: String? + ) -> OperationStartResult { + operation.start( + params: params, + profile: profile, + password: password, + rejectAlreadyRunning: { rejectRun(.operationAlreadyRunning) }, + resetRunState: resetRunState, + rejectRun: rejectRun(message:) + ) + } + + private func resetRunState() { + operation.resetForRun() + error = nil + currentStage = nil + passwordInvalidProfileID = nil + } + + private func handle(_ event: BackendEvent, activeOperation: ActiveOperation) { + guard event.operation == operation.name else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .running + } + return + } + + if event.type == "error" { + applyError(event, activeOperation: activeOperation) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFalseResult(event) + return + } + + if state == .planning { + do { + plan = try event.decodePayload(UninstallPlanPayload.self) + state = .planReady + operation.finishObserver() + } catch { + failContract(error) + } + return + } + + do { + result = try event.decodePayload(MaintenanceResultPayload.self) + state = .succeeded + error = nil + operation.finishObserver() + } catch { + failContract(error) + } + } + + private func applyError(_ event: BackendEvent, activeOperation: ActiveOperation) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + if event.code == "confirmation_cancelled" { + error = nil + currentStage = nil + operation.finishObserver() + restoreStateAfterCancellation(options: latestOptions) + return + } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation.profileID + } + error = BackendErrorViewModel(event: event) + state = .failed + operation.finishObserver() + } + + private func restoreStateAfterCancellation(options: MaintenanceOptions?) { + guard plan != nil else { + state = .idle + return + } + state = options == plannedOptions ? .planReady : .planStale + } + + private func markStale(_ localError: WorkflowLocalError) { + state = .planStale + error = operation.localError(localError) + } + + private func applyFalseResult(_ event: BackendEvent) { + error = operation.falseResultError(from: event) + state = .failed + operation.finishObserver() + } + + private func failContract(_ decodeError: Error) { + error = operation.contractDecodeError(decodeError) + state = .failed + operation.finishObserver() + } + + private func failLocally(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(_ localError: WorkflowLocalError) { + error = operation.localError(localError) + currentStage = nil + state = .failed + operation.finishObserver() + } + + private func rejectRun(message: String) { + error = operation.rejectedError(message: message) + currentStage = nil + state = .failed + operation.finishObserver() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift new file mode 100644 index 00000000..51ead328 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -0,0 +1,31 @@ +import AppKit +import Darwin +import SwiftUI +import TimeCapsuleSMBApp + +@main +struct TimeCapsuleSMBExecutable: App { + @NSApplicationDelegateAdaptor(AppCloseGuardApplicationDelegate.self) private var appCloseGuardDelegate + + init() { + if CommandLine.arguments.contains("--validate-resources") { + if let error = AppLaunchResourceValidation.validate() { + fputs("\(error)\n", stderr) + exit(70) + } + print("ok") + exit(0) + } + + 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 00000000..4b303a13 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift @@ -0,0 +1,550 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ActivityStoreTests: XCTestCase { + func testActivitySnapshotTracksActiveOperationTimelineAndDevice() async throws { + let runner = PausingStoreTestRunner(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.")]) + ) + ], pauseBeforeEvents: true) + ]) + 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")) + + runner.finishAll() + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.timeline.count == 2 } + XCTAssertEqual(activity.snapshot.timeline.map(\.title), ["Upload Payload", "Done"]) + XCTAssertEqual(activity.snapshot.latestMessage, "Deployment completed.") + } + + func testAppLanguageChangeRefreshesCachedActivityPresentation() async throws { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + L10n.apply(language: .simplifiedChinese) + + let temp = try TemporaryDirectory() + let runner = PausingStoreTestRunner(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.")]) + ) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + let settingsStore = AppSettingsStore(settingsURL: temp.url.appendingPathComponent("settings.json")) + let appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.appLane.backend), + appSettingsStore: settingsStore, + deviceRegistry: DeviceRegistryStore(applicationSupportURL: temp.url), + operationCoordinator: coordinator, + passwordStore: InMemoryPasswordStore(), + activityStore: activity + ) + + _ = coordinator.run( + operation: "deploy", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.timeline.count == 2 } + XCTAssertEqual(activity.snapshot.operationTitle, "安装 / ꛓꖰ") + XCTAssertEqual(activity.snapshot.timeline.map(\.title), ["上传 Payload", "完成"]) + + var settings = AppSettings.default + settings.language = .english + try await appStore.saveAppSettings(settings) + + XCTAssertEqual(activity.snapshot.operationTitle, "Install / Update") + XCTAssertEqual(activity.snapshot.timeline.map(\.title), ["Upload Payload", "Done"]) + } + + func testActivitySnapshotTracksBackendOnlyReadinessOperationAsAppScoped() async throws { + let runner = PausingStoreTestRunner(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)]) + ) + ], pauseBeforeEvents: true) + ]) + 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) + + runner.finishAll() + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.timeline.count == 2 } + XCTAssertEqual(activity.snapshot.scope, .app) + XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") + } + + func testActivitySnapshotTracksDiscoveryAsAppScoped() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "discover", + stage: "bonjour_discovery", + description: "Browse for AirPort Bonjour services." + ), + BackendEvent( + type: "result", + operation: "discover", + ok: true, + payload: testDiscoverPayload(records: []) + ) + ], pauseBeforeEvents: true) + ]) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let activity = ActivityStore(coordinator: coordinator) + + backend.run(operation: "discover") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "Discovery") + XCTAssertEqual(activity.snapshot.scope, .app) + runner.finishAll() + try await waitUntilStoreState { !activity.snapshot.isRunning } + } + + func testActivityStoreTracksMultipleActiveLanesAndPrefersDeviceSnapshot() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent( + type: "result", + operation: "discover", + ok: true, + payload: testDiscoverPayload(records: []) + ) + ], pauseBeforeEvents: true) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + description: "Run local and remote diagnostic checks." + ), + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ok", domain: "Runtime") + ])) + ], pauseBeforeEvents: true) + ] + ]) + 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")) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run(operation: "doctor", context: context, activeDeviceID: "device-one", laneKey: .device("device-one")) + + try await waitUntilStoreState { + activity.laneSnapshots.count == 2 && activity.laneSnapshots.allSatisfy { $0.snapshot.isRunning } + } + XCTAssertEqual(activity.snapshot.scope, .device("device-one")) + XCTAssertEqual(activity.snapshot.operationTitle, "Checkup") + XCTAssertEqual(Set(activity.laneSnapshots.map(\.laneKey)), [.app, .device("device-one")]) + runner.finishAll() + } + + func testCompactStatusPrefersSelectedDeviceOverRunningStartupDiscovery() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseBeforeEvents: true) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + description: "Run local and remote diagnostic checks." + ), + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: "device-one", + showingAddDevice: false, + showingActivity: false + )) + XCTAssertEqual(status.scope, .device("device-one")) + XCTAssertEqual(status.operationTitle, "Checkup") + XCTAssertEqual(status.activeLaneCount, 2) + runner.finishAll() + } + + func testCompactStatusShowsMultipleActiveOperationsWhenNoSelectedLaneCanOwnTheBar() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ], + .init("deploy", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: .object(["summary": .string("done")])) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + coordinator.run( + operation: "deploy", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: .none) + XCTAssertEqual(status.scope, .unknown) + XCTAssertEqual(status.operationTitle, "2 active operations") + XCTAssertEqual(status.latestMessage, "Open Activity for details.") + XCTAssertEqual(status.activeLaneCount, 2) + runner.finishAll() + } + + func testCompactStatusShowsMultipleActiveOperationsOnActivityScreenEvenWhenAppLaneRuns() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseBeforeEvents: true) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: nil, + showingAddDevice: false, + showingActivity: true + )) + XCTAssertEqual(status.scope, .unknown) + XCTAssertEqual(status.operationTitle, "2 active operations") + runner.finishAll() + } + + func testCompactStatusShowsSelectedPendingConfirmationBeforeRunningDiscovery() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseBeforeEvents: true) + ], + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "confirm-deploy") + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "deploy", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.contains { $0.laneKey == .app && $0.snapshot.isRunning } + && activity.activeLaneSnapshots.contains { $0.laneKey == .device("device-one") && $0.isPendingConfirmation } + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: "device-one", + showingAddDevice: false, + showingActivity: false + )) + XCTAssertEqual(status.scope, .device("device-one")) + XCTAssertEqual(status.operationTitle, "Install / Update") + XCTAssertTrue(status.requiresAttention) + XCTAssertFalse(status.isRunning) + runner.finishAll() + } + + func testCompactStatusUsesAppLaneForAddDeviceDiscoveryUnlessConfigureIsActive() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseBeforeEvents: true) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ], + .init("configure", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: .object(["summary": .string("configured")])) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + let addDeviceContext = ActivityDisplayContext( + selectedDeviceID: nil, + showingAddDevice: true, + showingActivity: false + ) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.count == 2 + } + XCTAssertEqual(activity.compactStatus(for: addDeviceContext).scope, .app) + XCTAssertEqual(activity.compactStatus(for: addDeviceContext).operationTitle, "Discovery") + + coordinator.run( + operation: "configure", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.contains { $0.laneKey == .device("device-two") } + } + let status = activity.compactStatus(for: addDeviceContext) + XCTAssertEqual(status.scope, .device("device-two")) + XCTAssertEqual(status.operationTitle, "Add Device") + runner.finishAll() + } + + func testCompactStatusKeepsSelectedDeviceHistoryAfterStartupDiscoveryCompletes() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.isEmpty && activity.laneSnapshots.count == 2 + } + + let status = activity.compactStatus(for: ActivityDisplayContext( + selectedDeviceID: "device-one", + showingAddDevice: false, + showingActivity: false + )) + XCTAssertEqual(status.scope, .device("device-one")) + XCTAssertEqual(status.operationTitle, "Checkup") + XCTAssertEqual(status.latestMessage, "Doctor checks passed.") + XCTAssertEqual(status.latestTimelineTitle, "Done") + } + + func testActivityStoreSeparatesActiveAndRecentLaneSnapshots() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + + coordinator.run(operation: "discover", laneKey: .app) + coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + + try await waitUntilStoreState { + activity.activeLaneSnapshots.map(\.laneKey) == [.device("device-one")] + && activity.recentLaneSnapshots.map(\.laneKey) == [.app] + } + runner.finishAll() + } + + func testSuccessfulAppValidationPresentsAppReadyWithoutDetailMessage() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "validate-install", + stage: "validate_install", + description: "Validate local helper and artifact prerequisites." + ), + BackendEvent( + type: "result", + operation: "validate-install", + ok: true, + payload: .object(["summary": .string("Install validation passed.")]) + ) + ], pauseBeforeEvents: true) + ]) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let activity = ActivityStore(coordinator: coordinator) + + backend.run(operation: "validate-install") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") + XCTAssertEqual(activity.snapshot.scope, .app) + + runner.finishAll() + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.operationTitle == "App Ready" } + XCTAssertEqual(activity.snapshot.scope, .app) + XCTAssertNil(activity.snapshot.latestMessage) + } + + private func context(_ profileID: String) -> DeviceRuntimeContext { + DeviceRuntimeContext( + profileID: profileID, + configURL: URL(fileURLWithPath: "/tmp/\(profileID)/.env") + ) + } + + private func doctorPayload() -> JSONValue { + testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ]) + } + + private func confirmationRequiredEvent(operation: String, id: String) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: "confirmation_required", + message: "Confirm operation.", + details: .object([ + "title": .string("Confirm operation"), + "message": .string("Continue."), + "action_title": .string("Continue"), + "confirmation_id": .string(id) + ]) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift new file mode 100644 index 00000000..e0dd6e70 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -0,0 +1,876 @@ +import Combine +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AddDeviceFlowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(AddDeviceFlowState.allCases, [ + .idle, + .discovering, + .discoveryEmpty, + .discoveryReady, + .manualEntry, + .passwordEntry, + .configuring, + .awaitingConfirmation, + .savingProfile, + .saved, + .authFailed, + .unsupported, + .failed + ]) + } + + func testEntryModeInventoryIsExplicit() { + XCTAssertEqual(AddDeviceEntryMode.allCases, [.discover, .manual]) + } + + func testInvalidDiscoverTimeoutFailsWithoutRunningHelper() async throws { + let fixture = try await makeStore(responses: []) + fixture.store.bonjourTimeout = "bad" + + fixture.store.runDiscover() + + XCTAssertEqual(fixture.store.state, .failed) + XCTAssertEqual(fixture.store.error?.code, "validation_failed") + XCTAssertEqual(fixture.runner.calls, []) + } + + func testDiscoverEmptyReadyAndFailureStates() async throws { + let empty = try await 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 await 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 await 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 await 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 testDiscoverPreservesDualStackAddressesAndUsesRegularIPv4SetupTarget() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["169.254.44.9", "10.0.0.2"], + ipv6: ["fd00::2"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]) + ]) + + fixture.store.runDiscover() + + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let device = try XCTUnwrap(fixture.store.devices.first) + XCTAssertEqual(device.addresses, ["169.254.44.9", "10.0.0.2", "fd00::2"]) + XCTAssertEqual(device.connectionTarget, "10.0.0.2") + XCTAssertEqual(device.addressSummary, "IPv4 10.0.0.2 IPv6 fd00::2") + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.2") + } + + func testIPv6OnlyDiscoveryConfiguresAndSavesNetworkIdentity() async throws { + let record = testDeviceRecord( + name: "IPv6 Capsule", + hostname: "ipv6-capsule.local.", + ipv4: [], + ipv6: ["fd00::2"], + fullname: "IPv6 Capsule._airport._tcp.local." + ) + let fixture = try await 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: "root@fd00::2")) + ]) + ]) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let device = try XCTUnwrap(fixture.store.devices.first) + XCTAssertEqual(device.connectionTarget, "fd00::2") + XCTAssertEqual(fixture.store.hostFieldText, "fd00::2") + + 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.connectionTarget, "fd00::2") + XCTAssertEqual(profile.addresses, ["fd00::2"]) + XCTAssertEqual(profile.addressSummary, "IPv6 fd00::2") + XCTAssertNotNil(fixture.runner.calls[1].params["selected_record"]) + XCTAssertNil(fixture.runner.calls[1].params["host"]) + } + + func testMalformedDiscoverPayloadFailsContract() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + + fixture.store.runDiscover() + + try await waitUntilStoreState { fixture.store.state == .failed } + XCTAssertEqual(fixture.store.error?.code, "contract_decode_failed") + XCTAssertEqual(fixture.store.devices, []) + } + + func testDiscoveredDeviceModelTextUsesFullModelIdentifier() throws { + let payload = try testDiscoveredDevice( + syap: "116", + model: "TimeCapsule6,116" + ).decode(DiscoveredDevicePayload.self) + + let device = DiscoveredDevice(payload: payload, index: 0) + + XCTAssertEqual(device.model, "TimeCapsule6,116") + XCTAssertEqual(device.discoveryModelText, "TimeCapsule6,116") + } + + func testDiscoveredDeviceModelTextCanUseSelectedRecordModel() throws { + let selectedRecord = testDeviceRecord( + name: "Office Capsule", + hostname: "office-capsule.local.", + ipv4: ["10.0.0.2"], + syap: "116", + model: "TimeCapsule6,116" + ) + let payload = try testDiscoveredDevice( + syap: "116", + model: nil, + selectedRecord: selectedRecord + ).decode(DiscoveredDevicePayload.self) + + let device = DiscoveredDevice(payload: payload, index: 0) + + XCTAssertEqual(device.model, "TimeCapsule6,116") + XCTAssertEqual(device.discoveryModelText, "TimeCapsule6,116") + } + + func testDiscoveredDeviceModelTextDoesNotFallbackToSyAP() throws { + let selectedRecord = testDeviceRecord( + name: "Office Capsule", + hostname: "office-capsule.local.", + ipv4: ["10.0.0.2"], + syap: "116", + model: "" + ) + let payload = try testDiscoveredDevice( + syap: "116", + model: nil, + selectedRecord: selectedRecord + ).decode(DiscoveredDevicePayload.self) + + let device = DiscoveredDevice(payload: payload, index: 0) + + XCTAssertEqual(device.syap, "116") + XCTAssertNil(device.model) + XCTAssertEqual(device.discoveryModelText, "") + } + + 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 await 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() async throws { + let fixture = try await 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 await 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) + guard case .string(let stagedConfigPath)? = fixture.runner.calls[0].params["config"] else { + return XCTFail("Expected staged config path.") + } + XCTAssertNotEqual(stagedConfigPath, profile.configPath) + XCTAssertTrue(stagedConfigPath.contains("/.Staging/")) + XCTAssertTrue(FileManager.default.fileExists(atPath: 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")) + XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(false)) + } + + func testPublishesWhenSetupBackendFinishesAfterConfigureResult() async throws { + let fixture = try await 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" + let finishPublished = expectation(description: "AddDeviceFlowStore publishes after setup backend running state clears") + var didFulfill = false + var cancellables: Set = [] + fixture.store.objectWillChange + .sink { [weak store = fixture.store] _ in + Task { @MainActor in + guard !didFulfill, + store?.state == .saved, + store?.isRunning == false else { + return + } + didFulfill = true + finishPublished.fulfill() + } + } + .store(in: &cancellables) + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + await fulfillment(of: [finishPublished], timeout: 2) + XCTAssertFalse(fixture.store.isRunning) + _ = cancellables + } + + func testConfigureSSHEnableConfirmationCanBeConfirmedAndSavesProfile() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "configure", + code: "confirmation_required", + message: "SSH is closed.", + details: .object([ + "confirmation_id": .string("confirm-ssh"), + "presentation_id": .string("configure.enable_ssh_reboot"), + "presentation_values": .object(["device_name": .string("Office Capsule")]) + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .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 == .awaitingConfirmation && + fixture.store.coordinator.pendingConfirmation != nil && + !fixture.store.coordinator.lane(for: .candidateHost("10.0.0.2")).backend.isRunning + } + XCTAssertFalse(fixture.store.canConfigure) + XCTAssertEqual(fixture.registry.profiles, []) + + fixture.store.coordinator.confirmPending() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.runner.calls.count, 2) + XCTAssertEqual(fixture.runner.calls[1].params["confirmation_id"], .string("confirm-ssh")) + XCTAssertEqual(fixture.store.savedProfile?.host, "root@10.0.0.2") + XCTAssertEqual(fixture.registry.profiles.count, 1) + } + + func testConfigureSSHEnableConfirmationCancellationReturnsToPasswordEntry() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "configure", + code: "confirmation_required", + message: "SSH is closed.", + details: .object([ + "confirmation_id": .string("confirm-ssh"), + "presentation_id": .string("configure.enable_ssh_reboot"), + "presentation_values": .object(["device_name": .string("Office Capsule")]) + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + try await waitUntilStoreState { + fixture.store.state == .awaitingConfirmation && + fixture.store.coordinator.pendingConfirmation != nil && + !fixture.store.coordinator.lane(for: .candidateHost("10.0.0.2")).backend.isRunning + } + + fixture.store.coordinator.cancelPendingConfirmation() + + try await waitUntilStoreState { fixture.store.state == .passwordEntry } + XCTAssertNil(fixture.store.error) + XCTAssertNil(fixture.store.savedProfile) + XCTAssertEqual(fixture.store.manualHost, "10.0.0.2") + XCTAssertEqual(fixture.store.password, "secret") + XCTAssertTrue(fixture.store.canConfigure) + XCTAssertEqual(fixture.registry.profiles, []) + } + + func testNewManualProfileUsesAppDefaultDeviceSettings() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@10.0.0.2")) + ]) + ]) + let defaultSettings = DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45, + ataIdleSeconds: 600, + ataStandby: 900 + ) + var appSettings = AppSettings.default + appSettings.defaultDeviceSettings = defaultSettings + fixture.store.applyAppSettings(appSettings) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.store.savedProfile?.settings, defaultSettings) + XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[0].params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[0].params["any_protocol"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_idle_seconds"], .number(600)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_standby"], .number(900)) + } + + func testExistingProfileSettingsAreNotClobberedByAppDefaults() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.2")) + ]) + ]) + let existing = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + var editedExisting = existing + editedExisting.settings = DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: false, + anyProtocol: false, + debugLogging: false, + mountWaitSeconds: 99, + ataIdleSeconds: 111, + ataStandby: nil + ) + _ = try await fixture.registry.updateProfile(editedExisting) + var appSettings = AppSettings.default + appSettings.defaultDeviceSettings = DeviceProfileSettings( + nbnsEnabled: true, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 1, + ataIdleSeconds: 2, + ataStandby: 3 + ) + fixture.store.applyAppSettings(appSettings) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.store.savedProfile?.settings, editedExisting.settings) + XCTAssertEqual(fixture.runner.calls[0].params["debug_logging"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["any_protocol"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_idle_seconds"], .number(111)) + XCTAssertEqual(fixture.runner.calls[0].params["ata_standby"], .string("")) + } + + func testConfigureRejectedWhileAnotherOperationRunsSavesNothing() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], pauseAfterEvents: true) + ]) + let existing = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + _ = fixture.store.coordinator.run( + operation: "doctor", + context: existing.runtimeContext, + activeDeviceID: existing.id, + laneKey: .device(existing.id) + ) + try await waitUntilStoreState { fixture.runner.calls.count == 1 } + XCTAssertTrue(fixture.store.coordinator.lane(for: existing).backend.isRunning) + fixture.store.runConfigure() + + XCTAssertEqual(fixture.store.state, .failed) + XCTAssertEqual(fixture.store.error?.code, "operation_rejected") + XCTAssertEqual(fixture.registry.profiles, [existing]) + XCTAssertEqual(fixture.runner.calls.count, 1) + fixture.runner.finishAll() + try await waitUntilStoreState { !fixture.store.coordinator.lane(for: existing).backend.isRunning } + } + + func testManualConfigureCanRunWhileAppDiscoveryLaneIsBusy() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseAfterEvents: true), + .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.coordinator.run(operation: "discover", laneKey: .app) + try await waitUntilStoreState { fixture.store.coordinator.appLane.backend.isRunning } + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.runner.calls.count == 2 } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["discover", "configure"]) + XCTAssertTrue(fixture.store.coordinator.appLane.backend.isRunning) + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.registry.profiles.count, 1) + fixture.runner.finishAll() + } + + 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 await 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 testConfigureAuthFailurePreservesDiscoverySelection() 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 await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "bad password") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let selectedID = fixture.store.selectedDeviceID + fixture.store.password = "bad" + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .authFailed } + XCTAssertEqual(fixture.store.selectedDeviceID, selectedID) + XCTAssertEqual(fixture.store.devices.count, 1) + XCTAssertEqual(fixture.registry.profiles, []) + } + + func testMalformedConfigurePayloadFailsContractAndSavesNothing() async throws { + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + 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, "contract_decode_failed") + XCTAssertEqual(fixture.registry.profiles, []) + XCTAssertNil(fixture.store.savedProfile) + } + + func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { + let auth = try await 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 await 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 await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload( + host: "10.0.0.2", + model: "Updated Capsule" + )) + ]) + ]) + let existing = try await 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 await 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 await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]) + ]) + let existing = try await 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) + } + + func testSelectingSharedDiscoveryDeviceFromOverviewPromptsForPasswordWithoutRediscovering() async throws { + let discovered = testDiscoveredDevice( + name: "Office Capsule", + host: "10.0.0.2", + model: "TimeCapsule6,116" + ) + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [], devices: [discovered])) + ]) + ]) + fixture.store.discovery.refresh(timeout: 0.1) + try await waitUntilStoreState { fixture.store.discovery.state == .ready } + let device = try XCTUnwrap(fixture.store.discovery.devices.first) + + fixture.store.select(device) + + XCTAssertEqual(fixture.store.state, .passwordEntry) + XCTAssertEqual(fixture.store.devices, [device]) + XCTAssertEqual(fixture.store.selectedDeviceID, device.id) + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.2") + XCTAssertEqual(fixture.runner.calls.count, 1) + } + + func testSharedDiscoverySelectionKeepsListOrderAndPreselectsClickedDevice() async throws { + let firstPayload = testDiscoveredDevice( + id: "bonjour:first", + name: "First Capsule", + host: "10.0.0.2", + hostname: "first.local.", + fullname: "First Capsule._airport._tcp.local." + ) + let secondPayload = testDiscoveredDevice( + id: "bonjour:second", + name: "Second Capsule", + host: "10.0.0.3", + hostname: "second.local.", + fullname: "Second Capsule._airport._tcp.local." + ) + let fixture = try await makeStore(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "discover", + ok: true, + payload: testDiscoverPayload(records: [], devices: [firstPayload, secondPayload]) + ) + ]) + ]) + fixture.store.discovery.refresh(timeout: 0.1) + try await waitUntilStoreState { fixture.store.discovery.state == .ready } + let first = fixture.store.discovery.devices[0] + let second = fixture.store.discovery.devices[1] + + fixture.store.select(second) + + XCTAssertEqual(fixture.store.state, .passwordEntry) + XCTAssertEqual(fixture.store.devices, [first, second]) + XCTAssertEqual(fixture.store.selectedDeviceID, second.id) + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.3") + XCTAssertEqual(fixture.runner.calls.count, 1) + } + + private func makeStore(responses: [StoreTestRunner.Response]) async throws -> ( + store: AddDeviceFlowStore, + runner: PausingStoreTestRunner, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let runner = PausingStoreTestRunner(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/AddDevicePresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDevicePresentationTests.swift new file mode 100644 index 00000000..7bde6596 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDevicePresentationTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class AddDevicePresentationTests: XCTestCase { + func testProgressPresentationAppearsOnlyForBlockingStates() { + let discoveryStage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "discover", + stage: "browse_bonjour", + cancellable: true, + description: "Browsing Bonjour services." + )) + let configureStage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "configure", + stage: "ssh_probe", + cancellable: true, + description: "Checking SSH access." + )) + + let discovering = AddDeviceProgressPresentation(state: .discovering, currentStage: discoveryStage) + XCTAssertEqual(discovering?.title, "Discovering Apple AirPort devices") + XCTAssertEqual(discovering?.message, "Browsing for nearby AirPort Bonjour services...") + XCTAssertNil(discovering?.detail) + + let configuring = AddDeviceProgressPresentation(state: .configuring, currentStage: configureStage) + XCTAssertEqual(configuring?.title, "Connecting to Apple AirPort device") + XCTAssertEqual(configuring?.detail, "Checking SSH") + + let saving = AddDeviceProgressPresentation(state: .savingProfile, currentStage: nil) + XCTAssertEqual(saving?.title, "Saving Device") + XCTAssertNil(saving?.detail) + + for state in AddDeviceFlowState.allCases where ![.discovering, .configuring, .savingProfile].contains(state) { + XCTAssertNil(AddDeviceProgressPresentation(state: state, currentStage: discoveryStage), "\(state) should not show a blocking progress modal.") + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceViewSmokeTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceViewSmokeTests.swift new file mode 100644 index 00000000..4be891b1 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceViewSmokeTests.swift @@ -0,0 +1,150 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AddDeviceViewSmokeTests: XCTestCase { + func testRendersIdleManualAndLocalValidationStates() async throws { + let fixture = try await makeFixture() + + try assertRendersNonBlank(AddDeviceView(store: fixture.store), size: CGSize(width: 900, height: 700)) + + fixture.store.startManualEntry() + try assertRendersNonBlank(AddDeviceView(store: fixture.store), size: CGSize(width: 900, height: 700)) + + fixture.store.bonjourTimeout = "-1" + fixture.store.runDiscover() + XCTAssertEqual(fixture.store.state, .failed) + try assertRendersNonBlank(AddDeviceView(store: fixture.store), size: CGSize(width: 900, height: 700)) + } + + func testRendersDiscoveringEmptyReadyAndPasswordEntryStates() async throws { + let discovering = try await makeFixture(responses: [ + .init( + events: [ + BackendEvent(type: "stage", operation: "discover", stage: "browse_bonjour") + ], + pauseAfterEvents: true + ) + ]) + discovering.store.runDiscover() + XCTAssertEqual(discovering.store.state, .discovering) + try assertRendersNonBlank(AddDeviceView(store: discovering.store), size: CGSize(width: 900, height: 700)) + discovering.runner.finishAll() + + let empty = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ]) + empty.store.runDiscover() + try await waitUntilStoreState { empty.store.state == .discoveryEmpty } + try assertRendersNonBlank(AddDeviceView(store: empty.store), size: CGSize(width: 900, height: 700)) + + let ready = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [], devices: [ + testDiscoveredDevice(name: "Office Capsule", host: "10.0.0.2") + ])) + ]) + ]) + ready.store.runDiscover() + try await waitUntilStoreState { ready.store.state == .discoveryReady && ready.store.selectedDevice != nil } + try assertRendersNonBlank(AddDeviceView(store: ready.store), size: CGSize(width: 900, height: 700)) + + let selected = try XCTUnwrap(ready.store.selectedDevice) + ready.store.select(selected) + XCTAssertEqual(ready.store.state, .passwordEntry) + try assertRendersNonBlank(AddDeviceView(store: ready.store), size: CGSize(width: 900, height: 700)) + } + + func testRendersConfigureTerminalStates() async throws { + try await renderConfigureState( + responses: [ + .init( + events: [BackendEvent(type: "stage", operation: "configure", stage: "connect_ssh")], + pauseAfterEvents: true + ) + ], + expectedState: .configuring + ) + try await renderConfigureState( + responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "configure", + code: "confirmation_required", + message: "Enable SSH?" + ) + ]) + ], + expectedState: .awaitingConfirmation + ) + try await renderConfigureState( + responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "Password rejected.") + ]) + ], + expectedState: .authFailed + ) + try await renderConfigureState( + responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "unsupported_device", message: "Unsupported device.") + ]) + ], + expectedState: .unsupported + ) + try await renderConfigureState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload()) + ]) + ], + expectedState: .saved + ) + } + + private func renderConfigureState( + responses: [StoreTestRunner.Response], + expectedState: AddDeviceFlowState + ) async throws { + let fixture = try await makeFixture(responses: responses) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "pw" + + fixture.store.runConfigure() + if expectedState != .configuring { + try await waitUntilStoreState { fixture.store.state == expectedState } + } + + XCTAssertEqual(fixture.store.state, expectedState) + try assertRendersNonBlank(AddDeviceView(store: fixture.store), size: CGSize(width: 900, height: 700)) + fixture.runner.finishAll() + } + + private func makeFixture(responses: [StoreTestRunner.Response] = []) async throws -> ( + store: AddDeviceFlowStore, + registry: DeviceRegistryStore, + runner: PausingStoreTestRunner + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let runner = PausingStoreTestRunner(responses: responses) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let passwordStore = InMemoryPasswordStore() + let persistence = DeviceProfilePersistenceService(registry: registry, passwordStore: passwordStore) + let discovery = DeviceDiscoveryStore(coordinator: coordinator, registry: registry) + let store = AddDeviceFlowStore( + coordinator: coordinator, + registry: registry, + passwordStore: passwordStore, + profilePersistence: persistence, + discovery: discovery + ) + return (store, registry, runner) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift new file mode 100644 index 00000000..f5a533fd --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppCloseGuardTests.swift @@ -0,0 +1,151 @@ +import AppKit +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppCloseGuardTests: XCTestCase { + func testCloseGuardAllowsWindowCloseWithoutPromptWhenNoOperationIsActive() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { false } + guardController.presenter = presenter + let window = NSWindow() + + XCTAssertTrue(guardController.shouldCloseWindow(window)) + XCTAssertTrue(presenter.requests.isEmpty) + } + + func testCloseGuardRequiresSharedConfirmationWhenOperationIsActive() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { true } + guardController.presenter = presenter + let window = NSWindow() + + XCTAssertFalse(guardController.shouldCloseWindow(window)) + XCTAssertEqual(presenter.requests, [.windowClose]) + XCTAssertEqual(presenter.prompts, [.activeOperation]) + XCTAssertEqual(presenter.windows, [window]) + + let delegate = AppCloseGuardApplicationDelegate() + delegate.closeGuard = guardController + + XCTAssertEqual(delegate.applicationShouldTerminate(.shared), .terminateLater) + XCTAssertEqual(presenter.requests, [.windowClose, .appQuit]) + XCTAssertEqual(presenter.prompts, [.activeOperation, .activeOperation]) + } + + func testConfirmedWindowCloseClosesWindowDirectly() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { true } + guardController.presenter = presenter + let window = RecordingWindow() + + XCTAssertFalse(guardController.shouldCloseWindow(window)) + + presenter.completions.first?(true) + + XCTAssertEqual(window.closeCount, 1) + } + + func testApplicationDelegateRoutesCommandQuitThroughCloseGuard() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { true } + guardController.presenter = presenter + let delegate = AppCloseGuardApplicationDelegate() + delegate.closeGuard = guardController + + XCTAssertEqual(delegate.applicationShouldTerminate(.shared), .terminateLater) + XCTAssertEqual(presenter.requests, [.appQuit]) + XCTAssertEqual(presenter.prompts, [.activeOperation]) + } + + func testApplicationDelegateAllowsCommandQuitWithoutPromptWhenNoOperationIsActive() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { false } + guardController.presenter = presenter + let delegate = AppCloseGuardApplicationDelegate() + delegate.closeGuard = guardController + + XCTAssertEqual(delegate.applicationShouldTerminate(.shared), .terminateNow) + XCTAssertTrue(presenter.requests.isEmpty) + } + + func testAttachedWindowDelegateForwardsUninterceptedCallbacks() { + let guardController = AppCloseGuard() + let downstream = RecordingWindowDelegate() + let window = NSWindow() + window.delegate = downstream + + guardController.attach(to: window) + let notification = Notification(name: NSWindow.didResizeNotification, object: window) + XCTAssertTrue(window.delegate?.responds(to: #selector(NSWindowDelegate.windowDidResize(_:))) ?? false) + + window.delegate?.windowDidResize?(notification) + + XCTAssertEqual(downstream.resizeCount, 1) + } + + func testAttachedWindowDelegateUsesConfiguredCloseGuardAndRewrapsReplacementDelegate() { + let guardController = AppCloseGuard() + let presenter = RecordingCloseGuardPresenter() + guardController.configure { true } + guardController.presenter = presenter + let downstream = RecordingWindowDelegate() + let window = NSWindow() + + guardController.attach(to: window) + window.delegate = downstream + guardController.attach(to: window) + + XCTAssertFalse(window.delegate === downstream) + XCTAssertFalse(window.delegate?.windowShouldClose?(window) ?? true) + XCTAssertEqual(presenter.requests, [.windowClose]) + XCTAssertEqual(downstream.shouldCloseCount, 1) + } +} + +private final class RecordingWindow: NSWindow { + private(set) var closeCount = 0 + + override func close() { + closeCount += 1 + } +} + +private final class RecordingWindowDelegate: NSObject, NSWindowDelegate { + private(set) var resizeCount = 0 + private(set) var shouldCloseCount = 0 + + func windowDidResize(_ notification: Notification) { + resizeCount += 1 + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + shouldCloseCount += 1 + return true + } +} + +@MainActor +private final class RecordingCloseGuardPresenter: AppCloseGuardPresenting { + private(set) var prompts: [AppCloseGuardPrompt] = [] + private(set) var requests: [AppCloseGuardRequest] = [] + private(set) var windows: [NSWindow?] = [] + private(set) var completions: [@MainActor (Bool) -> Void] = [] + + func confirmClose( + _ prompt: AppCloseGuardPrompt, + for request: AppCloseGuardRequest, + modalFor window: NSWindow?, + completion: @escaping @MainActor (Bool) -> Void + ) { + prompts.append(prompt) + requests.append(request) + windows.append(window) + completions.append(completion) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift new file mode 100644 index 00000000..5f92dadc --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift @@ -0,0 +1,455 @@ +import Combine +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppReadinessStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual( + AppReadinessStateKind.allCases, + [.idle, .resolvingBundle, .checkingVersion, .checkingCapabilities, .validatingInstall, .ready, .degraded, .blocked] + ) + } + + func testStateTitlesAreLocalized() { + XCTAssertEqual(AppReadinessStateKind.allCases.map(\.title), [ + "Idle", + "Preparing app runtime", + "Checking version", + "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 testReadinessVersionCheckRunsBeforeCapabilitiesWhenConfigured() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: false)) + ]), + .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, + versionCheck: AppReadinessVersionCheck(url: "https://example.invalid/version.json") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .ready } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check", "capabilities", "validate-install"]) + XCTAssertEqual(runner.calls.first?.params["url"], .string("https://example.invalid/version.json")) + XCTAssertNil(runner.calls.first?.params["local_version_code"]) + } + + func testBlockingVersionCheckStopsReadinessBeforeCapabilities() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: true)) + ]) + ]) + let store = makeStore( + runner: runner, + versionCheck: AppReadinessVersionCheck(url: "https://example.invalid/version.json") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check"]) + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .unsupportedVersion) + XCTAssertEqual(issue.message, "Please update.") + XCTAssertEqual(issue.recovery, "Download the latest version from https://example.invalid/download.") + } + + func testPublishesWhenBackendFinishesAfterBlockingVersionCheck() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: true)) + ]) + ]) + let store = makeStore( + runner: runner, + versionCheck: AppReadinessVersionCheck(url: "https://example.invalid/version.json") + ) + let finishPublished = expectation(description: "AppReadinessStore publishes after backend running state clears") + var didFulfill = false + var cancellables: Set = [] + store.objectWillChange + .sink { [weak store] _ in + Task { @MainActor in + guard !didFulfill, + store?.state.kind == .blocked, + store?.canRetry == true else { + return + } + didFulfill = true + finishPublished.fulfill() + } + } + .store(in: &cancellables) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + await fulfillment(of: [finishPublished], timeout: 2) + XCTAssertTrue(store.canRetry) + _ = cancellables + } + + func testUnavailableVersionMetadataDegradesButFailsOpenToReadinessChecks() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: false, source: "unavailable")) + ]), + .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, + versionCheck: AppReadinessVersionCheck(url: "") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .degraded } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check", "capabilities", "validate-install"]) + XCTAssertEqual(runner.calls.first?.params, [:]) + XCTAssertEqual(store.versionCheckPayload?.source, "unavailable") + XCTAssertTrue(store.issues.contains(where: { $0.code == .versionMetadataUnavailable && $0.severity == .warning })) + } + + func testVersionCheckErrorDegradesButContinuesReadiness() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "version-check", code: "network_failed", message: "offline") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .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, + versionCheck: AppReadinessVersionCheck(url: "https://example.invalid/version.json") + ) + + store.start() + + try await waitUntilStoreState { store.state.kind == .degraded } + XCTAssertEqual(runner.calls.map(\.operation), ["version-check", "capabilities", "validate-install"]) + XCTAssertTrue(store.issues.contains(where: { $0.code == .versionMetadataUnavailable && $0.message == "offline" })) + } + + 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: "doctor", 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) + XCTAssertNil(store.versionCheckPayload) + XCTAssertEqual(store.issues, []) + XCTAssertNil(store.currentStage) + } + + private func makeStore( + runner: StoreTestRunner, + issues: [BundleRuntimeIssue] = [], + resolveError: Error? = nil, + versionCheck: AppReadinessVersionCheck? = nil + ) -> AppReadinessStore { + let backend = BackendClient(runner: runner) + let resolver = TestRuntimeResolver(issues: issues, resolveError: resolveError) + let store = AppReadinessStore( + backend: backend, + runtimeResolver: resolver, + helperPathProvider: { "" } + ) + store.applyVersionCheck(versionCheck) + return store + } + + 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 func versionCheckPayload(shouldBlock: Bool, source: String = "network") -> JSONValue { + .object([ + "schema_version": .number(1), + "should_block": .bool(shouldBlock), + "update_available": .bool(shouldBlock), + "checked_url": .string("https://example.invalid/version.json"), + "message": .string("Please update."), + "download_url": .string("https://example.invalid/download"), + "local_version_code": .number(20000), + "current_version": .number(20125), + "min_supported_version": .number(20125), + "latest_tag": .string("v2.1.4"), + "source": .string(source), + "summary": .string(source == "unavailable" ? "Version metadata is unavailable." : (shouldBlock ? "Update required." : "TimeCapsuleSMB is up to date.")) + ]) + } +} + +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/AppRouteTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppRouteTests.swift new file mode 100644 index 00000000..308370ea --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppRouteTests.swift @@ -0,0 +1,206 @@ +import Combine +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppRouteTests: XCTestCase { + func testNavigationHelpersSetSingleRoute() async throws { + let fixture = try await makeFixture() + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + + fixture.appStore.select(profile) + XCTAssertEqual(fixture.appStore.route, .device(profile.id)) + XCTAssertEqual(fixture.appStore.selectedDeviceID, profile.id) + XCTAssertFalse(fixture.appStore.showingAddDevice) + XCTAssertFalse(fixture.appStore.showingActivity) + XCTAssertFalse(fixture.appStore.showingAppSettings) + + fixture.appStore.showAddDevice() + XCTAssertEqual(fixture.appStore.route, .addDevice) + XCTAssertNil(fixture.appStore.selectedDeviceID) + XCTAssertTrue(fixture.appStore.showingAddDevice) + + fixture.appStore.showActivity() + XCTAssertEqual(fixture.appStore.route, .activity) + XCTAssertTrue(fixture.appStore.showingActivity) + XCTAssertFalse(fixture.appStore.showingAddDevice) + + fixture.appStore.showAppSettings() + XCTAssertEqual(fixture.appStore.route, .appSettings) + XCTAssertTrue(fixture.appStore.showingAppSettings) + XCTAssertFalse(fixture.appStore.showingActivity) + + fixture.appStore.showAllDevices() + XCTAssertEqual(fixture.appStore.route, .allDevices) + XCTAssertNil(fixture.appStore.selectedDeviceID) + } + + func testAllDevicesRouteDoesNotAutoSelectWhenProfilesChange() async throws { + let fixture = try await makeFixture() + fixture.appStore.showAllDevices() + + _ = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + + XCTAssertEqual(fixture.appStore.route, .allDevices) + XCTAssertNil(fixture.appStore.selectedProfile) + } + + func testMissingDeviceRouteNormalizesToFirstProfileOrAllDevices() async throws { + let empty = try await makeFixture() + empty.appStore.navigate(to: .device("missing")) + XCTAssertEqual(empty.appStore.route, .allDevices) + + let fixture = try await makeFixture() + _ = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + _ = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + + fixture.appStore.navigate(to: .device("missing")) + + let firstStoredProfile = try XCTUnwrap(fixture.registry.profiles.first) + XCTAssertEqual(fixture.appStore.route, .device(firstStoredProfile.id)) + XCTAssertEqual(fixture.appStore.selectedProfile?.id, firstStoredProfile.id) + } + + func testDeletingSelectedProfileRoutesToFirstRemainingProfileOrAllDevices() async throws { + let fixture = try await makeFixture() + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + fixture.appStore.select(first) + + try await fixture.appStore.forget(first) + + XCTAssertEqual(fixture.appStore.route, .device(second.id)) + XCTAssertEqual(fixture.appStore.selectedProfile?.id, second.id) + + try await fixture.appStore.forget(second) + + XCTAssertEqual(fixture.appStore.route, .allDevices) + XCTAssertNil(fixture.appStore.selectedProfile) + } + + func testSelectedProfileRouteSynchronizesWhenRegistryDeletesProfile() async throws { + let fixture = try await makeFixture() + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + fixture.appStore.select(first) + + try await fixture.registry.delete(first) + + XCTAssertEqual(fixture.appStore.route, .device(second.id)) + XCTAssertEqual(fixture.appStore.selectedProfile?.id, second.id) + } + + func testDiagnosticsSelectedProfileFollowsDeviceRouteOnly() async throws { + let fixture = try await makeFixture() + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + + fixture.appStore.select(profile) + XCTAssertEqual(fixture.appStore.diagnosticsExportContext().selectedProfile?.id, profile.id) + + fixture.appStore.showAllDevices() + XCTAssertNil(fixture.appStore.diagnosticsExportContext().selectedProfile) + } + + func testAppStorePublishesOnlyAppLevelRouteChanges() async throws { + let fixture = try await makeFixture() + var cancellables: Set = [] + let published = expectation(description: "AppStore publishes route changes") + fixture.appStore.objectWillChange + .sink { + published.fulfill() + } + .store(in: &cancellables) + + fixture.appStore.showActivity() + + await fulfillment(of: [published], timeout: 1) + XCTAssertEqual(fixture.appStore.route, .activity) + _ = cancellables + } + + func testAppStoreDoesNotForwardChildStoreInvalidations() async throws { + let fixture = try await makeFixture() + var cancellables: Set = [] + let forwarded = expectation(description: "AppStore should not forward child store changes") + forwarded.isInverted = true + fixture.appStore.objectWillChange + .sink { + forwarded.fulfill() + } + .store(in: &cancellables) + + fixture.appStore.appReadinessStore.objectWillChange.send() + fixture.appStore.appSettingsStore.objectWillChange.send() + fixture.appStore.appUpdateStore.objectWillChange.send() + fixture.appStore.deviceRegistry.objectWillChange.send() + fixture.appStore.operationCoordinator.objectWillChange.send() + fixture.appStore.activityStore.objectWillChange.send() + fixture.appStore.deviceDiscovery.objectWillChange.send() + fixture.appStore.reachabilityStore.objectWillChange.send() + + await fulfillment(of: [forwarded], timeout: 0.1) + _ = cancellables + } + + private func makeFixture() async throws -> ( + appStore: AppStore, + registry: DeviceRegistryStore + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let coordinator = OperationCoordinator(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.backend), + deviceRegistry: registry, + operationCoordinator: coordinator, + passwordStore: InMemoryPasswordStore() + ) + return (appStore, registry) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift new file mode 100644 index 00000000..9e78d397 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppSettingsStoreTests.swift @@ -0,0 +1,273 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppSettingsStoreTests: XCTestCase { + func testLoadMissingSettingsUsesDefaults() async throws { + let temp = try TemporaryDirectory() + let store = AppSettingsStore(settingsURL: temp.url.appendingPathComponent("settings.json")) + + await store.load() + + XCTAssertEqual(store.state, .loaded) + XCTAssertEqual(store.settings, .default) + XCTAssertNil(store.error) + } + + func testSaveAndLoadRoundTripsAllSettings() async throws { + let temp = try TemporaryDirectory() + let settingsURL = temp.url.appendingPathComponent("settings.json") + let saved = AppSettings( + language: .simplifiedChinese, + appearance: .dark, + defaultBonjourTimeoutSeconds: 12.5, + defaultDeviceSettings: DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45, + ataIdleSeconds: 600, + ataStandby: 900 + ), + telemetryEnabled: false, + helperPathOverride: "/tmp/tcapsule", + showRawBackendEventsByDefault: false, + checkForUpdatesOnLaunch: false, + versionCheckURL: "https://example.invalid/version.json", + timeMachineWarningsEnabled: false + ) + + let writer = AppSettingsStore(settingsURL: settingsURL) + try await writer.save(saved) + let reader = AppSettingsStore(settingsURL: settingsURL) + await reader.load() + + XCTAssertEqual(reader.state, .loaded) + XCTAssertEqual(reader.settings, saved) + } + + func testLegacySettingsWithoutLanguageUseSystemDefault() async throws { + let temp = try TemporaryDirectory() + let settingsURL = temp.url.appendingPathComponent("settings.json") + try #"{"telemetryEnabled":false}"#.write(to: settingsURL, atomically: true, encoding: .utf8) + let store = AppSettingsStore(settingsURL: settingsURL) + + await store.load() + + XCTAssertEqual(store.state, .loaded) + XCTAssertEqual(store.settings.language, .system) + XCTAssertEqual(store.settings.appearance, .system) + XCTAssertFalse(store.settings.telemetryEnabled) + } + + func testCorruptSettingsFailsWithoutReplacingDefaults() async throws { + let temp = try TemporaryDirectory() + let settingsURL = temp.url.appendingPathComponent("settings.json") + try "{".write(to: settingsURL, atomically: true, encoding: .utf8) + let store = AppSettingsStore(settingsURL: settingsURL) + + await store.load() + + XCTAssertEqual(store.state, .failed) + XCTAssertEqual(store.settings, .default) + XCTAssertNotNil(store.error) + } + + func testDraftValidationRejectsBadNumbersAndURLs() throws { + var draft = AppSettingsDraft(settings: .default) + draft.defaultBonjourTimeoutSeconds = "-1" + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? AppSettingsValidationError, .invalidBonjourTimeout) + } + + draft = AppSettingsDraft(settings: .default) + draft.ataStandby = "abc" + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? AppSettingsValidationError, .invalidAtaStandby) + } + + draft = AppSettingsDraft(settings: .default) + draft.versionCheckURL = "file:///tmp/version.json" + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? AppSettingsValidationError, .invalidVersionCheckURL) + } + + draft = AppSettingsDraft(settings: .default) + draft.language = .simplifiedChinese + XCTAssertEqual(try draft.validatedSettings().language, .simplifiedChinese) + + draft = AppSettingsDraft(settings: .default) + draft.appearance = .dark + XCTAssertEqual(try draft.validatedSettings().appearance, .dark) + } + + func testLocalizationLanguageOverrideUsesSelectedBundleAndEnglishFallback() { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + L10n.apply(language: .english) + + XCTAssertEqual(L10n.string("app_settings.title", language: .english), "Settings") + XCTAssertEqual(L10n.string("app_settings.title", language: .simplifiedChinese), "设置") + XCTAssertEqual( + L10n.string("app_settings.subtitle", language: .simplifiedChinese), + "ę–°č®¾å¤‡é»˜č®¤å€¼å’Œ App ēŗ§åˆ«č”Œäøŗć€‚" + ) + XCTAssertEqual(L10n.string("sidebar.activity", language: .simplifiedChinese), "擻动") + XCTAssertEqual(L10n.string("activity.active", language: .simplifiedChinese), "ę­£åœØčæ›č”Œ") + XCTAssertEqual( + L10n.format("activity.multiple_active", 2), + "2 active operations" + ) + + L10n.apply(language: .simplifiedChinese) + XCTAssertEqual(L10n.string("app_settings.title"), "设置") + XCTAssertEqual(L10n.format("activity.multiple_active", 2), "2 äøŖę­£åœØčæ›č”Œēš„ę“ä½œ") + } + + func testSimplifiedChineseLocalizationCoversEnglishKeysAndFormatTokens() { + let english = L10n.strings(language: .english) + let simplifiedChinese = L10n.strings(language: .simplifiedChinese) + + XCTAssertFalse(english.isEmpty) + XCTAssertEqual(Set(simplifiedChinese.keys), Set(english.keys)) + for key in english.keys { + XCTAssertEqual(formatTokens(in: simplifiedChinese[key] ?? ""), formatTokens(in: english[key] ?? ""), key) + } + } + + func testStructuredLocalPresentationsRerenderAfterLanguageChange() { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + + let error = BackendErrorViewModel(operation: "deploy", localError: .deployPlanStale) + let issue = BundleRuntimeIssue(code: .helperMissing, severity: .error) + let checkup = DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 1_700_000_000), + state: .passed, + passCount: 2, + warnCount: 1, + failCount: 0, + summary: "PASS 2, WARN 1, FAIL 0" + ) + let deploy = testDeployState( + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + finishedAt: Date(timeIntervalSince1970: 1_700_000_000), + payloadFamily: nil, + rebootRequested: nil, + verified: true, + summary: "" + ) + + L10n.apply(language: .simplifiedChinese) + XCTAssertEqual(DoctorWorkflowState.running.title, "运蔌中") + XCTAssertEqual(DeployWorkflowState.planStale.title, "č®”åˆ’å·²čæ‡ęœŸ") + XCTAssertEqual(MaintenanceWorkflow.fsck.title, "ē£ē›˜äæ®å¤") + XCTAssertEqual(FlashWorkflowState.writeLocked.title, "就绪") + XCTAssertEqual(error.message, "éƒØē½²å‰čÆ·ę£€ęŸ„å¹¶é‡ę–°ē”ŸęˆéƒØē½²č®”åˆ’ć€‚") + XCTAssertEqual(issue.message, "ē¼ŗå°‘ę†ē»‘ēš„ TimeCapsuleSMB Helper怂") + XCTAssertEqual(issue.recovery, "é‡ę–°å®‰č£… TimeCapsuleSMB怂") + XCTAssertEqual(checkup.localizedSummary, "PASS 2,WARN 1,FAIL 0") + XCTAssertEqual(deploy.localizedSummary, "å®‰č£…å·²å®Œęˆć€‚") + XCTAssertEqual(L10n.string("install.timeline.title"), "ēŠ¶ę€") + + L10n.apply(language: .english) + XCTAssertEqual(DoctorWorkflowState.running.title, "Running") + XCTAssertEqual(DeployWorkflowState.planStale.title, "Plan Stale") + XCTAssertEqual(MaintenanceWorkflow.fsck.title, "Disk Repair") + XCTAssertEqual(FlashWorkflowState.writeLocked.title, "Ready") + XCTAssertEqual(error.message, "Review and regenerate the deploy plan before deploying.") + XCTAssertEqual(issue.message, "The bundled TimeCapsuleSMB helper is missing.") + XCTAssertEqual(issue.recovery, "Reinstall TimeCapsuleSMB.") + XCTAssertEqual(checkup.localizedSummary, "PASS 2, WARN 1, FAIL 0") + XCTAssertEqual(deploy.localizedSummary, "Install completed.") + XCTAssertEqual(L10n.string("install.timeline.title"), "Status") + } + + func testFocusedSimplifiedChineseKeysDoNotFallBackToEnglishUiCopy() { + let expectedChinese = [ + "button.discover": "å‘ēŽ°", + "app_appearance.dark": "深色", + "checkup.presentation.row.fail": "失蓄", + "backend.summary.doctor_checks_passed": "čÆŠę–­ę£€ęŸ„é€ščæ‡ć€‚", + "backend.summary.fsck_plan_generated": "å·²ē”Ÿęˆ fsck dry-run č®”åˆ’ć€‚", + "backend.summary.install_validation_passed": "å®‰č£…éŖŒčÆé€ščæ‡ć€‚", + "backend.summary.repair_xattrs_found": "å‘ēŽ° %d äøŖå…ƒę•°ę®é—®é¢˜ļ¼Œå…¶äø­ %d äøŖåÆäæ®å¤ć€‚", + "dashboard.overview.connection_target": "čæžęŽ„ē›®ę ‡", + "deploy.presentation.row.pre_upload_actions": "äøŠä¼ å‰ę“ä½œ", + "diagnostics.title": "čÆŠę–­", + "install.advanced_options": "é«˜ēŗ§é€‰é”¹", + "maintenance.workflow.repair_xattrs": "ę–‡ä»¶å…ƒę•°ę®äæ®å¤", + "profile_editor.display_name": "ę˜¾ē¤ŗåē§°", + "timeline.state.pending": "等待中", + "toggle.enable_debug_logging": "åÆē”Øč°ƒčÆ•ę—„åæ—", + "value.never": "ä»ŽęœŖ", + "workflow.state.deploying": "正在部署" + ] + + for (key, expectedValue) in expectedChinese { + XCTAssertEqual(L10n.string(key, language: .simplifiedChinese), expectedValue, key) + XCTAssertNotEqual( + L10n.string(key, language: .simplifiedChinese), + L10n.string(key, language: .english), + key + ) + } + } + + func testSavingSettingsAppliesHelperPathAndRunsTelemetrySyncOnlyWhenNeeded() async throws { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + let temp = try TemporaryDirectory() + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "set-telemetry", ok: true, payload: telemetryPayload(enabled: false)) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let settingsStore = AppSettingsStore(settingsURL: temp.url.appendingPathComponent("settings.json")) + await settingsStore.load() + let appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.appLane.backend), + appSettingsStore: settingsStore, + deviceRegistry: DeviceRegistryStore(applicationSupportURL: temp.url), + operationCoordinator: coordinator, + passwordStore: InMemoryPasswordStore() + ) + + var settings = AppSettings.default + settings.language = .simplifiedChinese + settings.telemetryEnabled = false + try await appStore.saveAppSettings(settings) + + try await waitUntilStoreState { runner.calls.map(\.operation).contains("set-telemetry") } + XCTAssertEqual(runner.calls.first?.params["enabled"], .bool(false)) + XCTAssertEqual(L10n.currentLanguage, .simplifiedChinese) + + var helperSettings = settings + helperSettings.helperPathOverride = "/tmp/tcapsule-helper" + try await appStore.saveAppSettings(helperSettings) + + XCTAssertEqual(appStore.backend.helperPath, "/tmp/tcapsule-helper") + } + + private func telemetryPayload(enabled: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "install_id": .string("install-one"), + "telemetry_enabled": .bool(enabled), + "bootstrap_path": .string("/tmp/.bootstrap"), + "summary": .string(enabled ? "Telemetry is enabled." : "Telemetry is disabled.") + ]) + } + + private func formatTokens(in string: String) -> [String] { + let pattern = "%(?:\\d+\\$)?[@df]" + let regex = try! NSRegularExpression(pattern: pattern) + let range = NSRange(string.startIndex.. = [] + store.objectWillChange + .sink { [weak store] _ in + Task { @MainActor in + guard !didFulfill, + store?.state == .current, + store?.isChecking == false else { + return + } + didFulfill = true + finishPublished.fulfill() + } + } + .store(in: &cancellables) + + store.checkNow(settings: .default) + + try await waitUntilStoreState { store.state == .current } + await fulfillment(of: [finishPublished], timeout: 2) + XCTAssertFalse(store.isChecking) + _ = cancellables + } + + func testCheckNowSurfacesUnavailableMetadataSeparatelyFromCurrentVersion() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "version-check", ok: true, payload: versionCheckPayload(shouldBlock: false, source: "unavailable")) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = AppUpdateStore(coordinator: coordinator) + + store.checkNow(settings: .default) + + try await waitUntilStoreState { store.state == .unavailable } + XCTAssertEqual(store.payload?.summary, "Version metadata is unavailable.") + XCTAssertEqual(store.payload?.localizedSummary, "Version metadata is unavailable.") + } + + func testCheckNowMarksOptionalUpdateAvailable() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "version-check", + ok: true, + payload: versionCheckPayload( + shouldBlock: false, + updateAvailable: true, + localVersionCode: 20124, + currentVersion: 20125 + ) + ) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = AppUpdateStore(coordinator: coordinator) + + store.checkNow(settings: .default) + + try await waitUntilStoreState { store.state == .updateAvailable } + XCTAssertEqual(store.payload?.localizedSummary, "Update available.") + } + + func testCheckNowBlocksConcurrentUpdateChecks() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [], pauseBeforeEvents: true) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = AppUpdateStore(coordinator: coordinator) + + store.checkNow(settings: .default) + try await waitUntilStoreState { runner.calls.count == 1 && store.isChecking } + store.checkNow(settings: .default) + + XCTAssertEqual(store.state, .failed) + XCTAssertEqual(store.error?.code, "operation_rejected") + runner.finishAll() + } + + private func versionCheckPayload( + shouldBlock: Bool, + updateAvailable: Bool = false, + source: String = "network", + localVersionCode: Int = 20125, + currentVersion: Int = 20125 + ) -> JSONValue { + let summary: String + if source == "unavailable" { + summary = "Version metadata is unavailable." + } else if shouldBlock { + summary = "Update required." + } else if updateAvailable { + summary = "Update available." + } else { + summary = "TimeCapsuleSMB is up to date." + } + return .object([ + "schema_version": .number(1), + "should_block": .bool(shouldBlock), + "update_available": .bool(updateAvailable), + "checked_url": .string("https://example.invalid/version.json"), + "message": .string(shouldBlock ? "Please update." : "Current."), + "download_url": .string("https://example.invalid/download"), + "local_version_code": .number(Double(localVersionCode)), + "current_version": .number(Double(currentVersion)), + "min_supported_version": .number(20000), + "latest_tag": .string("v2.1.4"), + "source": .string(source), + "summary": .string(summary) + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift new file mode 100644 index 00000000..0148b448 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -0,0 +1,404 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class BackendClientTests: XCTestCase { + func testRunPublishesEventsAndResetsState() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "stage", operation: "capabilities", stage: "start"), + BackendEvent(type: "result", operation: "capabilities", 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: "capabilities", params: ["dry_run": .bool(true)], requestID: "request-1") + + 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: "capabilities", + params: ["dry_run": .bool(true)], + requestID: "request-1", + context: nil + )] + ) + XCTAssertEqual(Set(client.events.compactMap(\.requestId)), Set(["request-1"])) + } + + 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 testCancelPendingConfirmationClearsPendingStateAndPublishesCancellationEvent() async throws { + let runner = RecordingHelperRunner( + 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: "") + ) + let client = BackendClient(runner: runner) + + client.run(operation: "deploy", params: ["dry_run": .bool(false)]) + try await waitUntil { + client.pendingConfirmation != nil && !client.isRunning + } + + client.cancelPendingConfirmation() + + XCTAssertNil(client.pendingConfirmation) + XCTAssertEqual(client.events.last?.type, "error") + XCTAssertEqual(client.events.last?.operation, "deploy") + XCTAssertEqual(client.events.last?.code, "confirmation_cancelled") + XCTAssertEqual(client.events.last?.message, "Operation cancelled.") + } + + 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: OperationCredentialInjector.injectingPassword("pw", into: ["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")) + XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw")])) + } + + 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")) + let laneKey = OperationLaneKey.device("device-one") + let deviceLane = coordinator.lane(for: laneKey) + + guard case .started(let activeOperation) = coordinator.run( + operation: "doctor", + context: context, + activeDeviceID: "device-one", + laneKey: laneKey + ) else { + XCTFail("Expected first operation to start.") + return + } + guard case .rejected(let rejectionMessage) = coordinator.run( + operation: "deploy", + context: context, + activeDeviceID: "device-one", + laneKey: laneKey + ) 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 { !deviceLane.backend.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], + requestID: String, + 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 requestID: String + 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], + requestID: String, + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + queue.sync { + storedCalls.append(Call( + helperPath: helperPath, + operation: operation, + params: params, + requestID: requestID, + 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"), + requestId: requestID + )) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in events { + await onEvent(event.withRequestId(requestID)) + } + return result + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift new file mode 100644 index 00000000..064d37b7 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -0,0 +1,168 @@ +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("Found 2 metadata 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, "Found 2 metadata issue(s), 1 repairable.") + XCTAssertEqual(blankSummaryFallsBack.summary, "doctor: Finished") + } + + func testBackendEventLocalizesKnownResultSummaries() { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + let event = BackendEvent( + type: "result", + operation: "doctor", + ok: true, + payload: .object(["summary": .string("Doctor checks passed.")]) + ) + + L10n.apply(language: .english) + XCTAssertEqual(event.localizedPayloadSummaryText, "Doctor checks passed.") + XCTAssertEqual(event.localizedSummary, "Doctor checks passed.") + + L10n.apply(language: .simplifiedChinese) + XCTAssertEqual(event.localizedPayloadSummaryText, "čÆŠę–­ę£€ęŸ„é€ščæ‡ć€‚") + XCTAssertEqual(event.localizedSummary, "čÆŠę–­ę£€ęŸ„é€ščæ‡ć€‚") + } + + func testBackendEventLocalizesStructuredResultSummaries() { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + let repair = BackendEvent( + type: "result", + operation: "repair-xattrs", + ok: true, + payload: .object([ + "summary_text": .string("Found 2 metadata issue(s), 1 repairable."), + "finding_count": .number(2), + "repairable_count": .number(1) + ]) + ) + let fsck = BackendEvent( + type: "result", + operation: "fsck", + ok: true, + payload: .object([ + "summary": .string("Dry-run plan generated for fsck."), + "target": .object(["device": .string("/dev/dk2"), "mountpoint": .string("/Volumes/Data")]) + ]) + ) + let flash = BackendEvent( + type: "result", + operation: "flash", + ok: true, + payload: .object([ + "summary": .string("Flash patch write validated; manual power cycle required."), + "mode": .string("patch"), + "write_status": .string("validated"), + "write_validated": .bool(true), + "post_write_action": .string("manual_power_cycle"), + "reboot_requested": .bool(false), + "rebooted": .bool(false) + ]) + ) + + L10n.apply(language: .english) + XCTAssertEqual(repair.localizedPayloadSummaryText, "Found 2 metadata issue(s), 1 repairable.") + XCTAssertEqual(fsck.localizedPayloadSummaryText, "Dry-run plan generated for fsck.") + XCTAssertEqual(flash.localizedPayloadSummaryText, "Flash patch write validated; manual power cycle required.") + + L10n.apply(language: .simplifiedChinese) + XCTAssertEqual(repair.localizedPayloadSummaryText, "å‘ēŽ° 2 äøŖå…ƒę•°ę®é—®é¢˜ļ¼Œå…¶äø­ 1 äøŖåÆäæ®å¤ć€‚") + XCTAssertEqual(fsck.localizedPayloadSummaryText, "å·²ē”Ÿęˆ fsck dry-run č®”åˆ’ć€‚") + XCTAssertEqual(flash.localizedPayloadSummaryText, "Flash patch å†™å…„å·²éŖŒčÆļ¼›éœ€č¦ę‰‹åŠØę–­ē”µé‡åÆć€‚") + } + + func testJSONValueRoundTripsNestedObjects() throws { + let value = JSONValue.object([ + "operation": .string("capabilities"), + "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/BackendOperationObserverTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendOperationObserverTests.swift new file mode 100644 index 00000000..f8bdc782 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendOperationObserverTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class BackendOperationObserverTests: XCTestCase { + func testObserverOnlyDeliversEventsForActiveRequestID() { + let observer = BackendOperationObserver() + let operation = ActiveOperation( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000123")!, + operation: "doctor", + profileID: "device-one", + context: nil + ) + observer.start(operation) + + var handled: [BackendEvent] = [] + observer.process([ + BackendEvent(requestId: "stale-request", type: "result", operation: "doctor", ok: true), + BackendEvent(requestId: operation.id.uuidString, type: "stage", operation: "doctor", stage: "probe"), + BackendEvent(requestId: operation.id.uuidString, type: "result", operation: "doctor", ok: true) + ]) { event, _ in + handled.append(event) + } + + XCTAssertEqual(handled.map(\.type), ["stage", "result"]) + } + + func testObserverAdvancesCursorAcrossCalls() { + let observer = BackendOperationObserver() + let operation = ActiveOperation(operation: "deploy", profileID: nil, context: nil) + let first = BackendEvent(requestId: operation.id.uuidString, type: "stage", operation: "deploy", stage: "plan") + let second = BackendEvent(requestId: operation.id.uuidString, type: "result", operation: "deploy", ok: true) + observer.start(operation) + + var handled: [BackendEvent] = [] + observer.process([first]) { event, _ in + handled.append(event) + } + observer.process([first, second]) { event, _ in + handled.append(event) + } + + XCTAssertEqual(handled.map(\.type), ["stage", "result"]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift new file mode 100644 index 00000000..7960a9df --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift @@ -0,0 +1,272 @@ +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 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")])])) + + let reachability = try jsonValue(""" + { + "schema_version": 1, + "status": "partial", + "ssh_host": "root@10.0.0.2", + "smb_host": "10.0.0.2", + "checks": [{"id": "ping", "status": "PASS", "message": "Host responds to ping.", "host": "10.0.0.2"}], + "counts": {"PASS": 1}, + "summary": "SSH reachable, SMB port closed." + } + """).decode(ReachabilityPayload.self) + + XCTAssertEqual(reachability.status, "partial") + XCTAssertEqual(reachability.checks[0].id, "ping") + } + + 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 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, + "startup_mode": "reboot_then_verify", + "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.startupMode, .rebootThenVerify) + 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: "capabilities", ok: true).decodePayload(CapabilitiesPayload.self)) { thrown in + XCTAssertEqual(thrown as? BackendContractError, .missingPayload(operation: "capabilities")) + } + + XCTAssertThrowsError( + try BackendEvent( + type: "result", + operation: "capabilities", + ok: true, + payload: .object(["schema_version": .string("wrong")]) + ).decodePayload(CapabilitiesPayload.self) + ) { thrown in + guard case BackendContractError.payloadDecodeFailed(let operation, let payloadType, _)? = thrown as? BackendContractError else { + return XCTFail("Expected payloadDecodeFailed, got \(thrown)") + } + XCTAssertEqual(operation, "capabilities") + XCTAssertEqual(payloadType, "CapabilitiesPayload") + } + } + + 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 00000000..58b53c4e --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift @@ -0,0 +1,245 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class BundleLayoutTests: XCTestCase { + func testResourceBundleLocatorPrefersPackagedResourceDirectory() throws { + let temp = try TemporaryDirectory() + let app = temp.url.appendingPathComponent("TimeCapsuleSMB.app", isDirectory: true) + let packaged = app + .appendingPathComponent("Contents/Resources", isDirectory: true) + .appendingPathComponent(AppResourceBundleLocator.bundleDirectoryName, isDirectory: true) + let appRoot = app.appendingPathComponent(AppResourceBundleLocator.bundleDirectoryName, isDirectory: true) + try FileManager.default.createDirectory(at: packaged, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appRoot, withIntermediateDirectories: true) + + let resolved = AppResourceBundleLocator.bundleURL( + appBundleURL: app, + resourceURL: app.appendingPathComponent("Contents/Resources", isDirectory: true) + ) + + XCTAssertEqual(resolved?.standardizedFileURL, packaged.standardizedFileURL) + } + + func testLaunchResourceValidationLoadsLocalizedStrings() { + XCTAssertNil(AppLaunchResourceValidation.validate()) + } + + func testLaunchResourceValidationIsIndependentOfSelectedLanguage() { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + L10n.apply(language: .simplifiedChinese) + + XCTAssertEqual(L10n.string("sidebar.activity"), "擻动") + XCTAssertNil(AppLaunchResourceValidation.validate()) + } + + func testStateInventoriesAreExplicit() { + XCTAssertEqual(BundleRuntimeMode.allCases, [.explicit, .productionBundle, .developmentCheckout]) + XCTAssertEqual(BundleRuntimeIssueSeverity.allCases, [.warning, .error]) + XCTAssertEqual( + BundleRuntimeIssueCode.allCases, + [ + .helperMissing, + .helperNotExecutable, + .pythonPackagesMissing, + .distributionRootMissing, + .artifactManifestMissing, + .artifactManifestInvalid, + .distributionArtifactsMissing, + .toolsDirectoryMissing, + .applicationSupportUnavailable, + .stateDirectoryUnavailable, + .unsupportedVersion, + .versionMetadataUnavailable, + .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 testMissingDistributionArtifactsIsBlockingIssue() throws { + let layout = try makeLayout(createDistributionBin: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .distributionArtifactsMissing && $0.severity == .error })) + } + + func testMissingArtifactManifestIsBlockingIssue() throws { + let layout = try makeLayout(createArtifactManifest: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .artifactManifestMissing && $0.severity == .error })) + } + + func testInvalidArtifactManifestIsBlockingIssue() throws { + let layout = try makeLayout(artifactManifestContents: "{") + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .artifactManifestInvalid && $0.severity == .error })) + } + + func testUnsafeArtifactManifestPathIsBlockingIssue() throws { + let layout = try makeLayout(artifactManifestContents: """ + { + "artifacts": { + "one": { + "path": "../outside" + } + } + } + """) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .artifactManifestInvalid && $0.severity == .error })) + } + + func testManifestMissingArtifactIsBlockingIssue() throws { + let layout = try makeLayout(createManifestArtifact: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .distributionArtifactsMissing && $0.severity == .error })) + } + + func testMissingPythonPackagesAreBlockingIssue() throws { + let layout = try makeLayout(createPythonPackages: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .pythonPackagesMissing && $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 })) + } + + func testApplicationSupportPathMustBeWritableDirectory() throws { + let temp = try TemporaryDirectory() + let appSupportFile = temp.url.appendingPathComponent("Application Support") + try "not a directory".write(to: appSupportFile, atomically: true, encoding: .utf8) + let layout = try makeLayout(applicationSupportURL: appSupportFile) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .applicationSupportUnavailable && $0.severity == .error })) + } + + private func makeLayout( + createHelper: Bool = true, + helperPermissions: Int = 0o755, + createDistribution: Bool = true, + createDistributionBin: Bool = true, + createArtifactManifest: Bool = true, + artifactManifestContents: String? = nil, + createManifestArtifact: Bool = true, + createPythonPackages: Bool = true, + createTools: Bool = true, + applicationSupportURL: URL? = nil + ) 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 = applicationSupportURL ?? temp.url.appendingPathComponent("Application Support", isDirectory: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) + if applicationSupportURL == nil { + 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 { + let distribution = resources.appendingPathComponent("Distribution", isDirectory: true) + try FileManager.default.createDirectory(at: distribution, withIntermediateDirectories: true) + if createDistributionBin { + let artifactDirectory = distribution.appendingPathComponent("bin/payloads", isDirectory: true) + try FileManager.default.createDirectory(at: artifactDirectory, withIntermediateDirectories: true) + if createManifestArtifact { + try "payload".write( + to: artifactDirectory.appendingPathComponent("one"), + atomically: true, + encoding: .utf8 + ) + } + } + if createArtifactManifest { + let manifest = artifactManifestContents ?? """ + { + "artifacts": { + "one": { + "path": "bin/payloads/one" + } + } + } + """ + try manifest.write( + to: distribution.appendingPathComponent("artifact-manifest.json"), + atomically: true, + encoding: .utf8 + ) + } + } + if createPythonPackages { + let pythonPackages = resources + .appendingPathComponent("Python", isDirectory: true) + .appendingPathComponent("site-packages", isDirectory: true) + try FileManager.default.createDirectory(at: pythonPackages, 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/ContentViewSmokeTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ContentViewSmokeTests.swift new file mode 100644 index 00000000..7f144a7e --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ContentViewSmokeTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ContentViewSmokeTests: XCTestCase { + func testRendersEmptyShellTopLevelRoutes() async throws { + let fixture = try await AppViewFixture() + for route in [AppRoute.allDevices, .activity, .appSettings, .addDevice] { + fixture.appStore.navigate(to: route) + try assertRendersNonBlank(fixture.contentView, minimumDistinctPixelCount: 4) + } + } + + func testRendersDeviceDashboardRoute() async throws { + let fixture = try await AppViewFixture() + let profile = try await fixture.saveProfile(id: "device-one") + fixture.appStore.select(profile) + + try assertRendersNonBlank(fixture.contentView) + } + + func testRendersAfterSelectedDeviceIsDeleted() async throws { + let fixture = try await AppViewFixture() + let first = try await fixture.saveProfile(id: "device-one", host: "root@10.0.0.2") + let second = try await fixture.saveProfile(id: "device-two", host: "root@10.0.0.3") + fixture.appStore.select(first) + + try await fixture.appStore.forget(first) + + XCTAssertEqual(fixture.appStore.route, .device(second.id)) + try assertRendersNonBlank(fixture.contentView) + } + + func testRendersReadinessBlockedSurface() async throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let runner = StoreTestRunner(responses: []) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let readiness = AppReadinessStore( + backend: coordinator.backend, + runtimeResolver: BlockingRuntimeResolver(), + helperPathProvider: { "" } + ) + let appStore = AppStore( + appReadinessStore: readiness, + appSettingsStore: AppSettingsStore(settingsURL: temp.url.appendingPathComponent("app-settings.json")), + deviceRegistry: registry, + operationCoordinator: coordinator, + passwordStore: InMemoryPasswordStore() + ) + let composition = AppViewComposition(appStore: appStore) + + readiness.start() + + guard case .blocked = readiness.state else { + return XCTFail("Expected readiness to be blocked.") + } + try assertRendersNonBlank(ContentView(composition: composition, startsAutomatically: false)) + } + + func testRendersWithPendingConfirmation() async throws { + let fixture = try await AppViewFixture(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Continue install?", + details: .object([ + "title": .string("Continue install?"), + "message": .string("Deploy TimeCapsuleSMB now."), + "action_title": .string("Deploy"), + "confirmation_id": .string("confirm-123") + ]) + ) + ]) + ]) + let profile = try await fixture.saveProfile(id: "device-one") + _ = fixture.appStore.operationCoordinator.run( + operation: "deploy", + params: ["dry_run": .bool(false)], + profile: profile + ) + try await waitUntilStoreState { + fixture.appStore.operationCoordinator.pendingConfirmation != nil + } + + fixture.appStore.select(profile) + + XCTAssertNotNil(fixture.appStore.operationCoordinator.pendingConfirmation) + try assertRendersNonBlank(fixture.contentView) + } +} + +private struct BlockingRuntimeResolver: AppRuntimeResolving { + func resolve(helperPath: String?) throws -> HelperResolution { + HelperResolution( + executableURL: URL(fileURLWithPath: "/tmp/tcapsule"), + distributionRootURL: nil, + toolsBinURL: nil, + mode: .developmentCheckout, + attemptedPaths: [] + ) + } + + func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] { + [ + BundleRuntimeIssue( + code: .helperMissing, + severity: .error, + message: "Test helper is missing." + ) + ] + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift new file mode 100644 index 00000000..4e87d6b7 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -0,0 +1,1217 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DashboardPresentationTests: XCTestCase { + 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.domains.first?.domain, .finderBonjour) + XCTAssertEqual(presentation.domains.first?.status, .warning) + } + + func testInstallActionsUseDownloadBoxIconExceptReinstall() { + XCTAssertEqual(DashboardSecondaryAction.refreshStatus.title, "Refresh Status") + XCTAssertEqual(DashboardSecondaryAction.refreshStatus.systemImage, "arrow.clockwise") + XCTAssertEqual(DashboardPrimaryAction.installSMB.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(DashboardSecondaryAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(CheckupUserAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(InstallUserAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(InstallUserAction.reinstall.systemImage, "arrow.clockwise") + } + + func testOverviewHeaderShowsActualGenerationInsteadOfCoarseCompatibilityBucket() throws { + let netbsd4 = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: try makeProfile(payloadFamily: "netbsd4_samba4", syap: "116", model: "Time Capsule", deviceGeneration: "gen1-4"), + passwordState: .available, + displayStatus: .unchecked, + primaryAction: .runCheckup, + hostWarning: nil + )) + + XCTAssertEqual(try headerValue("Generation", in: netbsd4), "4th generation") + + let modelFallback = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: try makeProfile(syap: "", model: "AirPort Extreme 6th generation", deviceGeneration: "gen1-4"), + passwordState: .available, + displayStatus: .unchecked, + primaryAction: .runCheckup, + hostWarning: nil + )) + + XCTAssertEqual(try headerValue("Generation", in: modelFallback), "6th generation") + + let coarseFallback = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: try makeProfile(syap: "", model: "AirPort Extreme", deviceGeneration: "tc_gen4"), + passwordState: .available, + displayStatus: .unchecked, + primaryAction: .runCheckup, + hostWarning: nil + )) + + XCTAssertEqual(try headerValue("Generation", in: coarseFallback), "4th generation") + } + + func testOverviewHeaderLocalizesLastCheckedDateForSimplifiedChinese() throws { + let originalLanguage = L10n.currentLanguage + defer { L10n.apply(language: originalLanguage) } + L10n.apply(language: .simplifiedChinese) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let checkedAt = try XCTUnwrap(calendar.date(from: DateComponents( + timeZone: .current, + year: 2026, + month: 5, + day: 29, + hour: 0, + minute: 12 + ))) + var profile = try makeProfile() + profile.lastCheckup = DeviceCheckupSnapshot( + checkedAt: checkedAt, + state: .passed, + passCount: 1, + warnCount: 0, + failCount: 0, + summary: "" + ) + + let presentation = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + )) + + XCTAssertEqual(presentation.header.lastChecked, "äøŠę¬”ę£€ęŸ„ļ¼š2026幓5月29ę—„ 00:12") + XCTAssertFalse(presentation.header.lastChecked.contains("May")) + XCTAssertFalse(presentation.header.lastChecked.contains("AM")) + } + + func testInstallActionAvailabilityBlocksMutatingActionsWhileDeviceIsBusy() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [])) + ], pauseBeforeEvents: true) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + let store = DeployWorkflowStore(coordinator: coordinator, laneKey: laneKey) + + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.reinstall, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.runCheckup, store: store)) + + _ = coordinator.run(operation: "doctor", context: nil, activeDeviceID: "device-one", laneKey: laneKey) + try await waitUntilStoreState { store.isBusy } + + XCTAssertFalse(InstallActionAvailabilityPolicy.isEnabled(.createPlan, store: store)) + XCTAssertFalse(InstallActionAvailabilityPolicy.isEnabled(.reinstall, store: store)) + XCTAssertFalse(InstallActionAvailabilityPolicy.isEnabled(.runCheckup, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.openFinder, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.viewCheckup, store: store)) + XCTAssertTrue(InstallActionAvailabilityPolicy.isEnabled(.viewDiagnostics, store: store)) + runner.finishAll() + } + + func testSidebarContextMenuIncludesRequestedActionsAndCopyValues() throws { + var profile = try makeProfile(host: "root@10.0.0.2") + profile.hostname = "airport-time-capsule.local." + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: false + ) + + XCTAssertEqual( + presentation.navigationItems.map(\.action), + [.openOverview, .openFinder, .runCheckup, .refreshStatus, .settings] + ) + XCTAssertTrue(presentation.navigationItems.allSatisfy(\.isEnabled)) + XCTAssertEqual( + presentation.clipboardItems.map(\.action), + [.copySMBAddress, .copyHostname, .copyIPAddress] + ) + XCTAssertEqual(presentation.clipboardValue(for: .copySMBAddress), "smb://airport-time-capsule.local") + XCTAssertEqual(presentation.clipboardValue(for: .copyHostname), "airport-time-capsule.local") + XCTAssertEqual(presentation.clipboardValue(for: .copyIPAddress), "10.0.0.2") + XCTAssertEqual(presentation.destructiveItems, [ + DeviceSidebarContextMenuItem(action: .removeFromThisMac, isEnabled: true) + ]) + } + + func testSidebarContextMenuSwitchesCheckupActionAndDisablesBusyActions() throws { + let profile = try makeProfile() + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .checking, + primaryAction: .viewCheckup, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: true + ) + + XCTAssertTrue(presentation.navigationItems.contains(DeviceSidebarContextMenuItem(action: .viewCheckup, isEnabled: true))) + XCTAssertFalse(presentation.navigationItems.contains { $0.action == .runCheckup }) + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .refreshStatus }?.isEnabled, + false + ) + XCTAssertEqual(presentation.destructiveItems, [ + DeviceSidebarContextMenuItem(action: .removeFromThisMac, isEnabled: false) + ]) + } + + func testSidebarContextMenuDisablesRunCheckupWhenDeviceLaneIsBusyWithoutCheckingStatus() throws { + let profile = try makeProfile() + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: true + ) + + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .runCheckup }, + DeviceSidebarContextMenuItem(action: .runCheckup, isEnabled: false) + ) + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .refreshStatus }, + DeviceSidebarContextMenuItem(action: .refreshStatus, isEnabled: false) + ) + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .openFinder }, + DeviceSidebarContextMenuItem(action: .openFinder, isEnabled: true) + ) + } + + func testSidebarContextMenuDisablesUnavailableActionsAndCopyValues() throws { + let profile = try makeProfile(host: "airport-time-capsule.local") + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .missing, + displayStatus: .passwordNeeded, + primaryAction: .replacePassword, + hostWarning: nil + ) + + let presentation = DeviceSidebarContextMenuPresentation( + profile: profile, + summary: summary, + isDeviceBusy: false + ) + + XCTAssertEqual( + presentation.navigationItems.first { $0.action == .runCheckup }, + DeviceSidebarContextMenuItem(action: .runCheckup, isEnabled: false) + ) + XCTAssertEqual( + presentation.clipboardItems.map(\.action), + [.copySMBAddress, .copyHostname, .copyIPAddress] + ) + XCTAssertEqual( + presentation.clipboardItems.first { $0.action == .copyIPAddress }?.isEnabled, + false + ) + XCTAssertNil(presentation.clipboardValue(for: .copyIPAddress)) + } + + func testDoctorDomainPolicyUsesTypedDetailsDomainAndSeverity() throws { + let payload = try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device"), + testDoctorCheck(status: "WARN", message: "bonjour warning", domain: "Bonjour"), + testDoctorCheck(status: "FAIL", message: "smb failed", domain: "SMB"), + doctorCheckWithoutDomain(status: "INFO", message: "misc info") + ]).decode(DoctorPayload.self) + let summary = DoctorSummary(payload: payload) + + let signals = DoctorCheckDomainPolicy.signals(from: summary) + + XCTAssertEqual(signals.map(\.domain), [.smbAuth, .finderBonjour, .connection, .general]) + XCTAssertEqual(signals.first?.severity, .failed) + XCTAssertEqual(DoctorCheckDomainPolicy.signal(for: .connection, summary: summary)?.passCount, 1) + XCTAssertEqual(DoctorCheckDomainPolicy.signal(for: .general, summary: summary)?.infoCount, 1) + XCTAssertNil(DoctorCheckDomainPolicy.signal(for: .disk, summary: summary)) + + let lowerStatusSummary = DoctorSummary(payload: try testDoctorPayload(checks: [ + testDoctorCheck(status: " warn ", message: "disk warning", domain: "Disk") + ]).decode(DoctorPayload.self)) + XCTAssertEqual(DoctorCheckDomainPolicy.signal(for: .disk, summary: lowerStatusSummary)?.warnCount, 1) + XCTAssertEqual(CheckupStatusPresentation(status: " warn "), .warning) + } + + func testCheckupPresentationCoversStatesTimelineAndHostWarning() throws { + let summary = DoctorSummary(payload: try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device") + ]).decode(DoctorPayload.self)) + let headlines: [DoctorWorkflowState: String] = [ + .idle: "Run a checkup to inspect this Apple AirPort Time Capsule or AirPort Extreme.", + .running: "Checkup is running.", + .passed: "Checkup passed.", + .warning: "Checkup found warnings.", + .failed: "Checkup failed.", + .runFailed: "Checkup could not complete." + ] + + for state in DoctorWorkflowState.allCases { + let presentation = CheckupPresentation(summary: summary, state: state) + + XCTAssertEqual(presentation.headline, headlines[state], "Unexpected headline for \(state).") + XCTAssertEqual(presentation.primaryAction, state == .running ? nil : .runCheckup) + } + + let stageEvent = BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + risk: "local_read", + cancellable: true, + description: "checking" + ) + let running = CheckupPresentation( + summary: summary, + state: .running, + events: [stageEvent], + currentStage: OperationStageState(event: stageEvent), + hostWarning: HostCompatibilityWarning(title: "macOS Warning", message: "Known Time Machine issue.") + ) + + XCTAssertEqual(running.timeline.count, 1) + XCTAssertEqual(running.timeline.first?.title, "Running Checkup") + XCTAssertEqual(running.hostWarning?.message, "Known Time Machine issue.") + } + + func testOverviewPresentationPromptsForMissingPassword() throws { + var profile = try makeProfile() + profile.passwordState = .missing + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .missing, + displayStatus: .passwordNeeded, + primaryAction: .replacePassword, + hostWarning: nil + ) + + let presentation = DeviceDashboardOverviewPresentation(summary: summary) + let connection = try row(.connection, in: presentation) + + XCTAssertEqual(presentation.primaryAction, .replacePassword) + XCTAssertTrue(presentation.requiresPasswordReplacement) + XCTAssertEqual(connection.status, .unknown) + XCTAssertEqual(connection.detail, "Connection status has not been refreshed.") + XCTAssertEqual(connection.action, .refreshStatus) + } + + func testOverviewPresentationUsesReachabilityForConnectionRow() throws { + let profile = try makeProfile() + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .unchecked, + primaryAction: .runCheckup, + hostWarning: nil + ) + let neverRefreshed = DeviceDashboardOverviewPresentation(summary: summary) + + XCTAssertEqual(try row(.connection, in: neverRefreshed).status, .unknown) + XCTAssertEqual(try row(.connection, in: neverRefreshed).detail, "Connection status has not been refreshed.") + XCTAssertEqual(try row(.connection, in: neverRefreshed).action, .refreshStatus) + + let missingPassword = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .missing, + displayStatus: .passwordNeeded, + primaryAction: .replacePassword, + hostWarning: nil + )) + XCTAssertEqual(try row(.connection, in: missingPassword).status, .unknown) + XCTAssertEqual(try row(.connection, in: missingPassword).detail, "Connection status has not been refreshed.") + XCTAssertEqual(try row(.connection, in: missingPassword).action, .refreshStatus) + + let reachable = DeviceReachabilitySnapshot( + refreshedAt: Date(timeIntervalSince1970: 1), + payload: try testReachabilityPayload().decode(ReachabilityPayload.self) + ) + let reachablePresentation = DeviceDashboardOverviewPresentation( + summary: summary, + reachabilitySnapshot: reachable + ) + XCTAssertEqual(try row(.connection, in: reachablePresentation).status, .good) + XCTAssertEqual(try row(.connection, in: reachablePresentation).detail, "SSH reachable; SMB port reachable.") + XCTAssertEqual(try row(.connection, in: reachablePresentation).action, .refreshStatus) + + let partial = DeviceReachabilitySnapshot( + refreshedAt: Date(timeIntervalSince1970: 2), + payload: try testReachabilityPayload(status: "partial", summary: "SSH reachable, SMB port closed.") + .decode(ReachabilityPayload.self) + ) + let partialRow = try row(.connection, in: DeviceDashboardOverviewPresentation(summary: summary, reachabilitySnapshot: partial)) + XCTAssertEqual(partialRow.status, .warning) + XCTAssertEqual(partialRow.detail, "SSH reachable, SMB port closed.") + + let unreachable = DeviceReachabilitySnapshot( + refreshedAt: Date(timeIntervalSince1970: 3), + payload: try testReachabilityPayload(status: "unreachable", summary: "Could not reach SSH or SMB.") + .decode(ReachabilityPayload.self) + ) + let unreachableRow = try row(.connection, in: DeviceDashboardOverviewPresentation(summary: summary, reachabilitySnapshot: unreachable)) + XCTAssertEqual(unreachableRow.status, .failed) + XCTAssertEqual(unreachableRow.detail, "Could not reach SSH or SMB.") + + let running = DeviceDashboardOverviewPresentation(summary: summary, isReachabilityRunning: true) + let runningRow = try row(.connection, in: running) + XCTAssertEqual(runningRow.status, .running) + XCTAssertEqual(runningRow.detail, "Checking DNS, SSH, and SMB reachability...") + XCTAssertFalse(running.isEnabled(.refreshStatus)) + XCTAssertFalse(running.isPrimaryActionEnabled) + } + + func testOverviewPresentationAggregatesServiceCheckupDomainsForHealthRow() throws { + var profile = try makeProfile() + profile.lastDeployState = testDeployState( + startedAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 100), + finishedAt: Date(timeIntervalSince1970: 100) + ) + profile.runtimeState = testRuntimeState() + let checkup = DoctorSummary(payload: try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "runtime ok", domain: "Runtime"), + testDoctorCheck(status: "WARN", message: "bonjour warning", domain: "Bonjour"), + testDoctorCheck(status: "FAIL", message: "smb failed", domain: "SMB"), + testDoctorCheck(status: "PASS", message: "time machine ok", domain: "Time Machine") + ]).decode(DoctorPayload.self)) + let summary = DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + ) + + let presentation = DeviceDashboardOverviewPresentation(summary: summary, currentCheckupSummary: checkup) + + XCTAssertEqual(try row(.runtime, in: presentation).status, .good) + XCTAssertEqual(presentation.healthSections.map(\.domain), [.connection, .runtime, .checkup]) + XCTAssertEqual(try row(.checkup, in: presentation).status, .failed) + XCTAssertEqual(try row(.checkup, in: presentation).detail, "PASS 1, WARN 1, FAIL 1") + XCTAssertEqual(try row(.checkup, in: presentation).action, .viewCheckup) + } + + func testOverviewPresentationCoversInstallHealthyActivationAndHostWarningStates() throws { + var readyProfile = try makeProfile() + readyProfile.lastCheckup = DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 3, + warnCount: 0, + failCount: 0, + summary: "healthy" + ) + let ready = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: readyProfile, + passwordState: .available, + displayStatus: .readyToInstall, + primaryAction: .installSMB, + hostWarning: nil + )) + XCTAssertEqual(try row(.runtime, in: ready).status, .warning) + XCTAssertEqual(try row(.runtime, in: ready).action, .installUpdate) + + var healthyProfile = readyProfile + healthyProfile.lastDeployState = testDeployState() + healthyProfile.runtimeState = testRuntimeState() + let healthy = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: healthyProfile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + )) + XCTAssertEqual(try row(.runtime, in: healthy).status, .good) + XCTAssertEqual(try row(.runtime, in: healthy).action, .openFinder) + + var netbsd4Profile = try makeProfile(payloadFamily: "netbsd4_samba4") + netbsd4Profile.lastDeployState = healthyProfile.lastDeployState + netbsd4Profile.runtimeState = testRuntimeState(state: .activationNeeded, source: .doctor, payloadFamily: "netbsd4_samba4", verified: false) + let activation = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: netbsd4Profile, + passwordState: .available, + displayStatus: .activationNeeded, + primaryAction: .viewCheckup, + hostWarning: nil + )) + XCTAssertEqual(try row(.runtime, in: activation).status, .warning) + XCTAssertEqual(try row(.runtime, in: activation).action, .startSMB) + + let warning = HostCompatibilityWarning(title: "macOS Warning", message: "Time Machine warning.") + let hostWarning = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: healthyProfile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: warning + )) + XCTAssertEqual(try row(.checkup, in: hostWarning).status, .warning) + XCTAssertEqual(try row(.checkup, in: hostWarning).detail, "Time Machine warning.") + } + + func testOverviewActionsUseFinderLabelAndSuppressRunCheckupWhileChecking() throws { + var profile = try makeProfile() + profile.lastDeployState = testDeployState() + profile.runtimeState = testRuntimeState() + + let healthy = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .healthy, + primaryAction: .openSMB, + hostWarning: nil + )) + XCTAssertEqual(DashboardPrimaryAction.openSMB.title, "Open Finder") + XCTAssertEqual(healthy.primaryAction, .openSMB) + XCTAssertEqual(healthy.secondaryActions, [.runCheckup, .settings]) + + let checking = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .checking, + primaryAction: .viewCheckup, + hostWarning: nil + )) + XCTAssertEqual(checking.primaryAction, .viewCheckup) + XCTAssertEqual(checking.secondaryActions, [.openFinder, .settings]) + XCTAssertFalse(checking.secondaryActions.contains(.runCheckup)) + let checkingRow = try row(.checkup, in: checking) + XCTAssertEqual(checkingRow.status, .running) + XCTAssertEqual(checkingRow.detail, "Checkup is running.") + XCTAssertEqual(checkingRow.action, .viewCheckup) + + let warning = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .warning, + primaryAction: .viewCheckup, + hostWarning: nil + )) + XCTAssertEqual(warning.secondaryActions, [.runCheckup, .openFinder, .settings]) + } + + func testOverviewDisablesMutatingActionsWhileOperationIsActive() throws { + let profile = try makeProfile() + let installing = DeviceDashboardOverviewPresentation(summary: DeviceDashboardSummary( + profile: profile, + passwordState: .available, + displayStatus: .installing, + primaryAction: .installSMB, + hostWarning: nil + )) + + XCTAssertFalse(installing.isPrimaryActionEnabled) + XCTAssertEqual(installing.secondaryActions, [.runCheckup, .settings]) + XCTAssertFalse(installing.isEnabled(.runCheckup)) + XCTAssertFalse(installing.isEnabled(.installUpdate)) + XCTAssertTrue(installing.isEnabled(.settings)) + + let checkup = try row(.checkup, in: installing) + XCTAssertEqual(checkup.action, .runCheckup) + XCTAssertFalse(installing.isEnabled(try XCTUnwrap(checkup.action))) + } + + func testInstallPlanPresentationShowsDeviceImpactAndWarnings() throws { + let plan = try netbsd4DeployPlan().decode(DeployPlanPayload.self) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + let warning = HostCompatibilityWarning(title: "macOS Warning", message: "Time Machine warning.") + + let presentation = InstallPlanPresentation(plan: plan, profile: profile, hostWarning: warning) + + XCTAssertEqual(presentation.title, "Install / Update SMB, Reboot, and Start Runtime") + XCTAssertFalse(presentation.sections.contains { $0.title == "Files" }) + let target = try XCTUnwrap(presentation.sections.first { $0.title == "Target" }) + XCTAssertTrue(target.rows.contains(PresentationRow(label: "Payload", value: "netbsd4_samba4"))) + XCTAssertFalse(target.rows.contains { $0.label == "Disk" || $0.label == "Payload Directory" }) + let actions = try XCTUnwrap(presentation.sections.first { $0.title == "Device Actions" }) + XCTAssertTrue(actions.rows.contains(PresentationRow(label: "Uploads", value: "1"))) + XCTAssertTrue(actions.rows.contains(PresentationRow(label: "Remote Actions", value: "1"))) + XCTAssertTrue(actions.rows.contains(PresentationRow(label: "Expected Downtime", value: "Several minutes while the device reboots."))) + XCTAssertEqual(presentation.warnings.count, 2) + } + + func testInstallPlanPresentationUsesActivateNowMode() throws { + let plan = try testDeployPlanPayload( + requiresReboot: false, + startupMode: .activateNow + ).decode(DeployPlanPayload.self) + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + + let presentation = InstallPlanPresentation(plan: plan, profile: profile) + + XCTAssertEqual(presentation.title, "Install / Update SMB and Start Runtime") + XCTAssertTrue(presentation.sections.contains { section in + section.rows.contains(PresentationRow( + label: "Expected Downtime", + value: "Usually under a minute while Samba starts without rebooting." + )) + }) + XCTAssertEqual(presentation.warnings, []) + } + + func testInstallPlanPresentationShowsNoWaitPostRebootImpact() throws { + let plan = try netbsd4DeployPlan().decode(DeployPlanPayload.self) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + let options = DeployOptions( + nbnsEnabled: true, + noReboot: false, + noWait: true, + internalShareUseDiskRoot: false, + anyProtocol: false, + debugLogging: false, + mountWait: 30 + ) + + let presentation = InstallPlanPresentation(plan: plan, profile: profile, options: options) + + XCTAssertEqual(presentation.title, "Install / Update SMB and Request Reboot") + XCTAssertTrue(presentation.sections.contains { section in + section.rows.contains(PresentationRow( + label: "Expected Downtime", + value: "The app will request reboot and return immediately." + )) + }) + XCTAssertEqual(presentation.warnings, [ + "No Wait will return after requesting reboot. Samba activation will not run automatically after SSH returns." + ]) + } + + func testInstallWorkflowPresentationRestoresPersistedDeployFailure() throws { + var profile = try makeProfile() + profile.lastDeployState = testDeployState( + status: .failed, + startedAt: Date(timeIntervalSince1970: 300), + updatedAt: Date(timeIntervalSince1970: 300), + finishedAt: Date(timeIntervalSince1970: 300), + stage: "read_mast", + payloadFamily: "netbsd6_samba4", + rebootRequested: nil, + verified: nil, + summary: "", + errorCode: "remote_error", + errorMessage: "No deployable HFS disk was found after 10 MaSt queries spaced 3 seconds apart.", + recovery: DeviceRecoverySnapshot( + title: "No HFS volumes found", + message: "The device did not report a deployable HFS disk through MaSt.", + actions: [], + actionIDs: [], + retryable: true, + suggestedOperation: "deploy", + docsAnchor: nil + ) + ) + + let presentation = InstallWorkflowPresentation( + state: .idle, + plan: nil, + result: nil, + error: nil, + events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ], + currentStage: nil, + profile: profile + ) + + XCTAssertEqual(presentation.stateTitle, "Deploy Failed") + XCTAssertEqual(presentation.statusMessage, "No deployable HFS disk was found after 10 MaSt queries spaced 3 seconds apart.") + XCTAssertEqual(presentation.error?.recovery?.title, "No HFS volumes found") + XCTAssertNil(presentation.failureGuidance) + XCTAssertEqual(presentation.timeline?.items.first?.title, "Find Payload Volume") + XCTAssertEqual(presentation.timeline?.items.first?.state, .failed) + XCTAssertNil(presentation.completion) + } + + func testInstallWorkflowPresentationRestoresInterruptedDeployState() throws { + var profile = try makeProfile() + profile.lastDeployState = testDeployState( + status: .interrupted, + startedAt: Date(timeIntervalSince1970: 300), + updatedAt: Date(timeIntervalSince1970: 310), + finishedAt: Date(timeIntervalSince1970: 310), + stage: "read_mast", + payloadFamily: "netbsd6_samba4", + rebootRequested: nil, + verified: nil, + summary: "", + errorCode: "operation_interrupted" + ) + + let presentation = InstallWorkflowPresentation( + state: .idle, + plan: nil, + result: nil, + error: nil, + events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ], + currentStage: nil, + profile: profile + ) + + XCTAssertEqual(presentation.stateTitle, "Deploy Failed") + XCTAssertEqual(presentation.statusMessage, "Deploy was interrupted before it completed.") + XCTAssertEqual(presentation.error?.code, "operation_interrupted") + XCTAssertEqual(presentation.timeline?.items.first?.title, "Find Payload Volume") + XCTAssertEqual(presentation.timeline?.items.first?.state, .failed) + XCTAssertNil(presentation.completion) + } + + func testInstallWorkflowPresentationCoversAllDeployStates() throws { + let profile = try makeProfile() + let plan = try testDeployPlanPayload().decode(DeployPlanPayload.self) + let result = try testDeployResultPayload().decode(DeployResultPayload.self) + let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "failed") + + let cases: [(DeployWorkflowState, DeployPlanPayload?, DeployResultPayload?, BackendErrorViewModel?, [InstallUserAction])] = [ + (.idle, nil, nil, nil, [.createPlan, .installUpdate]), + (.planning, nil, nil, nil, [.createPlan, .installUpdate]), + (.planReady, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.planStale, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.planFailed, nil, nil, error, [.createPlan, .installUpdate]), + (.deploying, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.awaitingConfirmation, plan, nil, nil, [.regeneratePlan, .installUpdate]), + (.deployed, plan, result, nil, []), + (.deployFailed, plan, nil, error, [.regeneratePlan, .installUpdate]) + ] + + for testCase in cases { + let presentation = InstallWorkflowPresentation( + state: testCase.0, + plan: testCase.1, + result: testCase.2, + error: testCase.3, + events: [], + currentStage: nil, + profile: profile + ) + XCTAssertEqual(presentation.actions, testCase.4, "Unexpected actions for \(testCase.0)") + } + } + + func testInstallWorkflowPresentationRestoresPostInstallViewFromSavedDeploySnapshot() throws { + var profile = try makeProfile() + profile.lastDeployState = testDeployState( + startedAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 200), + finishedAt: Date(timeIntervalSince1970: 200), + stage: "verify_runtime_reboot", + payloadFamily: "netbsd6_samba4", + rebootRequested: false, + verified: true, + summary: "" + ) + + let presentation = InstallWorkflowPresentation( + state: .idle, + plan: nil, + result: nil, + error: nil, + events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ], + currentStage: nil, + profile: profile + ) + + let completion = try XCTUnwrap(presentation.completion) + XCTAssertEqual(presentation.stateTitle, "Deployed") + XCTAssertEqual(presentation.statusMessage, "Install / Update completed.") + XCTAssertEqual(presentation.actions, []) + XCTAssertEqual(completion.title, "Install / Update Verified") + XCTAssertTrue(completion.rows.contains(PresentationRow(label: "Reboot Requested", value: "no"))) + XCTAssertTrue(completion.rows.contains(PresentationRow(label: "Message", value: "Install completed."))) + XCTAssertEqual(completion.actions, [.reinstall, .openFinder, .runCheckup, .viewDiagnostics]) + let timeline = try XCTUnwrap(presentation.timeline) + XCTAssertEqual(timeline.items.map(\.title), ["Done"]) + XCTAssertEqual(timeline.items.first?.detail, "Deployment completed.") + XCTAssertEqual(timeline.items.first?.state, .succeeded) + } + + func testInstallWorkflowPresentationKeepsTimelineAfterSuccessfulDeploy() throws { + let profile = try makeProfile() + let result = try testDeployResultPayload(payloadFamily: "netbsd6_samba4") + .decode(DeployResultPayload.self) + let presentation = InstallWorkflowPresentation( + state: .deployed, + plan: nil, + result: result, + error: nil, + events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_smbd"), + BackendEvent(type: "stage", operation: "deploy", stage: "verify_payload_upload"), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) + ], + currentStage: nil, + profile: profile + ) + + let timeline = try XCTUnwrap(presentation.timeline) + XCTAssertEqual(timeline.items.map(\.title), [ + "Upload smbd", + "Verify Upload", + "Done" + ]) + XCTAssertEqual(timeline.items.map(\.state), [.succeeded, .succeeded, .succeeded]) + XCTAssertNotNil(presentation.completion) + } + + func testInstallWorkflowPresentationShowsViewCheckupWhenCheckupIsRunning() throws { + var profile = try makeProfile() + profile.lastDeployState = testDeployState( + startedAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 200), + finishedAt: Date(timeIntervalSince1970: 200), + payloadFamily: "netbsd6_samba4", + rebootRequested: false, + verified: true, + summary: "Installed from previous app session." + ) + + let presentation = InstallWorkflowPresentation( + state: .idle, + plan: nil, + result: nil, + error: nil, + events: [], + currentStage: nil, + profile: profile, + isCheckupRunning: true + ) + + XCTAssertEqual(presentation.completion?.actions, [.reinstall, .openFinder, .viewCheckup, .viewDiagnostics]) + XCTAssertFalse(presentation.completion?.actions.contains(.runCheckup) == true) + } + + func testInstallWorkflowPresentationPrefersCurrentWorkflowOverSavedDeploySnapshot() throws { + var profile = try makeProfile() + profile.lastDeployState = testDeployState( + startedAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 200), + finishedAt: Date(timeIntervalSince1970: 200), + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "Installed from previous app session." + ) + let plan = try testDeployPlanPayload().decode(DeployPlanPayload.self) + let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "failed") + + let planReady = InstallWorkflowPresentation( + state: .planReady, + plan: plan, + result: nil, + error: nil, + events: [], + currentStage: nil, + profile: profile + ) + XCTAssertEqual(planReady.stateTitle, "Plan Ready") + XCTAssertEqual(planReady.actions, [.regeneratePlan, .installUpdate]) + XCTAssertNil(planReady.completion) + + let deployFailed = InstallWorkflowPresentation( + state: .deployFailed, + plan: plan, + result: nil, + error: error, + events: [], + currentStage: nil, + profile: profile + ) + XCTAssertEqual(deployFailed.stateTitle, "Deploy Failed") + XCTAssertEqual(deployFailed.actions, [.regeneratePlan, .installUpdate]) + XCTAssertNotNil(deployFailed.timeline) + XCTAssertNil(deployFailed.completion) + } + + func testInstallCompletionPresentationShowsVerificationAndNextActions() throws { + let result = try testDeployResultPayload(payloadFamily: "netbsd4_samba4", verified: true, netbsd4: true) + .decode(DeployResultPayload.self) + + let presentation = InstallCompletionPresentation(result: result) + + XCTAssertEqual(presentation.title, "Install / Update Verified") + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Verified", value: "yes"))) + XCTAssertEqual(presentation.warnings, [ + "NetBSD4 devices may need Activate after a later reboot unless the boot hook is patched." + ]) + XCTAssertEqual(presentation.actions, [.reinstall, .openFinder, .runCheckup, .viewDiagnostics]) + XCTAssertEqual(InstallUserAction.installUpdate.systemImage, "square.and.arrow.down.on.square") + XCTAssertEqual(InstallUserAction.reinstall.systemImage, "arrow.clockwise") + XCTAssertEqual(InstallUserAction.reinstall.title, "Reinstall") + } + + func testInstallCompletionPresentationReplacesRunCheckupWithViewCheckupWhileChecking() throws { + let result = try testDeployResultPayload(payloadFamily: "netbsd6_samba4", verified: true, netbsd4: false) + .decode(DeployResultPayload.self) + + let presentation = InstallCompletionPresentation(result: result, isCheckupRunning: true) + + XCTAssertEqual(presentation.actions, [.reinstall, .openFinder, .viewCheckup, .viewDiagnostics]) + XCTAssertEqual(InstallUserAction.viewCheckup.title, "View Checkup") + XCTAssertEqual(InstallUserAction.viewCheckup.systemImage, "list.bullet.clipboard") + } + + func testInstallTimelinePresentationUsesDeployEventsOnly() { + let presentation = InstallTimelinePresentation(events: [ + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload", description: "uploading") + ], currentStage: nil) + + XCTAssertEqual(presentation.items.count, 1) + XCTAssertEqual(presentation.items.first?.title, "Upload Payload") + } + + func testInstallTimelinePresentationStopsRunningStageAfterDeployError() { + let presentation = InstallTimelinePresentation(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "read_mast"), + BackendEvent(type: "error", operation: "deploy", code: "remote_error", message: "No deployable HFS disk was found.") + ], currentStage: nil) + + XCTAssertEqual(presentation.items.first?.title, "Find Payload Volume") + XCTAssertEqual(presentation.items.first?.state, .failed) + XCTAssertEqual(presentation.items.last?.state, .failed) + XCTAssertFalse(presentation.items.contains { $0.state == .running }) + } + + func testInstallProgressPresentationAppearsOnlyWhileDeploying() { + let stage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "deploy", + stage: "upload_payload", + description: "Uploading files." + )) + + let deploying = InstallProgressPresentation(state: .deploying, currentStage: stage) + + XCTAssertEqual(deploying?.title, "Installing / Updating SMB") + XCTAssertEqual(deploying?.message, "Uploading and applying the managed SMB runtime. This can take a few minutes...") + XCTAssertEqual(deploying?.detail, "Uploading managed SMB payload files.") + for state in DeployWorkflowState.allCases where state != .deploying { + XCTAssertNil(InstallProgressPresentation(state: state, currentStage: stage), "\(state) should not show a blocking progress modal.") + } + } + + func testCheckupProgressPresentationAppearsOnlyWhileRunning() { + let stage = OperationStageState(event: BackendEvent( + type: "stage", + operation: "doctor", + stage: "run_checks", + description: "Run local and remote diagnostic checks." + )) + + let running = CheckupProgressPresentation(state: .running, currentStage: stage) + + XCTAssertEqual(running?.title, "Running Checkup") + XCTAssertEqual(running?.message, "Running local and remote diagnostic checks.\nThis can take a few minutes...") + XCTAssertNil(running?.detail) + for state in DoctorWorkflowState.allCases where state != .running { + XCTAssertNil(CheckupProgressPresentation(state: state, currentStage: stage), "\(state) should not show a blocking progress modal.") + } + } + + func testMaintenanceActionPolicyUsesStableWorkflowActionGroups() { + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .activate), [.planActivation, .runActivation]) + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .uninstall), [.planUninstall, .runUninstall]) + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .fsck), [.findVolumes, .planFsck, .runFsck]) + XCTAssertEqual(MaintenanceActionPolicy.actions(for: .repairXattrs), [.scanMetadata, .repairMetadata]) + XCTAssertEqual(MaintenanceUserAction.planActivation.title, "Plan Activate") + XCTAssertEqual(MaintenanceUserAction.runActivation.title, "Activate") + XCTAssertFalse(MaintenanceUserAction.planActivation.isCommitAction) + XCTAssertTrue(MaintenanceUserAction.runActivation.isCommitAction) + } + + func testMaintenanceStatusMessagesCoverAllStates() { + for state in MaintenanceOperationState.allCases { + XCTAssertFalse(state.maintenanceStatusMessage(for: .activate).isEmpty) + XCTAssertFalse(state.maintenanceStatusMessage(for: .repairXattrs).isEmpty) + } + + XCTAssertEqual(MaintenanceOperationState.listReady.maintenanceStatusMessage(for: .fsck), "Choose a volume, then plan disk repair.") + XCTAssertEqual(MaintenanceOperationState.scanReady.maintenanceStatusMessage(for: .repairXattrs), "Review the scan before repairing metadata.") + XCTAssertEqual(MaintenanceOperationState.scanReady.maintenanceStatusMessage(for: .activate), "Scan Ready") + } + + func testMaintenancePresentationHidesActivationForDevicesThatDoNotNeedIt() throws { + let store = MaintenanceStore(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + + let presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + + XCTAssertEqual(presentation.cards.map { $0.workflow }, [MaintenanceWorkflow.uninstall, .fsck, .repairXattrs]) + XCTAssertEqual(presentation.cards.first?.isSelected, true) + XCTAssertEqual(presentation.detail.workflow, .uninstall) + XCTAssertEqual(presentation.detail.title, "Uninstall") + } + + func testMaintenancePresentationKeepsActivationForNetBSD4Devices() throws { + let store = MaintenanceStore(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + let presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + + XCTAssertEqual(presentation.cards.map { $0.workflow }, [MaintenanceWorkflow.activate, .uninstall, .fsck, .repairXattrs]) + XCTAssertEqual(presentation.cards.first?.isSelected, true) + XCTAssertEqual(presentation.detail.workflow, .activate) + } + + func testMaintenancePresentationShowsRunActionsDisabledBeforePlanning() throws { + let store = MaintenanceStore(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + var presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .activate) + XCTAssertEqual(presentation.detail.actions, [.planActivation, .runActivation]) + XCTAssertTrue(presentation.detail.isEnabled(.planActivation)) + XCTAssertFalse(presentation.detail.isEnabled(.runActivation)) + + store.selectedWorkflow = .uninstall + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.actions, [.planUninstall, .runUninstall]) + XCTAssertTrue(presentation.detail.isEnabled(.planUninstall)) + XCTAssertFalse(presentation.detail.isEnabled(.runUninstall)) + + store.selectedWorkflow = .fsck + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.actions, [.findVolumes, .planFsck, .runFsck]) + XCTAssertTrue(presentation.detail.isEnabled(.findVolumes)) + XCTAssertFalse(presentation.detail.isEnabled(.planFsck)) + XCTAssertFalse(presentation.detail.isEnabled(.runFsck)) + + store.selectedWorkflow = .repairXattrs + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.actions, [.scanMetadata, .repairMetadata]) + XCTAssertFalse(presentation.detail.isEnabled(.scanMetadata)) + XCTAssertFalse(presentation.detail.isEnabled(.repairMetadata)) + } + + func testMaintenancePresentationBuildsWorkflowPlansAndCompletions() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(alreadyActive: true)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + var presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .activate) + XCTAssertEqual(presentation.detail.actions, [.planActivation, .runActivation]) + XCTAssertTrue(presentation.detail.isEnabled(.planActivation)) + XCTAssertTrue(presentation.detail.isEnabled(.runActivation)) + XCTAssertEqual(presentation.detail.plan?.title, "Activation Plan") + XCTAssertEqual(presentation.detail.plan?.rows.first, PresentationRow(label: "Device", value: profile.title)) + + store.runActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.completion?.title, "Activation Complete") + XCTAssertTrue(presentation.detail.completion?.rows.contains(PresentationRow(label: "Already Active", value: "yes")) == true) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .uninstall) + XCTAssertEqual(presentation.detail.actions, [.planUninstall, .runUninstall]) + XCTAssertTrue(presentation.detail.isEnabled(.planUninstall)) + XCTAssertTrue(presentation.detail.isEnabled(.runUninstall)) + XCTAssertEqual(presentation.detail.plan?.warnings, ["Uninstall removes installed files from this device."]) + + store.refreshFsckTargets(password: "pw") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .fsck) + XCTAssertEqual(presentation.detail.actions, [.findVolumes, .planFsck, .runFsck]) + XCTAssertTrue(presentation.detail.isEnabled(.findVolumes)) + XCTAssertTrue(presentation.detail.isEnabled(.planFsck)) + XCTAssertFalse(presentation.detail.isEnabled(.runFsck)) + + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.plan?.title, "Disk Repair Plan") + XCTAssertEqual(presentation.detail.plan?.warnings, ["Disk repair can modify the selected volume."]) + + store.repairPath = "/Volumes/Data" + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.workflow, .repairXattrs) + XCTAssertEqual(presentation.detail.actions, [.scanMetadata, .repairMetadata]) + XCTAssertTrue(presentation.detail.isEnabled(.scanMetadata)) + XCTAssertTrue(presentation.detail.isEnabled(.repairMetadata)) + XCTAssertEqual(presentation.detail.plan?.title, "Metadata Scan") + XCTAssertEqual(presentation.detail.plan?.warnings, ["Metadata repair modifies files under the selected local SMB mount."]) + } + + func testMaintenancePresentationKeepsTimelineAfterWorkflowCompletes() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "activate", stage: "probe_runtime"), + BackendEvent(type: "stage", operation: "activate", stage: "run_activation"), + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(alreadyActive: false)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + store.runActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } + + var presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + XCTAssertEqual(presentation.detail.timeline?.items.map(\.title), [ + "Check Existing Runtime", + "Starting SMB", + "Done" + ]) + XCTAssertEqual(presentation.detail.timeline?.items.map(\.state), [.succeeded, .succeeded, .succeeded]) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.selectedWorkflow = .activate + presentation = MaintenanceDashboardPresentation(store: store, profile: profile) + + XCTAssertEqual(presentation.detail.workflow, .activate) + XCTAssertEqual(presentation.detail.timeline?.items.map(\.title), [ + "Check Existing Runtime", + "Starting SMB", + "Done" + ]) + } + + func testMaintenanceTimelineFiltersByWorkflowOperation() { + let presentation = MaintenanceTimelinePresentation(events: [ + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks"), + BackendEvent(type: "stage", operation: "uninstall", stage: "remove_payload", description: "removing") + ], currentStage: nil, workflow: .uninstall) + + XCTAssertEqual(presentation.items.count, 1) + XCTAssertEqual(presentation.items.first?.title, "Remove Payload") + } + + 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(true), + "reboot_required": .bool(true), + "startup_mode": .string("reboot_then_activate"), + "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.") + ]) + } + + private func doctorCheckWithoutDomain(status: String, message: String) -> JSONValue { + .object([ + "status": .string(status), + "message": .string(message), + "details": .object([:]) + ]) + } + + private func makeProfile( + id: String = "device-one", + host: String = "10.0.0.2", + payloadFamily: String = "netbsd6_samba4", + syap: String = "119", + model: String = "Time Capsule", + deviceGeneration: String = "tc_gen4" + ) throws -> DeviceProfile { + DeviceProfile.make( + id: id, + configuredDevice: try testConfiguredDevice( + host: host, + syap: syap, + model: model, + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration + ), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + } + + private func headerValue( + _ label: String, + in presentation: DeviceDashboardOverviewPresentation + ) throws -> String { + try XCTUnwrap(presentation.header.rows.first { $0.label == label }).value + } + + private func row( + _ domain: DashboardHealthDomain, + in presentation: DeviceDashboardOverviewPresentation + ) throws -> DashboardHealthRow { + let section = try XCTUnwrap(presentation.healthSections.first { $0.domain == domain }) + return try XCTUnwrap(section.rows.first) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift new file mode 100644 index 00000000..3fe4d8d6 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -0,0 +1,1237 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DashboardStoreTests: XCTestCase { + func testNoDeviceRegistryLeavesNoSelectedProfile() async throws { + let fixture = try await makeFixture(responses: []) + + XCTAssertEqual(fixture.registry.state, .empty) + XCTAssertNil(fixture.appStore.selectedProfile) + } + + func testPrimaryActionDerivesFromPasswordAndRuntimeState() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await 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) + + await fixture.registry.updateRuntimeState(testRuntimeState(source: .doctor, summary: ""), for: profile.id) + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: checked).primaryAction, .openSMB) + + await fixture.registry.updateRuntimeState(testRuntimeState(summary: "Install completed."), for: profile.id) + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: installed).primaryAction, .openSMB) + + await fixture.registry.updateRuntimeState(testRuntimeState(state: .installedUnverified, source: .doctor, verified: false, summary: "warning"), for: profile.id) + let warning = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: warning).primaryAction, .viewCheckup) + } + + func testPrimaryActionsRouteThroughDashboardSession() async throws { + let fixture = try await 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 await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let opener = RecordingURLOpener() + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore, urlOpener: opener) + + session.performPrimaryAction(.runCheckup, profile: profile) + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(session.selectedTab, .checkup) + + session.performPrimaryAction(.installSMB, profile: profile) + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + XCTAssertEqual(fixture.runner.calls[1].operation, "deploy") + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(session.selectedTab, .install) + + session.performPrimaryAction(.viewCheckup, profile: profile) + XCTAssertEqual(session.selectedTab, .checkup) + + session.profileEditorStore.replacementPassword = "draft" + session.performPrimaryAction(.replacePassword, profile: profile) + XCTAssertEqual(session.selectedTab, .settings) + XCTAssertEqual(session.profileEditorStore.replacementPassword, "draft") + XCTAssertNil(session.profileEditorStore.passwordError) + + session.performPrimaryAction(.openSMB, profile: profile) + XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://10.0.0.2"]) + } + + func testOpenSMBPrimaryActionUsesBonjourHostnameWhenAvailable() async throws { + let fixture = try await makeFixture(responses: []) + let discovered = DiscoveredDevice( + payload: try testDiscoveredDevice( + host: "10.0.0.2", + hostname: "office-capsule.local.", + fullname: "Office Capsule._airport._tcp.local." + ).decode(DiscoveredDevicePayload.self), + index: 0 + ) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: discovered, + passwordState: .available, + preferredID: "device-one" + ) + let opener = RecordingURLOpener() + let session = DeviceDashboardSession( + profile: profile, + appStore: fixture.appStore, + urlOpener: opener, + smbAccountResolver: StaticSMBAccountResolver(accounts: [profile.id: "jameschang"]) + ) + + session.performPrimaryAction(.openSMB, profile: profile) + + XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://jameschang@Office%20Capsule._smb._tcp.local"]) + } + + func testRefreshStatusSecondaryActionRunsReachability() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + + session.performSecondaryAction(.refreshStatus, profile: profile) + try await waitUntilStoreState { fixture.appStore.reachabilityStore.snapshot(for: profile) != nil } + + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["reachability"]) + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(fixture.appStore.reachabilityStore.snapshot(for: profile)?.payload.status, "reachable") + } + + func testRefreshStatusDoesNotClearSuccessfulDeployTimeline() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_smbd"), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + + session.runInstallPlan(profile: profile) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: profile) + try await waitUntilStoreState { session.deployStore.state == .deployed } + + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + let beforeRefresh = InstallWorkflowPresentation( + state: session.deployStore.state, + plan: session.deployStore.plan, + result: session.deployStore.result, + error: session.deployStore.error, + events: session.deployStore.events, + currentStage: session.deployStore.currentStage, + profile: installed + ) + XCTAssertEqual(beforeRefresh.timeline?.items.map(\.title), ["Upload smbd", "Done"]) + + session.performSecondaryAction(.refreshStatus, profile: installed) + try await waitUntilStoreState { fixture.appStore.reachabilityStore.snapshot(for: installed) != nil } + + let afterRefresh = InstallWorkflowPresentation( + state: session.deployStore.state, + plan: session.deployStore.plan, + result: session.deployStore.result, + error: session.deployStore.error, + events: session.deployStore.events, + currentStage: session.deployStore.currentStage, + profile: installed + ) + XCTAssertEqual(afterRefresh.timeline?.items.map(\.title), ["Upload smbd", "Done"]) + XCTAssertEqual(afterRefresh.timeline?.items.last?.detail, "Deployment completed.") + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["deploy", "deploy", "reachability"]) + } + + func testCheckupDoesNotClearSuccessfulDeployTimeline() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_smbd"), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + + session.runInstallPlan(profile: profile) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: profile) + try await waitUntilStoreState { session.deployStore.state == .deployed } + session.runCheckup(profile: profile) + try await waitUntilStoreState { session.doctorStore.state == .passed } + + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + let presentation = InstallWorkflowPresentation( + state: session.deployStore.state, + plan: session.deployStore.plan, + result: session.deployStore.result, + error: session.deployStore.error, + events: session.deployStore.events, + currentStage: session.deployStore.currentStage, + profile: installed + ) + XCTAssertEqual(presentation.timeline?.items.map(\.title), ["Upload smbd", "Done"]) + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["deploy", "deploy", "doctor"]) + } + + func testProfileEditorPasswordSaveUpdatesPasswordStateAndClearsDraft() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + session.performPrimaryAction(.replacePassword, profile: profile) + session.profileEditorStore.replacementPassword = "new-password" + + await session.profileEditorStore.save(profile: profile) + + XCTAssertEqual(session.profileEditorStore.replacementPassword, "") + XCTAssertNil(session.profileEditorStore.passwordError) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "new-password") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .available) + } + + func testProfileEditorPasswordSaveFailureKeepsDraft() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + fixture.passwordStore.saveFailure = .save + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore) + session.profileEditorStore.replacementPassword = "new-password" + + await session.profileEditorStore.save(profile: profile) + + XCTAssertEqual(session.profileEditorStore.replacementPassword, "new-password") + XCTAssertEqual(session.profileEditorStore.passwordError, "In-memory password store save failed.") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + } + + func testDashboardSessionsAreIsolatedByProfile() async throws { + let fixture = try await makeFixture(responses: []) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + + let firstSession = dashboard.session(for: first) + firstSession.selectedTab = .maintenance + firstSession.profileEditorStore.replacementPassword = "draft" + firstSession.deployStore.mountWait = "77" + firstSession.maintenanceStore.selectedWorkflow = .fsck + + let secondSession = dashboard.session(for: second) + + XCTAssertFalse(firstSession === secondSession) + XCTAssertEqual(secondSession.selectedTab, .overview) + XCTAssertEqual(secondSession.profileEditorStore.replacementPassword, "") + XCTAssertEqual(secondSession.deployStore.mountWait, "30") + XCTAssertEqual(secondSession.maintenanceStore.selectedWorkflow, .activate) + } + + func testSessionDefaultsComeFromProfileSettingsAndDoNotResetOnSnapshotUpdates() async throws { + let fixture = try await makeFixture(responses: []) + var profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + profile.settings = DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45, + ataIdleSeconds: 0, + ataStandby: 0 + ) + profile = try await fixture.registry.updateProfile(profile) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + + XCTAssertEqual(session.deployStore.nbnsEnabled, false) + XCTAssertEqual(session.deployStore.internalShareUseDiskRoot, true) + XCTAssertEqual(session.deployStore.anyProtocol, true) + XCTAssertEqual(session.deployStore.debugLogging, true) + XCTAssertEqual(session.deployStore.ataIdleSeconds, "0") + XCTAssertEqual(session.deployStore.ataStandby, "0") + XCTAssertEqual(session.deployStore.mountWait, "45") + XCTAssertEqual(session.maintenanceStore.mountWait, "45") + + session.deployStore.mountWait = "12" + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 1, + warnCount: 0, + failCount: 0, + summary: "healthy" + ), for: profile.id) + + XCTAssertEqual(session.deployStore.mountWait, "12") + } + + func testProfileEditorSaveAppliesSettingsBackToSessionDefaults() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + + session.profileEditorStore.draft.nbnsEnabled = false + session.profileEditorStore.draft.internalShareUseDiskRoot = true + session.profileEditorStore.draft.anyProtocol = true + session.profileEditorStore.draft.debugLogging = true + session.profileEditorStore.draft.mountWaitSeconds = "64" + session.profileEditorStore.draft.ataIdleSeconds = "0" + session.profileEditorStore.draft.ataStandby = "0" + + await session.profileEditorStore.save(profile: profile) + + XCTAssertEqual(session.profileEditorStore.state, .saved) + XCTAssertEqual(session.deployStore.nbnsEnabled, false) + XCTAssertEqual(session.deployStore.internalShareUseDiskRoot, true) + XCTAssertEqual(session.deployStore.anyProtocol, true) + XCTAssertEqual(session.deployStore.debugLogging, true) + XCTAssertEqual(session.deployStore.ataIdleSeconds, "0") + XCTAssertEqual(session.deployStore.ataStandby, "0") + XCTAssertEqual(session.deployStore.mountWait, "64") + XCTAssertEqual(session.maintenanceStore.mountWait, "64") + } + + func testDeletingProfilePrunesInactiveSession() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + var session: DeviceDashboardSession? = dashboard.session(for: profile) + weak var weakSession = session + XCTAssertNotNil(weakSession) + session = nil + + try await fixture.registry.delete(profile) + + try await waitUntilStoreState { weakSession == nil } + } + + func testDeletedProfileSessionStaysUntilStartedOperationFinishes() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ], pauseBeforeEvents: true) + ]) + let profile = try await 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) + var session: DeviceDashboardSession? = dashboard.session(for: profile) + weak var weakSession = session + + session?.runCheckup(profile: profile) + try await waitUntilStoreState { self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + try await fixture.registry.delete(profile) + session = nil + + XCTAssertNotNil(weakSession) + fixture.runner.finishAll() + try await waitUntilStoreState { !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + try await waitUntilStoreState { weakSession == nil } + } + + func testOperationRunningOnAnotherDeviceAllowsNewSessionOperation() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ], pauseBeforeEvents: true), + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) + ]) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + try fixture.passwordStore.save("pw1", for: first.keychainAccount) + try fixture.passwordStore.save("pw2", for: second.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let firstSession = dashboard.session(for: first) + let secondSession = dashboard.session(for: second) + + firstSession.runCheckup(profile: first) + try await waitUntilStoreState { self.deviceLaneIsRunning(first, appStore: fixture.appStore) } + secondSession.runCheckup(profile: second) + + try await waitUntilStoreState { fixture.runner.calls.count == 2 } + XCTAssertEqual(secondSession.doctorStore.state, .running) + try await waitUntilStoreState { secondSession.doctorStore.state == .passed } + XCTAssertEqual(Set(fixture.runner.calls.map { $0.context?.profileID }), ["device-one", "device-two"]) + fixture.runner.finishAll() + try await waitUntilStoreState { !self.deviceLaneIsRunning(first, appStore: fixture.appStore) } + } + + func testDashboardOperationsUpdateCheckupDeployAndRuntimeSnapshots() async throws { + let fixture = try await 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")) + ], pauseBeforeEvents: true) + ]) + let profile = try await 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) + let session = dashboard.session(for: profile) + + session.runCheckup(profile: profile) + + try await waitUntilStoreState { + session.doctorStore.state == .warning + && fixture.registry.profile(id: profile.id)?.lastCheckup?.state == .warning + && fixture.registry.profile(id: profile.id)?.runtimeState?.state == .installedUnverified + } + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(checked.lastCheckup?.state, .warning) + XCTAssertEqual(checked.lastCheckup?.warnCount, 1) + XCTAssertEqual(checked.runtimeState?.source, .doctor) + XCTAssertEqual(checked.runtimeState?.verified, false) + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(fixture.runner.calls[0].context?.profileID, profile.id) + + session.runInstallPlan(profile: checked) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: checked) + + try await waitUntilStoreState { + session.deployStore.state == .deploying + && fixture.registry.profile(id: profile.id)?.lastCheckup == nil + && session.doctorStore.summary == nil + } + fixture.runner.finishAll() + try await waitUntilStoreState { + session.deployStore.state == .deployed + && fixture.registry.profile(id: profile.id)?.lastDeployState?.status == .succeeded + && fixture.registry.profile(id: profile.id)?.runtimeState?.state == .installedVerified + } + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertNil(installed.lastCheckup) + XCTAssertEqual(installed.lastDeployState?.status, .succeeded) + XCTAssertEqual(installed.lastDeployState?.payloadFamily, "netbsd6_samba4") + XCTAssertEqual(installed.lastDeployState?.verified, true) + XCTAssertEqual(installed.runtimeState?.state, .installedVerified) + XCTAssertEqual(installed.runtimeState?.payloadFamily, "netbsd6_samba4") + XCTAssertEqual(installed.runtimeState?.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 testFailedInstallPersistsUnifiedDeployState() async throws { + let recovery: JSONValue = .object([ + "title": .string("No HFS volumes found"), + "message": .string("The device did not report a deployable HFS disk through MaSt."), + "actions": .array([]), + "action_ids": .array([]), + "retryable": .bool(true), + "suggested_operation": .string("deploy") + ]) + let failure = "No deployable HFS disk was found after 10 MaSt queries spaced 3 seconds apart." + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "read_mast"), + BackendEvent(type: "error", operation: "deploy", code: "remote_error", message: failure, recovery: recovery) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await fixture.registry.updateDeployState(testDeployState(), for: profile.id) + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: installed.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: installed) + + session.runInstallPlan(profile: installed) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: installed) + + try await waitUntilStoreState { + session.deployStore.state == .deployFailed + && fixture.registry.profile(id: profile.id)?.lastDeployState?.status == .failed + && fixture.registry.profile(id: profile.id)?.runtimeState?.state == .installFailed + } + let failed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(failed.lastDeployState?.status, .failed) + XCTAssertEqual(failed.lastDeployState?.stage, "read_mast") + XCTAssertEqual(failed.lastDeployState?.errorMessage, failure) + XCTAssertEqual(failed.lastDeployState?.recovery?.title, "No HFS volumes found") + XCTAssertEqual(failed.runtimeState?.state, .installFailed) + XCTAssertEqual(failed.runtimeState?.stage, "read_mast") + XCTAssertEqual(failed.runtimeState?.errorMessage, failure) + XCTAssertEqual(failed.runtimeState?.recovery?.title, "No HFS volumes found") + XCTAssertEqual(fixture.appStore.dashboardSummary(for: failed).displayStatus, .failed) + } + + func testSuccessfulCheckupAfterFailedInstallUpdatesDeviceRuntimeStateWithoutClearingDeployHistory() async throws { + let failure = "No deployable HFS disk was found after 10 MaSt queries spaced 3 seconds apart." + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "read_mast"), + BackendEvent(type: "error", operation: "deploy", code: "remote_error", message: failure) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) + ]) + let profile = try await 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 session = dashboard.session(for: profile) + + session.runInstallPlan(profile: profile) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: profile) + try await waitUntilStoreState { + session.deployStore.state == .deployFailed + && fixture.registry.profile(id: profile.id)?.runtimeState?.state == .installFailed + } + let failed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: failed).displayStatus, .failed) + + session.runCheckup(profile: failed) + + try await waitUntilStoreState { + session.doctorStore.state == .passed + && fixture.registry.profile(id: profile.id)?.runtimeState?.state == .installedVerified + } + let recovered = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(recovered.lastDeployState?.status, .failed) + XCTAssertEqual(recovered.lastDeployState?.errorMessage, failure) + XCTAssertEqual(recovered.runtimeState?.source, .doctor) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: recovered).displayStatus, .healthy) + } + + func testFactoryDeviceCheckupStoresNotInstalledRuntimeStateWithoutFailedSidebarStatus() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(fatal: true, checks: [ + testDoctorCheck( + status: "FAIL", + message: "deployed payload config not found; please run deploy to install on your device", + domain: "Runtime", + code: DoctorSummary.runtimeNotInstalledResultCode + ) + ])) + ]) + ]) + let profile = try await 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 session = dashboard.session(for: profile) + + session.runCheckup(profile: profile) + + try await waitUntilStoreState { + session.doctorStore.state == .failed + && fixture.registry.profile(id: profile.id)?.runtimeState?.state == .notInstalled + } + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(checked.lastCheckup?.state, .failed) + XCTAssertEqual(checked.runtimeState?.source, .doctor) + XCTAssertEqual(checked.runtimeState?.errorCode, DoctorSummary.runtimeNotInstalledResultCode) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: checked).displayStatus, .readyToInstall) + } + + func testInstallPlanDoesNotChangePersistedInstallState() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let failedState = testDeployState( + status: .failed, + startedAt: Date(timeIntervalSince1970: 120), + updatedAt: Date(timeIntervalSince1970: 120), + finishedAt: Date(timeIntervalSince1970: 120), + stage: "read_mast", + verified: nil, + summary: "", + errorCode: "remote_error", + errorMessage: "No deployable HFS disk was found." + ) + let failedRuntimeState = testRuntimeState( + state: .installFailed, + stage: "read_mast", + verified: false, + summary: "", + errorCode: "remote_error", + errorMessage: "No deployable HFS disk was found." + ) + await fixture.registry.updateDeployState(failedState, for: profile.id) + await fixture.registry.updateRuntimeState(failedRuntimeState, for: profile.id) + let failed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: failed.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: failed) + + session.runInstallPlan(profile: failed) + + try await waitUntilStoreState { session.deployStore.state == .planReady } + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.lastDeployState, failedState) + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.runtimeState, failedRuntimeState) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: failed).displayStatus, .failed) + } + + func testSuccessfulUninstallClearsInstalledSnapshot() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallResultPayload(waited: true, verified: true)) + ], pauseBeforeEvents: true) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await fixture.registry.updateDeployState(testDeployState(), for: profile.id) + await fixture.registry.updateRuntimeState(testRuntimeState(), for: profile.id) + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 130), + state: .failed, + passCount: 1, + warnCount: 0, + failCount: 1, + summary: "failed" + ), for: profile.id) + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: installed.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: installed) + + session.performMaintenanceAction(.planUninstall, profile: installed) {} + try await waitUntilStoreState { session.maintenanceStore.uninstallState == .planReady } + session.performMaintenanceAction(.runUninstall, profile: installed) {} + + try await waitUntilStoreState { + session.maintenanceStore.uninstallState == .running + && fixture.registry.profile(id: installed.id)?.lastCheckup == nil + } + fixture.runner.finishAll() + try await waitUntilStoreState { + session.maintenanceStore.uninstallState == .succeeded + && fixture.registry.profile(id: installed.id)?.lastDeployState == nil + && fixture.registry.profile(id: installed.id)?.runtimeState == nil + } + XCTAssertNil(fixture.registry.profile(id: installed.id)?.lastDeployState) + XCTAssertNil(fixture.registry.profile(id: installed.id)?.runtimeState) + XCTAssertNil(fixture.registry.profile(id: installed.id)?.lastCheckup) + XCTAssertEqual(fixture.runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(false)) + } + + func testActivationInvalidatesCheckupWhenRunStarts() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(alreadyActive: false)) + ], pauseBeforeEvents: true) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 130), + state: .failed, + passCount: 1, + warnCount: 0, + failCount: 1, + summary: "failed" + ), for: profile.id) + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: checked.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: checked) + + session.performMaintenanceAction(.planActivation, profile: checked) {} + try await waitUntilStoreState { session.maintenanceStore.activateState == .planReady } + XCTAssertNotNil(fixture.registry.profile(id: checked.id)?.lastCheckup) + + session.performMaintenanceAction(.runActivation, profile: checked) {} + + try await waitUntilStoreState { + session.maintenanceStore.activateState == .running + && fixture.registry.profile(id: checked.id)?.lastCheckup == nil + } + fixture.runner.finishAll() + try await waitUntilStoreState { session.maintenanceStore.activateState == .succeeded } + XCTAssertEqual(fixture.runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(false)) + } + + func testCheckupSnapshotUsesStartedProfileWhenSelectionChanges() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ], pauseBeforeEvents: true) + ]) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await 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) + let session = dashboard.session(for: first) + + session.runCheckup(profile: first) + fixture.appStore.select(second) + fixture.runner.finishAll() + + try await waitUntilStoreState { + session.doctorStore.state == .passed + && fixture.registry.profile(id: first.id)?.lastCheckup?.state == .passed + && fixture.registry.profile(id: first.id)?.runtimeState?.state == .installedVerified + } + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastCheckup?.state, .passed) + XCTAssertNil(fixture.registry.profile(id: first.id)?.lastDeployState) + XCTAssertEqual(fixture.registry.profile(id: first.id)?.runtimeState?.source, .doctor) + XCTAssertEqual(fixture.registry.profile(id: first.id)?.runtimeState?.verified, true) + XCTAssertEqual(fixture.registry.profile(id: first.id)?.runtimeState?.summary, "") + XCTAssertEqual(fixture.registry.profile(id: first.id)?.runtimeState?.localizedSummary, "Installed and verified by checkup.") + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastCheckup) + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeployState) + XCTAssertNil(fixture.registry.profile(id: second.id)?.runtimeState) + } + + func testSkippedSSHCheckupDoesNotMarkRuntimeInstalled() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "local checks passed", domain: "General") + ])) + ]) + ]) + let profile = try await 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 session = dashboard.session(for: profile) + session.doctorStore.skipSSH = true + + session.runCheckup(profile: profile) + + try await waitUntilStoreState { + session.doctorStore.state == .passed + && fixture.registry.profile(id: profile.id)?.lastCheckup?.state == .passed + } + XCTAssertNil(fixture.registry.profile(id: profile.id)?.lastDeployState) + XCTAssertNil(fixture.registry.profile(id: profile.id)?.runtimeState) + } + + func testDeploySnapshotUsesStartedProfileWhenSelectionChanges() async throws { + let fixture = try await 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")) + ], pauseBeforeEvents: true) + ]) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await 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) + let session = dashboard.session(for: first) + + session.runInstallPlan(profile: first) + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: first) + fixture.appStore.select(second) + fixture.runner.finishAll() + + try await waitUntilStoreState { session.deployStore.state == .deployed } + try await waitUntilStoreState { + fixture.registry.profile(id: first.id)?.lastDeployState?.status == .succeeded + && fixture.registry.profile(id: first.id)?.runtimeState?.state == .installedVerified + } + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastDeployState?.status, .succeeded) + XCTAssertEqual(fixture.registry.profile(id: first.id)?.runtimeState?.state, .installedVerified) + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeployState) + XCTAssertNil(fixture.registry.profile(id: second.id)?.runtimeState) + } + + func testPasswordLookupFailureMarksProfileMissing() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .unknown, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + + session.runCheckup(profile: profile) + + XCTAssertEqual(session.profileEditorStore.passwordError, "Password is required.") + XCTAssertEqual(session.selectedTab, .settings) + try await waitUntilStoreState { + fixture.registry.profile(id: profile.id)?.passwordState == .missing + } + } + + func testAuthFailureMarksSavedPasswordInvalid() async throws { + let fixture = try await 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 await 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) + let session = dashboard.session(for: profile) + + session.runCheckup(profile: profile) + + try await waitUntilStoreState { + fixture.registry.profile(id: profile.id)?.passwordState == .invalid + } + let updated = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(session.doctorStore.state, .runFailed) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: updated).primaryAction, .replacePassword) + } + + func testRecoveryActionsRouteToMaintenanceAndPasswordWorkflows() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + let error = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Needs recovery.") + + XCTAssertTrue(session.handleRecoveryAction( + RecoveryAction(title: "Run Disk Repair", kind: .diskRepair), + error: error, + profile: profile + )) + XCTAssertEqual(session.selectedTab, .maintenance) + XCTAssertEqual(session.maintenanceStore.selectedWorkflow, .fsck) + + XCTAssertTrue(session.handleRecoveryAction( + RecoveryAction(title: "Repair File Metadata", kind: .metadataRepair), + error: error, + profile: profile + )) + XCTAssertEqual(session.maintenanceStore.selectedWorkflow, .repairXattrs) + + XCTAssertTrue(session.handleRecoveryAction( + RecoveryAction(title: "Activate", kind: .startSMB), + error: error, + profile: profile + )) + XCTAssertEqual(session.maintenanceStore.selectedWorkflow, .activate) + + XCTAssertTrue(session.handleRecoveryAction( + RecoveryAction(title: "Replace Password", kind: .replacePassword), + error: error, + profile: profile + )) + XCTAssertEqual(session.selectedTab, .settings) + XCTAssertNil(session.profileEditorStore.passwordError) + } + + func testRecoveryRunCheckupAndInstallActionsStartBackendOperations() async throws { + let fixture = try await 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 await 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 session = dashboard.session(for: profile) + let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "Needs recovery.") + + XCTAssertTrue(session.handleRecoveryAction( + RecoveryAction(title: "Run Checkup", kind: .runCheckup), + error: error, + profile: profile + )) + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(session.selectedTab, .checkup) + + XCTAssertTrue(session.handleRecoveryAction( + RecoveryAction(title: "Install SMB", kind: .installSMB), + error: error, + profile: profile + )) + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + 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(session.selectedTab, .install) + } + + func testRecoveryRetryUsesFailedOperation() async throws { + let fixture = try await 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 await 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 session = dashboard.session(for: profile) + let doctorError = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Doctor failed.") + + XCTAssertTrue(session.handleRecoveryAction( + RecoveryAction(title: "Retry", kind: .retry), + error: doctorError, + profile: profile + )) + + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(session.selectedTab, .checkup) + } + + func testNonActionableRecoveryKindsReturnFalse() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let session = dashboard.session(for: profile) + let error = BackendErrorViewModel(operation: "validate-install", code: "operation_failed", message: "Needs diagnostics.") + + XCTAssertFalse(session.handleRecoveryAction( + RecoveryAction(title: "Open Diagnostics", kind: .diagnostics), + error: error, + profile: profile + )) + XCTAssertFalse(session.handleRecoveryAction( + RecoveryAction(title: "Unknown", kind: .generic), + error: error, + profile: profile + )) + } + + func testInstallCompletionActionsRunThroughSession() async throws { + let fixture = try await 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 await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let opener = RecordingURLOpener() + let session = DeviceDashboardSession(profile: profile, appStore: fixture.appStore, urlOpener: opener) + var diagnosticsShown = false + + session.performInstallAction(.openFinder, profile: profile) { + diagnosticsShown = true + } + XCTAssertEqual(opener.openedURLs.map(\.absoluteString), ["smb://10.0.0.2"]) + XCTAssertFalse(diagnosticsShown) + + session.performInstallAction(.runCheckup, profile: profile) { + diagnosticsShown = true + } + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(session.selectedTab, .checkup) + + session.performInstallAction(.reinstall, profile: profile) { + diagnosticsShown = true + } + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !self.deviceLaneIsRunning(profile, appStore: fixture.appStore) } + XCTAssertEqual(fixture.runner.calls[1].operation, "deploy") + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(session.selectedTab, .install) + + session.performInstallAction(.viewDiagnostics, profile: profile) { + diagnosticsShown = true + } + XCTAssertTrue(diagnosticsShown) + } + + func testForgetProfileDeletesRegistryConfigDirectoryAndPassword() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await 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 await 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 deviceLaneIsRunning(_ profile: DeviceProfile, appStore: AppStore) -> Bool { + appStore.operationCoordinator.isDeviceBusy(profile) + } + + private func makeFixture(responses: [StoreTestRunner.Response]) async throws -> ( + appStore: AppStore, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore, + runner: PausingStoreTestRunner + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let runner = PausingStoreTestRunner(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) + } +} + +private final class RecordingURLOpener: URLOpening { + private(set) var openedURLs: [URL] = [] + + func open(_ url: URL) { + openedURLs.append(url) + } +} + +private struct StaticSMBAccountResolver: SMBAccountResolving { + let accounts: [DeviceProfile.ID: String] + + func account(for profile: DeviceProfile) -> String? { + accounts[profile.id] + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift new file mode 100644 index 00000000..8f374eee --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -0,0 +1,571 @@ +import Combine +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, "mount_wait_invalid") + 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.noWait = true + store.nbnsEnabled = false + store.internalShareUseDiskRoot = true + store.anyProtocol = true + store.debugLogging = true + store.ataIdleSeconds = "0" + store.ataStandby = "0" + + 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(false)) + XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["nbns_enabled"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["any_protocol"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["debug_logging"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(runner.calls[0].params["ata_standby"], .number(0)) + XCTAssertEqual(runner.calls[0].params["mount_wait"], .number(45)) + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + } + + func testPublishesWhenBackendFinishesAfterPlanResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + let finishPublished = expectation(description: "DeployWorkflowStore publishes after backend running state clears") + var didFulfill = false + var cancellables: Set = [] + store.objectWillChange + .sink { [weak store] _ in + Task { @MainActor in + guard !didFulfill, + store?.state == .planReady, + store?.isBusy == false else { + return + } + didFulfill = true + finishPublished.fulfill() + } + } + .store(in: &cancellables) + + store.runPlan(password: "pw") + + try await waitUntilStoreState { store.state == .planReady } + await fulfillment(of: [finishPublished], timeout: 2) + XCTAssertFalse(store.isBusy) + _ = cancellables + } + + func testInvalidAtaOptionsMoveToPlanFailedWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.ataIdleSeconds = "bad" + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "ata_idle_seconds_invalid") + XCTAssertEqual(store.error?.message, "ATA idle seconds must be a non-negative integer.") + XCTAssertEqual(runner.calls, []) + + store.ataIdleSeconds = "300" + store.ataStandby = "bad" + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "ata_standby_invalid") + XCTAssertEqual(store.error?.message, "ATA standby seconds must be blank or a non-negative integer.") + XCTAssertEqual(runner.calls, []) + } + + func testNoRebootAndNoWaitAreMutuallyExclusive() 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: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.noWait = true + + XCTAssertTrue(store.noWait) + XCTAssertFalse(store.noReboot) + XCTAssertFalse(DeployExecutionOptionPolicy.allowsNoReboot(noWait: store.noWait)) + XCTAssertTrue(DeployExecutionOptionPolicy.allowsNoWait(noReboot: store.noReboot)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + + XCTAssertEqual(runner.calls[0].params["no_reboot"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(true)) + + store.noReboot = true + + XCTAssertTrue(store.noReboot) + XCTAssertFalse(store.noWait) + XCTAssertTrue(DeployExecutionOptionPolicy.allowsNoReboot(noWait: store.noWait)) + XCTAssertFalse(DeployExecutionOptionPolicy.allowsNoWait(noReboot: store.noReboot)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { runner.calls.count == 2 && store.state == .planReady } + + XCTAssertEqual(runner.calls[1].params["no_reboot"], .bool(true)) + XCTAssertEqual(runner.calls[1].params["no_wait"], .bool(false)) + } + + func testRejectedPlanDoesNotEnterPlanning() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], pauseBeforeEvents: true) + ]) + 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 && coordinator.backend.isRunning } + let result = store.runPlan(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "operation_already_running") + XCTAssertEqual(runner.calls.count, 1) + runner.finishAll() + 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, "deploy_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.internalShareUseDiskRoot = true + + XCTAssertEqual(store.state, .planStale) + XCTAssertFalse(store.canDeploy) + + store.internalShareUseDiskRoot = false + + XCTAssertEqual(store.state, .planReady) + XCTAssertTrue(store.canDeploy) + } + + func testOptionChangeWhilePlanningMakesReturnedPlanStaleAndAllowsRegeneration() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ], pauseBeforeEvents: true), + .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 { runner.calls.count == 1 } + XCTAssertEqual(store.state, .planning) + + store.noWait = true + + XCTAssertEqual(store.state, .planning) + XCTAssertFalse(store.canDeploy) + XCTAssertNil(store.plan) + runner.finishAll() + + try await waitUntilStoreState { store.state == .planStale } + XCTAssertNotNil(store.plan) + XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(false)) + try await waitUntilStoreState { !store.isBusy } + + store.runPlan(password: "pw") + + try await waitUntilStoreState { store.state == .planReady && runner.calls.count == 2 } + XCTAssertTrue(store.canDeploy) + XCTAssertEqual(runner.calls[1].params["no_wait"], .bool(true)) + } + + func testDefaultRuntimeOverridesAreSentExplicitlyInPlanParams() 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 } + + XCTAssertEqual(runner.calls[0].params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["any_protocol"], .bool(false)) + } + + 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.internalShareUseDiskRoot = true + store.anyProtocol = true + + 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["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(runner.calls[1].params["any_protocol"], .bool(true)) + XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) + } + + func testDeployCannotRunAgainDirectlyFromDeployedState() 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: true, payload: deployResultPayload()) + ]) + ]) + 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 == .deployed } + + let result = store.runDeploy(password: "pw2") + + XCTAssertEqual(result.rejectionMessage, "Deploy plan is not ready.") + XCTAssertEqual(store.state, .deployed) + XCTAssertEqual(runner.calls.count, 2) + } + + func testReinstallCreatesFreshPlanFromDeployedState() 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: true, payload: deployResultPayload()) + ]), + .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.runDeploy(password: "pw") + try await waitUntilStoreState { store.state == .deployed } + + store.noWait = true + store.runPlan(password: "pw2") + + try await waitUntilStoreState { store.state == .planReady && runner.calls.count == 3 } + XCTAssertNil(store.result) + XCTAssertEqual(runner.calls[2].operation, "deploy") + XCTAssertEqual(runner.calls[2].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[2].params["no_wait"], .bool(true)) + XCTAssertEqual(runner.calls[2].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.isRunning } + + 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 testCancellingDeployConfirmationRestoresReadyPlan() 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(["confirmation_id": .string("confirm-1")]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + 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.isRunning } + + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.state == .planReady && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + XCTAssertTrue(store.canDeploy) + XCTAssertNotNil(store.plan) + XCTAssertEqual(runner.calls.count, 2) + } + + func testCancellingDeployConfirmationRestoresStalePlanWhenOptionsChanged() 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(["confirmation_id": .string("confirm-1")]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + 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.isRunning } + + store.noWait = true + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.state == .planStale && backend.pendingConfirmation == nil } + XCTAssertNil(store.error) + XCTAssertFalse(store.canDeploy) + XCTAssertNotNil(store.plan) + XCTAssertEqual(runner.calls.count, 2) + } + + 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), + "startup_mode": .string("reboot_then_verify"), + "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/DeviceDashboardSnapshotMapperTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDashboardSnapshotMapperTests.swift new file mode 100644 index 00000000..1c202289 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDashboardSnapshotMapperTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DeviceDashboardSnapshotMapperTests: XCTestCase { + func testPassedCheckupMapsRuntimeToInstalledVerified() throws { + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + let summary = try makeDoctorSummary(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ]) + + let runtimeState = DeviceDashboardSnapshotMapper.runtimeStateFromCheckup( + profile: profile, + skipSSH: false, + state: .passed, + summary: summary + ) + + XCTAssertEqual(runtimeState?.state, .installedVerified) + XCTAssertEqual(runtimeState?.source, .doctor) + XCTAssertEqual(runtimeState?.payloadFamily, "netbsd6_samba4") + XCTAssertEqual(runtimeState?.verified, true) + } + + func testSkippedSSHCheckupDoesNotInventRuntimeState() throws { + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + let summary = try makeDoctorSummary(checks: [ + testDoctorCheck(status: "PASS", message: "local checks passed", domain: "General") + ]) + + let runtimeState = DeviceDashboardSnapshotMapper.runtimeStateFromCheckup( + profile: profile, + skipSSH: true, + state: .passed, + summary: summary + ) + + XCTAssertNil(runtimeState) + } + + func testWarningCheckupKeepsNetBSD4InstalledRuntimeActivationNeeded() throws { + var profile = try makeProfile(payloadFamily: "netbsd4_samba4") + profile.runtimeState = testRuntimeState( + state: .installedVerified, + payloadFamily: "netbsd4_samba4", + verified: true + ) + let summary = try makeDoctorSummary(checks: [ + testDoctorCheck(status: "WARN", message: "activation required after reboot", domain: "Runtime") + ]) + + let runtimeState = DeviceDashboardSnapshotMapper.runtimeStateFromCheckup( + profile: profile, + skipSSH: false, + state: .warning, + summary: summary + ) + + XCTAssertEqual(runtimeState?.state, .activationNeeded) + XCTAssertEqual(runtimeState?.source, .doctor) + XCTAssertEqual(runtimeState?.payloadFamily, "netbsd4_samba4") + XCTAssertEqual(runtimeState?.verified, false) + } + + private func makeDoctorSummary(checks: [JSONValue]) throws -> DoctorSummary { + DoctorSummary(payload: try testDoctorPayload(checks: checks).decode(DoctorPayload.self)) + } + + private func makeProfile( + id: String = "device-one", + host: String = "10.0.0.2", + payloadFamily: String + ) throws -> DeviceProfile { + DeviceProfile.make( + id: id, + configuredDevice: try testConfiguredDevice(host: host, payloadFamily: payloadFamily), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDashboardViewSmokeTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDashboardViewSmokeTests.swift new file mode 100644 index 00000000..2274aca2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDashboardViewSmokeTests.swift @@ -0,0 +1,264 @@ +import SwiftUI +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceDashboardViewSmokeTests: XCTestCase { + func testRendersEveryDashboardTabInIdleState() async throws { + let fixture = try await AppViewFixture() + let profile = try await fixture.saveProfile(id: "device-one") + let session = fixture.dashboardSession(for: profile) + + for tab in DeviceDashboardTab.allCases { + session.selectedTab = tab + try assertRendersNonBlank(dashboardView(fixture: fixture, profile: profile, session: session)) + } + } + + func testRendersInstallPlanningPlanReadyDeployingConfirmationFailedAndCompletedStates() async throws { + try await renderInstallState( + responses: [ + .init( + events: [BackendEvent(type: "stage", operation: "deploy", stage: "build_deployment_plan")], + pauseAfterEvents: true + ) + ], + expectedState: .planning, + runDeploy: false + ) + try await renderInstallState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]) + ], + expectedState: .planReady, + runDeploy: false + ) + try await renderInstallState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]), + .init( + events: [BackendEvent(type: "stage", operation: "deploy", stage: "upload_smbd")], + pauseAfterEvents: true + ) + ], + expectedState: .deploying, + runDeploy: true + ) + try await renderInstallState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Deployment needs confirmation." + ) + ]) + ], + expectedState: .awaitingConfirmation, + runDeploy: true + ) + try await renderInstallState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "error", operation: "deploy", code: "remote_error", message: "Upload failed.") + ]) + ], + expectedState: .deployFailed, + runDeploy: true + ) + try await renderInstallState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ]) + ], + expectedState: .deployed, + runDeploy: true + ) + } + + func testRendersCheckupRunningPassedWarningFailedAndRunFailedStates() async throws { + try await renderCheckupState( + responses: [ + .init( + events: [BackendEvent(type: "stage", operation: "doctor", stage: "run_checks")], + pauseAfterEvents: true + ) + ], + expectedState: .running + ) + try await renderCheckupState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) + ], + expectedState: .passed + ) + try await renderCheckupState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "WARN", message: "SMB needs attention", domain: "Runtime") + ])) + ]) + ], + expectedState: .warning + ) + try await renderCheckupState( + responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: false, payload: testDoctorPayload(fatal: true, checks: [ + testDoctorCheck(status: "FAIL", message: "smbd is not running", domain: "Runtime") + ])) + ]) + ], + expectedState: .failed + ) + try await renderCheckupState( + responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "doctor", code: "auth_failed", message: "Password rejected.") + ]) + ], + expectedState: .runFailed + ) + } + + func testRendersMaintenanceWorkflowIdleAndResultStates() async throws { + let idleFixture = try await AppViewFixture() + let idleProfile = try await idleFixture.saveProfile(id: "device-one") + let idleSession = idleFixture.dashboardSession(for: idleProfile) + idleSession.selectedTab = .maintenance + for workflow in MaintenanceWorkflow.allCases { + idleSession.maintenanceStore.selectedWorkflow = workflow + try assertRendersNonBlank(dashboardView(fixture: idleFixture, profile: idleProfile, session: idleSession)) + } + + let activation = try await AppViewFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]) + ]) + let activationProfile = try await activation.saveProfile(id: "activation-device") + let activationSession = activation.dashboardSession(for: activationProfile) + activationSession.maintenanceStore.planActivation(password: "pw", profile: activationProfile) + try await waitUntilStoreState { activationSession.maintenanceStore.activateState == .planReady } + activationSession.selectedTab = .maintenance + try assertRendersNonBlank(dashboardView(fixture: activation, profile: activationProfile, session: activationSession)) + + let fsck = try await AppViewFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [ + testFsckTargetPayload(name: "Data") + ])) + ]) + ]) + let fsckProfile = try await fsck.saveProfile(id: "fsck-device") + let fsckSession = fsck.dashboardSession(for: fsckProfile) + fsckSession.maintenanceStore.refreshFsckTargets(password: "pw", profile: fsckProfile) + try await waitUntilStoreState { fsckSession.maintenanceStore.fsckState == .listReady } + fsckSession.selectedTab = .maintenance + try assertRendersNonBlank(dashboardView(fixture: fsck, profile: fsckProfile, session: fsckSession)) + + let repair = try await AppViewFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]) + ]) + let repairProfile = try await repair.saveProfile(id: "repair-device") + let repairSession = repair.dashboardSession(for: repairProfile) + repairSession.maintenanceStore.repairPath = "/Volumes/Data" + repairSession.maintenanceStore.scanRepairXattrs() + try await waitUntilStoreState { repairSession.maintenanceStore.repairState == .scanReady } + repairSession.selectedTab = .maintenance + try assertRendersNonBlank(dashboardView(fixture: repair, profile: repairProfile, session: repairSession)) + } + + func testRendersSettingsPasswordReplacementAttention() async throws { + let fixture = try await AppViewFixture() + let profile = try await fixture.saveProfile(id: "device-one", passwordState: .missing, password: nil) + let session = fixture.dashboardSession(for: profile) + + session.runCheckup(profile: profile) + + XCTAssertEqual(session.selectedTab, .settings) + try assertRendersNonBlank(dashboardView(fixture: fixture, profile: profile, session: session)) + } + + private func renderInstallState( + responses: [StoreTestRunner.Response], + expectedState: DeployWorkflowState, + runDeploy: Bool + ) async throws { + let runner = PausingStoreTestRunner(responses: responses) + let fixture = try await AppViewFixture(runner: runner) + let profile = try await fixture.saveProfile(id: "device-one") + let session = fixture.dashboardSession(for: profile) + + session.runInstallPlan(profile: profile) + if runDeploy { + try await waitUntilStoreState { session.deployStore.state == .planReady } + session.runInstall(profile: profile) + } + if expectedState != .planning && expectedState != .deploying { + try await waitUntilStoreState { session.deployStore.state == expectedState } + } + + XCTAssertEqual(session.deployStore.state, expectedState) + try assertRendersNonBlank(dashboardView(fixture: fixture, profile: profile, session: session)) + runner.finishAll() + } + + private func renderCheckupState( + responses: [StoreTestRunner.Response], + expectedState: DoctorWorkflowState + ) async throws { + let runner = PausingStoreTestRunner(responses: responses) + let fixture = try await AppViewFixture(runner: runner) + let profile = try await fixture.saveProfile(id: "device-one") + let session = fixture.dashboardSession(for: profile) + + session.runCheckup(profile: profile) + if expectedState != .running { + try await waitUntilStoreState { session.doctorStore.state == expectedState } + } + + XCTAssertEqual(session.doctorStore.state, expectedState) + try assertRendersNonBlank(dashboardView(fixture: fixture, profile: profile, session: session)) + runner.finishAll() + } + + private func dashboardView( + fixture: AppViewFixture, + profile: DeviceProfile, + session: DeviceDashboardSession + ) -> some View { + DeviceDashboardView( + profile: profile, + session: session, + appStore: fixture.appStore, + appSettingsStore: fixture.appStore.appSettingsStore, + reachabilityStore: fixture.appStore.reachabilityStore, + operationCoordinator: fixture.appStore.operationCoordinator, + backend: fixture.appStore.backend, + showDiagnostics: {} + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryStoreTests.swift new file mode 100644 index 00000000..615391bf --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceDiscoveryStoreTests.swift @@ -0,0 +1,257 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceDiscoveryStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual( + DeviceDiscoveryState.allCases, + [.idle, .waitingForReadiness, .discovering, .empty, .ready, .paused, .readinessBlocked, .failed] + ) + } + + func testWaitsForReadinessThenDiscoversWithoutSavingProfiles() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload())]), + .init(events: [BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload())]), + .init(events: [ + BackendEvent(type: "stage", operation: "discover", stage: "bonjour_discovery"), + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord() + ])) + ]) + ]) + + fixture.monitor.startMonitoring() + XCTAssertEqual(fixture.monitor.state, .waitingForReadiness) + fixture.readiness.start() + + try await waitUntilStoreState { fixture.monitor.state == .ready } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["capabilities", "validate-install", "discover"]) + XCTAssertEqual(fixture.monitor.devices.map(\.host), ["10.0.0.2"]) + XCTAssertEqual(fixture.monitor.unsavedDevices.count, 1) + XCTAssertTrue(fixture.registry.profiles.isEmpty) + XCTAssertEqual(fixture.monitor.currentStage?.stage, "bonjour_discovery") + } + + func testDiscoveryEmptyFailedAndMalformedPayloadStatesAreExplicit() async throws { + let empty = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: []))]) + ]) + empty.monitor.startMonitoring() + try await waitUntilStoreState { empty.monitor.state == .empty } + XCTAssertEqual(empty.monitor.devices, []) + + let failed = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent.error(operation: "discover", code: "bonjour_failed", message: "Bonjour failed.")]) + ]) + failed.monitor.startMonitoring() + try await waitUntilStoreState { failed.monitor.state == .failed } + XCTAssertEqual(failed.monitor.error?.code, "bonjour_failed") + + let malformed = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: .object(["schema_version": .string("wrong")]))]) + ]) + malformed.monitor.startMonitoring() + try await waitUntilStoreState { malformed.monitor.state == .failed } + XCTAssertEqual(malformed.monitor.error?.code, "contract_decode_failed") + } + + func testSavedProfilesAreFilteredAndReportedAsSeenNow() async throws { + let fixture = try await makeReadyFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord() + ]))]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + + fixture.monitor.startMonitoring() + + try await waitUntilStoreState { fixture.monitor.state == .ready } + XCTAssertEqual(fixture.monitor.unsavedDevices, []) + XCTAssertEqual(fixture.monitor.savedDevices.map(\.host), ["10.0.0.2"]) + XCTAssertEqual(fixture.monitor.lastSeenText(for: profile), "Seen now") + } + + func testRefreshPausesBehindActiveOperationAndResumesWhenRunnerIsFree() async throws { + let fixture = try await makeReadyFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ok", domain: "Runtime") + ])) + ], pauseAfterEvents: true), + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord(hostname: "paused.local.") + ]))]) + ]) + + fixture.coordinator.run(operation: "doctor", params: [:], profile: nil) + fixture.monitor.startMonitoring() + + XCTAssertEqual(fixture.monitor.state, .paused) + fixture.runner.finishAll() + try await waitUntilStoreState { fixture.monitor.state == .ready } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["capabilities", "validate-install", "doctor", "discover"]) + } + + func testDeviceOperationDoesNotPauseAppDiscoveryRefresh() async throws { + let fixture = try await makeReadyFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ok", domain: "Runtime") + ])) + ], pauseAfterEvents: true), + .init(events: [BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord(hostname: "parallel.local.") + ]))]) + ]) + let context = DeviceRuntimeContext( + profileID: "device-one", + configURL: URL(fileURLWithPath: "/tmp/device-one/.env") + ) + + fixture.coordinator.run( + operation: "doctor", + context: context, + activeDeviceID: "device-one", + laneKey: .device("device-one") + ) + try await waitUntilStoreState { + fixture.coordinator.lane(for: .device("device-one")).backend.isRunning && + fixture.runner.calls.map(\.operation) == ["capabilities", "validate-install", "doctor"] + } + fixture.monitor.startMonitoring() + + XCTAssertNotEqual(fixture.monitor.state, .paused) + try await waitUntilStoreState { fixture.runner.calls.count == 4 } + XCTAssertEqual(fixture.runner.calls.map(\.operation), ["capabilities", "validate-install", "doctor", "discover"]) + try await waitUntilStoreState { fixture.monitor.state == .ready } + fixture.runner.finishAll() + } + + func testReadinessBlockedPreventsDiscovery() async throws { + let temp = try TemporaryDirectory() + let runner = StoreTestRunner(responses: []) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let readiness = AppReadinessStore( + backend: backend, + runtimeResolver: DiscoveryMonitorTestRuntimeResolver(issues: [ + BundleRuntimeIssue( + code: .distributionRootMissing, + severity: .error, + message: "missing distribution", + recovery: "reinstall" + ) + ]), + helperPathProvider: { "" } + ) + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let monitor = DeviceDiscoveryStore(coordinator: coordinator, readinessStore: readiness, registry: registry) + + monitor.startMonitoring() + readiness.start() + + try await waitUntilStoreState { monitor.state == .readinessBlocked } + XCTAssertEqual(monitor.state, .readinessBlocked) + XCTAssertEqual(runner.calls, []) + } + + private struct Fixture { + let runner: PausingStoreTestRunner + let coordinator: OperationCoordinator + let readiness: AppReadinessStore + let registry: DeviceRegistryStore + let monitor: DeviceDiscoveryStore + } + + private func makeFixture(responses: [StoreTestRunner.Response]) async throws -> Fixture { + let temp = try TemporaryDirectory() + let runner = PausingStoreTestRunner(responses: responses) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let readiness = AppReadinessStore( + backend: backend, + runtimeResolver: DiscoveryMonitorTestRuntimeResolver(), + helperPathProvider: { "" } + ) + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let monitor = DeviceDiscoveryStore(coordinator: coordinator, readinessStore: readiness, registry: registry) + return Fixture( + runner: runner, + coordinator: coordinator, + readiness: readiness, + registry: registry, + monitor: monitor + ) + } + + private func makeReadyFixture(responses: [StoreTestRunner.Response]) async throws -> Fixture { + let fixture = try await makeFixture(responses: [ + .init(events: [BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload())]), + .init(events: [BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload())]) + ] + responses) + fixture.readiness.start() + try await waitUntilStoreState { fixture.readiness.state.kind == .ready } + return fixture + } + + 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("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() -> JSONValue { + .object([ + "schema_version": .number(1), + "ok": .bool(true), + "checks": .array([ + .object([ + "id": .string("distribution_root"), + "ok": .bool(true), + "message": .string("distribution root is valid") + ]) + ]), + "counts": .object([ + "checks": .number(1), + "pass": .number(1), + "fail": .number(0) + ]), + "summary": .string("Install validation passed.") + ]) + } +} + +private struct DiscoveryMonitorTestRuntimeResolver: AppRuntimeResolving { + var issues: [BundleRuntimeIssue] = [] + + func resolve(helperPath: String?) throws -> HelperResolution { + 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/DeviceEndpointPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceEndpointPolicyTests.swift new file mode 100644 index 00000000..9a964e00 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceEndpointPolicyTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DeviceEndpointPolicyTests: XCTestCase { + func testAddressFamilyParsesIPLiteralForms() { + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "10.0.0.2"), .ipv4) + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "fd00::2"), .ipv6) + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "[fd00::2]"), .ipv6) + XCTAssertEqual(DeviceEndpointPolicy.addressFamily(for: "fe80::1%en0"), .ipv6) + XCTAssertNil(DeviceEndpointPolicy.addressFamily(for: "capsule.local")) + } + + func testHostComponentNormalizesUserURLAndIPv6Wrappers() { + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("root@10.0.0.2"), "10.0.0.2") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("root@[fd00::2]"), "fd00::2") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("smb://admin@capsule.local/share"), "capsule.local") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent(" capsule.local. "), "capsule.local") + } + + func testHostComponentStripsPortsWithoutBreakingIPv6Literals() { + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("root@10.0.0.2:22"), "10.0.0.2") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("capsule.local:445"), "capsule.local") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("smb://admin@capsule.local:445/share"), "capsule.local") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("root@[fd00::2]:22"), "fd00::2") + XCTAssertEqual(DeviceEndpointPolicy.hostComponent("fd00::2"), "fd00::2") + } + + func testRootSSHTargetCanonicalizesDefaultPortButPreservesUnsupportedPortsForBackendValidation() { + XCTAssertEqual(DeviceEndpointPolicy.rootSSHTarget("10.0.0.2:22"), "root@10.0.0.2") + XCTAssertEqual(DeviceEndpointPolicy.rootSSHTarget("admin@capsule.local:22"), "admin@capsule.local") + XCTAssertEqual(DeviceEndpointPolicy.rootSSHTarget("root@[fd00::2]:22"), "root@fd00::2") + XCTAssertEqual(DeviceEndpointPolicy.rootSSHTarget("10.0.0.2:2222"), "root@10.0.0.2:2222") + XCTAssertEqual(DeviceEndpointPolicy.rootSSHTarget("[fd00::2]:2222"), "root@[fd00::2]:2222") + } + + func testNormalizedHostKeyTreatsEquivalentTargetsAsEqual() { + XCTAssertEqual( + DeviceEndpointPolicy.normalizedHostKey("root@10.0.0.2"), + DeviceEndpointPolicy.normalizedHostKey("10.0.0.2") + ) + XCTAssertEqual( + DeviceEndpointPolicy.normalizedHostKey("CAPSULE.local."), + DeviceEndpointPolicy.normalizedHostKey("capsule.local") + ) + XCTAssertEqual( + DeviceEndpointPolicy.normalizedHostKey("root@capsule.local:445"), + DeviceEndpointPolicy.normalizedHostKey("capsule.local") + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift new file mode 100644 index 00000000..4ed354d4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileEditorStoreTests.swift @@ -0,0 +1,506 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceProfileEditorStoreTests: XCTestCase { + func testStateAndValidationInventoriesAreExplicit() { + XCTAssertEqual(DeviceProfileEditorState.allCases.map(\.rawValue), [ + "clean", + "dirty", + "invalid", + "saving", + "reconfiguring", + "saved", + "authFailed", + "unsupported", + "failed" + ]) + XCTAssertEqual(DeviceProfileEditorValidationError.allCases.map(\.rawValue), [ + "hostRequired", + "duplicateHost", + "mountWaitInvalid", + "ataIdleSecondsInvalid", + "ataStandbyInvalid", + "passwordRequired" + ]) + } + + func testIntegerSettingValidationAcceptsZeroAndPositiveIntegersOnly() throws { + var draft = DeviceProfileEditorDraft( + displayName: "Office", + host: "10.0.0.2", + nbnsEnabled: true, + debugLogging: false, + mountWaitSeconds: "0" + ) + XCTAssertEqual(try draft.validatedSettings().mountWaitSeconds, 0) + + draft.mountWaitSeconds = "45" + XCTAssertEqual(try draft.validatedSettings().mountWaitSeconds, 45) + + for invalid in ["", "-1", "1.5", "abc"] { + draft.mountWaitSeconds = invalid + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? DeviceProfileEditorValidationError, .mountWaitInvalid) + } + } + + draft.mountWaitSeconds = "45" + draft.ataIdleSeconds = "0" + XCTAssertEqual(try draft.validatedSettings().ataIdleSeconds, 0) + draft.ataIdleSeconds = "300" + XCTAssertEqual(try draft.validatedSettings().ataIdleSeconds, 300) + for invalid in ["", "-1", "1.5", "abc"] { + draft.ataIdleSeconds = invalid + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? DeviceProfileEditorValidationError, .ataIdleSecondsInvalid) + } + } + + draft.ataIdleSeconds = "300" + draft.ataStandby = "" + XCTAssertNil(try draft.validatedSettings().ataStandby) + draft.ataStandby = "0" + XCTAssertEqual(try draft.validatedSettings().ataStandby, 0) + for invalid in ["-1", "1.5", "abc"] { + draft.ataStandby = invalid + XCTAssertThrowsError(try draft.validatedSettings()) { error in + XCTAssertEqual(error as? DeviceProfileEditorValidationError, .ataStandbyInvalid) + } + } + } + + func testUndoingDraftChangeReturnsEditorToCleanState() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + + store.draft.nbnsEnabled.toggle() + + XCTAssertEqual(store.state, .dirty) + XCTAssertTrue(store.canSave) + + store.draft.nbnsEnabled.toggle() + + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + } + + func testCleanEditorSyncsToUpdatedProfileBaseline() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + var updatedProfile = profile + updatedProfile.displayName = "Renamed Capsule" + + store.sync(to: updatedProfile) + + XCTAssertEqual(store.draft.displayName, "Renamed Capsule") + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + } + + func testUnchangedHostSaveUpdatesProfileSettingsWithoutBackendConfigure() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.displayName = "Media Capsule" + store.draft.nbnsEnabled = false + store.draft.internalShareUseDiskRoot = true + store.draft.anyProtocol = true + store.draft.debugLogging = true + store.draft.mountWaitSeconds = "45" + store.draft.ataIdleSeconds = "0" + store.draft.ataStandby = "0" + + await store.save(profile: profile) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(saved.displayName, "Media Capsule") + XCTAssertEqual(saved.host, "root@10.0.0.2") + XCTAssertEqual(saved.settings, DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 45, + ataIdleSeconds: 0, + ataStandby: 0 + )) + XCTAssertEqual(fixture.runner.calls, []) + } + + func testEquivalentHostEditDoesNotRunBackendConfigure() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.host = " 10.0.0.2 " + store.draft.displayName = "Media Capsule" + + await store.save(profile: profile) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(saved.host, "root@10.0.0.2") + XCTAssertEqual(saved.displayName, "Media Capsule") + XCTAssertEqual(fixture.runner.calls, []) + } + + func testPasswordOnlySaveUpdatesKeychainAndClearsDraft() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + XCTAssertFalse(store.canSave) + store.replacementPassword = " " + XCTAssertFalse(store.canSave) + XCTAssertEqual(store.state, .clean) + + store.replacementPassword = "new-password" + XCTAssertTrue(store.canSave) + + await store.save(profile: profile) + + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(store.replacementPassword, "") + XCTAssertNil(store.passwordError) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "new-password") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .available) + XCTAssertEqual(fixture.runner.calls, []) + } + + func testPasswordSaveFailureKeepsDraftAndDoesNotMarkAvailable() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + fixture.passwordStore.saveFailure = .save + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + store.replacementPassword = "new-password" + + await store.save(profile: profile) + + XCTAssertEqual(store.state, .failed) + XCTAssertEqual(store.replacementPassword, "new-password") + XCTAssertEqual(store.passwordError, "In-memory password store save failed.") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + } + + func testResetClearsPendingProfileAndPasswordChanges() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.nbnsEnabled.toggle() + store.replacementPassword = "new-password" + XCTAssertTrue(store.canSave) + + store.reset(to: profile) + + XCTAssertEqual(store.state, .clean) + XCTAssertFalse(store.canSave) + XCTAssertEqual(store.replacementPassword, "") + XCTAssertNil(store.passwordError) + XCTAssertEqual(store.draft, DeviceProfileEditorDraft(profile: profile)) + } + + func testBlankDisplayNameIsAllowedAndFallsBackThroughTitlePolicy() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "TimeCapsule8,119"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.displayName = "" + + await store.save(profile: profile) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(store.state, .saved) + XCTAssertEqual(saved.displayName, "") + XCTAssertEqual(saved.title, "TimeCapsule8,119") + } + + func testInvalidHostDuplicateHostAndInvalidMountWaitSaveNothing() async throws { + let fixture = try await makeFixture(responses: []) + let first = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + _ = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "root@10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + let store = DeviceProfileEditorStore(profile: first, appStore: fixture.appStore) + + store.draft.host = " " + await store.save(profile: first) + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.hostRequired]) + + store.draft.host = "10.0.0.3" + await store.save(profile: first) + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.duplicateHost]) + + store.draft.host = first.host + store.draft.mountWaitSeconds = "bad" + store.draft.ataIdleSeconds = "also-bad" + store.draft.ataStandby = "still-bad" + await store.save(profile: first) + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.mountWaitInvalid, .ataIdleSecondsInvalid, .ataStandbyInvalid]) + XCTAssertEqual(fixture.runner.calls, []) + } + + func testChangedHostRequiresSavedPassword() async throws { + let fixture = try await makeFixture(responses: []) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.host = "10.0.0.9" + await store.save(profile: profile) + + XCTAssertEqual(store.state, .invalid) + XCTAssertEqual(store.validationErrors, [.passwordRequired]) + XCTAssertEqual(fixture.runner.calls, []) + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.host, "10.0.0.2") + } + + func testChangedHostUsesReplacementPasswordWhenSavedPasswordIsMissing() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: true, + payload: testConfigurePayload(host: "root@10.0.0.9", syap: "119", model: "TimeCapsule8,119") + ) + ]) + ]) + let profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.host = "10.0.0.9" + store.replacementPassword = "new-password" + + await store.save(profile: profile) + + try await waitUntilStoreState { store.state == .saved } + let call = try XCTUnwrap(fixture.runner.calls.first) + XCTAssertEqual(call.operation, "configure") + XCTAssertEqual(call.params["password"], .string("new-password")) + XCTAssertEqual(store.replacementPassword, "") + XCTAssertNil(store.passwordError) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "new-password") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .available) + } + + func testChangedHostRunsConfigureWithExistingProfileContextAndPreservesProfileData() async throws { + let fixture = try await makeFixture(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: true, + payload: testConfigurePayload(host: "root@10.0.0.9", syap: "119", model: "TimeCapsule8,119") + ) + ]) + ]) + var profile = try await fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 1, + warnCount: 0, + failCount: 0, + summary: "healthy" + ), for: profile.id) + await fixture.registry.updateDeployState(testDeployState( + startedAt: Date(timeIntervalSince1970: 110), + updatedAt: Date(timeIntervalSince1970: 110), + finishedAt: Date(timeIntervalSince1970: 110) + ), for: profile.id) + await fixture.registry.updateRuntimeState(testRuntimeState(summary: "Install completed."), for: profile.id) + profile = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let store = DeviceProfileEditorStore(profile: profile, appStore: fixture.appStore) + + store.draft.displayName = "Updated Capsule" + store.draft.host = "10.0.0.9" + store.draft.nbnsEnabled = false + store.draft.internalShareUseDiskRoot = true + store.draft.anyProtocol = true + store.draft.debugLogging = true + store.draft.mountWaitSeconds = "60" + store.draft.ataIdleSeconds = "0" + store.draft.ataStandby = "0" + + await store.save(profile: profile) + + try await waitUntilStoreState { store.state == .saved } + let call = try XCTUnwrap(fixture.runner.calls.first) + XCTAssertEqual(call.operation, "configure") + XCTAssertEqual(call.context?.profileID, profile.id) + guard case .string(let stagedConfigPath)? = call.params["config"] else { + return XCTFail("Expected staged config path.") + } + XCTAssertNotEqual(stagedConfigPath, profile.configPath) + XCTAssertTrue(stagedConfigPath.contains("/.Staging/")) + XCTAssertTrue(FileManager.default.fileExists(atPath: profile.configPath)) + XCTAssertEqual(call.params["host"], .string("root@10.0.0.9")) + XCTAssertEqual(call.params["password"], .string("pw")) + XCTAssertEqual(call.params["persist_password"], .bool(false)) + XCTAssertEqual(call.params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(call.params["any_protocol"], .bool(true)) + XCTAssertEqual(call.params["debug_logging"], .bool(true)) + XCTAssertEqual(call.params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(call.params["ata_standby"], .number(0)) + + let saved = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(saved.id, profile.id) + XCTAssertEqual(saved.keychainAccount, profile.keychainAccount) + XCTAssertEqual(saved.displayName, "Updated Capsule") + XCTAssertEqual(saved.host, "root@10.0.0.9") + XCTAssertEqual(saved.lastCheckup?.state, .passed) + XCTAssertEqual(saved.lastDeployState?.status, .succeeded) + XCTAssertEqual(saved.runtimeState?.state, .installedVerified) + XCTAssertEqual(saved.settings, DeviceProfileSettings( + nbnsEnabled: false, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: true, + mountWaitSeconds: 60, + ataIdleSeconds: 0, + ataStandby: 0 + )) + } + + func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { + let auth = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "bad password") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let authProfile = try await auth.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try auth.passwordStore.save("bad", for: authProfile.keychainAccount) + let authStore = DeviceProfileEditorStore(profile: authProfile, appStore: auth.appStore) + authStore.draft.host = "10.0.0.9" + + await authStore.save(profile: authProfile) + + try await waitUntilStoreState { + authStore.state == .authFailed && + auth.registry.profile(id: authProfile.id)?.passwordState == .invalid + } + XCTAssertEqual(auth.registry.profile(id: authProfile.id)?.host, "10.0.0.2") + XCTAssertEqual(auth.registry.profile(id: authProfile.id)?.passwordState, .invalid) + + let unsupported = try await makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "unsupported_device", message: "unsupported") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let unsupportedProfile = try await unsupported.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try unsupported.passwordStore.save("pw", for: unsupportedProfile.keychainAccount) + let unsupportedStore = DeviceProfileEditorStore(profile: unsupportedProfile, appStore: unsupported.appStore) + unsupportedStore.draft.host = "10.0.0.9" + + await unsupportedStore.save(profile: unsupportedProfile) + + try await waitUntilStoreState { unsupportedStore.state == .unsupported } + XCTAssertEqual(unsupported.registry.profile(id: unsupportedProfile.id)?.host, "10.0.0.2") + } + + private func makeFixture(responses: [StoreTestRunner.Response]) async throws -> ( + appStore: AppStore, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore, + runner: StoreTestRunner + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await 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/DeviceProfilePersistenceServiceTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfilePersistenceServiceTests.swift new file mode 100644 index 00000000..5f0295aa --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfilePersistenceServiceTests.swift @@ -0,0 +1,208 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceProfilePersistenceServiceTests: XCTestCase { + func testKeychainFailureDoesNotPersistProfile() async throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let passwordStore = InMemoryPasswordStore() + passwordStore.saveFailure = .save + let service = DeviceProfilePersistenceService(registry: registry, passwordStore: passwordStore) + let draft = try service.prepareConfigureTarget( + targetHost: "10.0.0.2", + discoveredDevice: nil, + existingProfile: nil, + preferredID: "device-one", + settings: .default + ) + try writeTestConfig(to: draft.context.configURL) + + do { + _ = try await service.commitConfiguredProfile( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + draft: draft, + password: "secret", + overrides: .empty + ) + XCTFail("Expected keychain save failure.") + } catch { + XCTAssertNotNil(error) + } + + XCTAssertEqual(registry.profiles, []) + XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) + } + + func testRegistryFailureRollsBackNewKeychainPassword() async 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 service = DeviceProfilePersistenceService(registry: registry, passwordStore: passwordStore) + let draft = ConfigureProfileDraft( + profileID: "device-one", + existingProfileID: nil, + discoveredDevice: nil, + targetHost: "10.0.0.2", + settings: .default, + context: DeviceRuntimeContext( + profileID: "device-one", + configURL: temp.url.appendingPathComponent("staged.env") + ) + ) + try writeTestConfig(to: draft.context.configURL) + + do { + _ = try await service.commitConfiguredProfile( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + draft: draft, + password: "secret", + overrides: .empty + ) + XCTFail("Expected registry save failure.") + } catch { + XCTAssertNotNil(error) + } + + XCTAssertEqual(registry.profiles, []) + XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) + } + + func testRegistryFailureRestoresExistingKeychainPassword() async throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let existing = try await 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 service = DeviceProfilePersistenceService(registry: registry, passwordStore: passwordStore) + let draft = try service.prepareConfigureTarget( + targetHost: "10.0.0.2", + discoveredDevice: nil, + existingProfile: existing, + preferredID: existing.id, + settings: existing.settings + ) + try writeTestConfig(to: draft.context.configURL, host: "root@10.0.0.2") + let blockedRegistryPath = registry.registryURL + try FileManager.default.removeItem(at: blockedRegistryPath) + try FileManager.default.createDirectory(at: blockedRegistryPath, withIntermediateDirectories: false) + + do { + _ = try await service.commitConfiguredProfile( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Updated Capsule"), + draft: draft, + password: "new-secret", + overrides: .empty + ) + XCTFail("Expected registry save failure.") + } catch { + XCTAssertNotNil(error) + } + + XCTAssertEqual(try passwordStore.password(for: existing.keychainAccount), "old-secret") + XCTAssertEqual(registry.profile(id: existing.id)?.model, existing.model) + } + + func testConfiguredCommitMovesStagedConfigToFinalPath() async throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let passwordStore = InMemoryPasswordStore() + let service = DeviceProfilePersistenceService(registry: registry, passwordStore: passwordStore) + let draft = try service.prepareConfigureTarget( + targetHost: "10.0.0.2", + discoveredDevice: nil, + existingProfile: nil, + preferredID: "device-one", + settings: .default + ) + try writeTestConfig(to: draft.context.configURL) + + let profile = try await service.commitConfiguredProfile( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + draft: draft, + password: "secret" + ) + + XCTAssertFalse(FileManager.default.fileExists(atPath: draft.context.configURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: profile.configPath)) + XCTAssertEqual(try passwordStore.password(for: profile.keychainAccount), "secret") + } + + func testConfiguredCommitReplacingConfigDoesNotLeaveRollbackArtifact() async throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let existing = try await registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try writeTestConfig(to: existing.configURL, host: "root@10.0.0.2") + let passwordStore = InMemoryPasswordStore(passwords: [existing.keychainAccount: "old-secret"]) + let service = DeviceProfilePersistenceService(registry: registry, passwordStore: passwordStore) + let draft = try service.prepareConfigureTarget( + targetHost: "10.0.0.3", + discoveredDevice: nil, + existingProfile: existing, + preferredID: existing.id, + settings: existing.settings + ) + try writeTestConfig(to: draft.context.configURL, host: "root@10.0.0.3") + + let profile = try await service.commitConfiguredProfile( + configuredDevice: testConfiguredDevice(host: "10.0.0.3", model: "Updated Capsule"), + draft: draft, + password: "new-secret" + ) + + let stagingURL = temp.url + .appendingPathComponent("Devices", isDirectory: true) + .appendingPathComponent(".Staging", isDirectory: true) + let stagedArtifacts = (try? FileManager.default.contentsOfDirectory(atPath: stagingURL.path)) ?? [] + XCTAssertEqual(stagedArtifacts, []) + XCTAssertEqual(try String(contentsOf: profile.configURL, encoding: .utf8), "TC_HOST=root@10.0.0.3\n") + XCTAssertEqual(try passwordStore.password(for: profile.keychainAccount), "new-secret") + } + + func testForgetRestoresPasswordWhenRegistryDeleteFails() async throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + let profile = try await registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let passwordStore = InMemoryPasswordStore(passwords: [profile.keychainAccount: "old-secret"]) + let service = DeviceProfilePersistenceService(registry: registry, passwordStore: passwordStore) + try FileManager.default.removeItem(at: registry.registryURL) + try FileManager.default.createDirectory(at: registry.registryURL, withIntermediateDirectories: false) + + do { + try await service.forget(profile) + XCTFail("Expected registry delete failure.") + } catch { + XCTAssertNotNil(error) + } + + XCTAssertNotNil(registry.profile(id: profile.id)) + XCTAssertEqual(try passwordStore.password(for: profile.keychainAccount), "old-secret") + XCTAssertTrue(FileManager.default.fileExists(atPath: profile.configURL.deletingLastPathComponent().path)) + } + + private func writeTestConfig(to url: URL, host: String = "root@10.0.0.2") throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "TC_HOST=\(host)\n".write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift new file mode 100644 index 00000000..21a88eec --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift @@ -0,0 +1,210 @@ +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 testNetworkIdentityKeepsMultipleAddressesAndPrefersRegularIPv4() { + let profile = makeProfile( + host: "root@10.0.0.2", + hostname: "office-capsule.local.", + addresses: ["169.254.44.9", "10.0.0.2", "fd00::2"] + ) + + XCTAssertEqual(profile.addresses, ["169.254.44.9", "10.0.0.2", "fd00::2"]) + XCTAssertEqual(profile.connectionTarget, "10.0.0.2") + XCTAssertEqual(profile.displayTarget, "office-capsule.local") + XCTAssertEqual(profile.addressSummary, "IPv4 10.0.0.2 IPv6 fd00::2") + } + + func testNetworkIdentitySupportsIPv6OnlyProfiles() { + let profile = makeProfile( + host: "root@fd00::2", + bonjourName: nil, + hostname: nil, + addresses: ["fd00::2"] + ) + + XCTAssertEqual(profile.connectionTarget, "fd00::2") + XCTAssertEqual(profile.displayTarget, "fd00::2") + XCTAssertEqual(profile.addressSummary, "IPv6 fd00::2") + } + + func testDuplicateMatchingUsesBonjourHostHostnameAndAddressIdentityButNotWeakMetadata() { + let first = makeProfile( + id: "one", + host: " TCAPSULE.LOCAL. ", + bonjourFullname: "Office Capsule._airport._tcp.local.", + hostname: "office-capsule.local.", + addresses: ["10.0.0.2", "169.254.44.9"], + 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 sameHostname = makeProfile(id: "six", host: "10.0.0.10", hostname: "office-capsule.local.") + let sameAddress = makeProfile(id: "seven", host: "10.0.0.11", addresses: ["10.0.0.2"]) + let sameLinkLocalAddress = makeProfile(id: "eight", host: "10.0.0.13", addresses: ["169.254.44.9"]) + let weakMetadataOnly = makeProfile(id: "four", host: "10.0.0.12", syap: "119", model: "Time Capsule") + + XCTAssertTrue(DeviceProfile.matches(first, sameFullname)) + XCTAssertTrue(DeviceProfile.matches(first, sameHost)) + XCTAssertTrue(DeviceProfile.matches(first, sameHostWithRootUser)) + XCTAssertTrue(DeviceProfile.matches(first, sameHostname)) + XCTAssertTrue(DeviceProfile.matches(first, sameAddress)) + XCTAssertFalse(DeviceProfile.matches(first, sameLinkLocalAddress)) + 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 testProfileSettingsDecodeMissingNewKeysWithDefaults() throws { + let data = Data(""" + { + "nbnsEnabled": false, + "debugLogging": true, + "mountWaitSeconds": 45 + } + """.utf8) + + let settings = try JSONDecoder().decode(DeviceProfileSettings.self, from: data) + + XCTAssertEqual(settings.nbnsEnabled, false) + XCTAssertEqual(settings.internalShareUseDiskRoot, false) + XCTAssertEqual(settings.anyProtocol, false) + XCTAssertEqual(settings.debugLogging, true) + XCTAssertEqual(settings.mountWaitSeconds, 45) + XCTAssertEqual(settings.ataIdleSeconds, 300) + XCTAssertNil(settings.ataStandby) + } + + func testProfileSettingsDecodeLegacyStringAtaValues() throws { + let data = Data(""" + { + "nbnsEnabled": true, + "debugLogging": false, + "mountWaitSeconds": 45, + "ataIdleSeconds": "0", + "ataStandby": "120" + } + """.utf8) + + let settings = try JSONDecoder().decode(DeviceProfileSettings.self, from: data) + + XCTAssertEqual(settings.ataIdleSeconds, 0) + XCTAssertEqual(settings.ataStandby, 120) + } + + func testProfileSettingsInvalidLegacyAtaValuesFallbackSafely() throws { + let data = Data(""" + { + "nbnsEnabled": true, + "debugLogging": false, + "mountWaitSeconds": 45, + "ataIdleSeconds": "bad", + "ataStandby": "bad" + } + """.utf8) + + let settings = try JSONDecoder().decode(DeviceProfileSettings.self, from: data) + + XCTAssertEqual(settings.ataIdleSeconds, 300) + XCTAssertNil(settings.ataStandby) + } + + 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, + hostname: String? = nil, + addresses: [String] = [], + 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: hostname, + addresses: 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, + lastDeployState: nil, + settings: .default, + passwordState: .unknown + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceReachabilityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceReachabilityStoreTests.swift new file mode 100644 index 00000000..069261d8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceReachabilityStoreTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceReachabilityStoreTests: XCTestCase { + func testRefreshRunsReachabilityOnWorkflowLaneAndStoresSnapshot() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "reachability", stage: "check_ssh_port"), + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator, now: { Date(timeIntervalSince1970: 123) }) + let profile = try makeProfile(host: "10.0.0.2") + + store.refresh(profile: profile, password: "pw") + try await waitUntilStoreState { store.snapshot(for: profile) != nil } + + XCTAssertEqual(runner.calls.map(\.operation), ["reachability"]) + XCTAssertEqual(runner.calls[0].context?.profileID, profile.id) + XCTAssertEqual(runner.calls[0].params["ssh_host"], .string("root@10.0.0.2")) + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(store.snapshot(for: profile)?.payload.status, "reachable") + XCTAssertEqual(store.snapshot(for: profile)?.refreshedAt, Date(timeIntervalSince1970: 123)) + XCTAssertNil(store.error(for: profile)) + XCTAssertEqual(coordinator.lane(for: .deviceWorkflow(profile.id, .reachability)).backend.events.last?.operation, "reachability") + } + + func testRefreshCanRunWithoutPassword() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload( + status: "partial", + summary: "SSH reachable, SMB port closed." + )) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator) + let profile = try makeProfile(host: "root@10.0.0.2") + + store.refresh(profile: profile, password: nil) + try await waitUntilStoreState { store.snapshot(for: profile) != nil } + + XCTAssertNil(runner.calls[0].params["credentials"]) + XCTAssertEqual(store.snapshot(for: profile)?.payload.status, "partial") + } + + func testErrorEventIsStoredPerProfile() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "reachability", code: "operation_failed", message: "failed") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator) + let profile = try makeProfile() + + store.refresh(profile: profile, password: "pw") + try await waitUntilStoreState { store.error(for: profile) != nil } + + XCTAssertEqual(store.error(for: profile)?.message, "failed") + XCTAssertNil(store.snapshot(for: profile)) + } + + func testRefreshDoesNotClearBusyDeviceLane() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks") + ], pauseAfterEvents: true) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator) + let profile = try makeProfile() + + coordinator.run( + operation: "doctor", + params: [:], + context: profile.runtimeContext, + activeDeviceID: profile.id, + laneKey: .device(profile.id) + ) + try await waitUntilStoreState { + coordinator.activeOperation(for: profile)?.operation == "doctor" && + runner.calls.map(\.operation) == ["doctor"] + } + + store.refresh(profile: profile, password: "pw") + + XCTAssertEqual(coordinator.activeOperation(for: profile)?.operation, "doctor") + XCTAssertEqual(store.error(for: profile)?.code, "operation_rejected") + XCTAssertEqual(runner.calls.map(\.operation), ["doctor"]) + runner.finishAll() + } + + func testRefreshIsRejectedWhileDeployWorkflowOwnsSameDeviceResource() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_smbd") + ], pauseAfterEvents: true) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeviceReachabilityStore(coordinator: coordinator) + let profile = try makeProfile() + let deployLane = OperationLaneKey.deviceWorkflow(profile.id, .deploy) + + coordinator.run( + operation: "deploy", + params: [:], + context: profile.runtimeContext, + activeDeviceID: profile.id, + laneKey: deployLane + ) + try await waitUntilStoreState { + coordinator.lane(for: deployLane).backend.isRunning && + runner.calls.map(\.operation) == ["deploy"] + } + + store.refresh(profile: profile, password: "pw") + + XCTAssertEqual(store.error(for: profile)?.code, "operation_rejected") + XCTAssertEqual(runner.calls.map(\.operation), ["deploy"]) + XCTAssertTrue(coordinator.lane(for: deployLane).backend.isRunning) + XCTAssertTrue(coordinator.lane(for: .deviceWorkflow(profile.id, .reachability)).backend.events.isEmpty) + runner.finishAll() + } + + private func makeProfile(host: String = "10.0.0.2") throws -> DeviceProfile { + DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(host: host), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift new file mode 100644 index 00000000..dabdf6a4 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift @@ -0,0 +1,499 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceRegistryStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DeviceRegistryState.allCases, [.idle, .loading, .empty, .loaded, .saving, .failed]) + } + + func testMissingRegistryStartsEmpty() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + + await store.load() + + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.devicesDirectoryURL.path)) + } + + func testCorruptRegistryEntersFailedStateWithoutDeletingFile() async 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) + + await store.load() + + XCTAssertEqual(store.state, .failed) + XCTAssertNotNil(store.error) + XCTAssertTrue(FileManager.default.fileExists(atPath: registryURL.path)) + XCTAssertEqual(try String(contentsOf: registryURL), "{ not json") + } + + func testLegacyStoredPathAndKeychainAccountAreDerivedAfterLoad() async throws { + let temp = try TemporaryDirectory() + let registryURL = temp.url.appendingPathComponent("devices.json") + try """ + [ + { + "id": "device-one", + "displayName": "Office", + "network": { + "configuredSSHTarget": "10.0.0.2", + "addresses": [] + }, + "configPath": "/legacy/path/.env", + "keychainAccount": "legacy-account", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z", + "settings": {}, + "passwordState": "available" + } + ] + """.write(to: registryURL, atomically: true, encoding: .utf8) + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + + await store.load() + + let profile = try XCTUnwrap(store.profiles.first) + XCTAssertEqual(profile.configPath, temp.url.appendingPathComponent("Devices/device-one/.env").path) + XCTAssertEqual(profile.keychainAccount, "device-one") + _ = try await store.updateProfile(profile) + let persistedJSON = try String(contentsOf: registryURL) + XCTAssertFalse(persistedJSON.contains("\"configPath\"")) + XCTAssertFalse(persistedJSON.contains("\"keychainAccount\"")) + } + + func testCreateUpdateAndDeleteProfile() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + + var profile = try await 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)) + let persistedJSON = try String(contentsOf: store.registryURL) + XCTAssertFalse(persistedJSON.contains("\"configPath\"")) + XCTAssertFalse(persistedJSON.contains("\"keychainAccount\"")) + + profile.displayName = "Renamed Capsule" + profile.settings.debugLogging = true + let updated = try await store.updateProfile(profile) + XCTAssertEqual(updated.displayName, "Renamed Capsule") + XCTAssertEqual(store.profiles.first?.settings.debugLogging, true) + + try await store.delete(updated) + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + XCTAssertFalse(FileManager.default.fileExists(atPath: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent().path)) + let stagingURL = temp.url.appendingPathComponent("Devices/.Staging", isDirectory: true) + let stagedArtifacts = (try? FileManager.default.contentsOfDirectory(atPath: stagingURL.path)) ?? [] + XCTAssertEqual(stagedArtifacts, []) + } + + func testDuplicateSaveUpdatesByHostAndBonjourFullnameButNotWeakMetadata() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + + let first = try await 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 await 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 await 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) + + let addressDuplicate = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "other.local."), + discoveredDevice: try discovered(record: testDeviceRecord( + hostname: "other.local.", + ipv4: ["10.0.0.2"], + fullname: "Other._airport._tcp.local." + )), + passwordState: .available, + preferredID: "device-address" + ) + XCTAssertEqual(addressDuplicate.id, first.id) + XCTAssertEqual(store.profiles.count, 1) + + _ = try await 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 testConcurrentDuplicateSavesAreSerializedThroughRepository() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + + async let first = store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Original Capsule"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + async let second = store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: " 10.0.0.2 ", model: "Updated Capsule"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-two" + ) + + let saved = try await [first, second] + + XCTAssertEqual(Set(saved.map(\.id)).count, 1) + XCTAssertEqual(store.profiles.count, 1) + XCTAssertEqual(store.profiles.first?.id, saved[0].id) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let persisted = try decoder.decode([DeviceProfile].self, from: Data(contentsOf: store.registryURL)) + XCTAssertEqual(persisted.count, 1) + XCTAssertEqual(persisted.first?.id, saved[0].id) + } + + func testUpdateProfileDoesNotMergeDuplicateHostIntoAnotherProfile() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + let first = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await 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. " + + do { + _ = try await store.updateProfile(conflictingUpdate) + XCTFail("Expected duplicate host update to fail.") + } catch { + 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() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + let first = try await 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 await 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. " + + do { + _ = try await store.updateProfile(second) + XCTFail("Expected duplicate Bonjour fullname update to fail.") + } catch { + 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 testUpdateProfileIgnoresLinkLocalAddressConflicts() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + _ = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: try discovered(record: testDeviceRecord( + hostname: "office.local.", + ipv4: ["10.0.0.2", "169.254.44.9"], + fullname: "Office._airport._tcp.local." + )), + passwordState: .available, + preferredID: "device-one" + ) + var second = try await 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.addresses = ["169.254.44.9"] + let updated = try await store.updateProfile(second) + + XCTAssertEqual(updated.addresses, ["169.254.44.9", "10.0.0.3"]) + XCTAssertEqual(store.profiles.count, 2) + } + + func testUpdateProfileRejectsRegularAddressConflicts() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + let first = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + var second = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + + second.addresses = ["10.0.0.2"] + + do { + _ = try await store.updateProfile(second) + XCTFail("Expected duplicate regular address update to fail.") + } catch { + XCTAssertEqual( + error as? DeviceRegistryError, + .duplicateProfile(field: "address", value: "10.0.0.2", conflictingProfileID: first.id) + ) + } + XCTAssertEqual(store.profiles.count, 2) + } + + func testDeleteRestoresConfigDirectoryWhenRegistryPersistFails() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await store.load() + let profile = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try "TC_HOST=root@10.0.0.2\n".write(to: profile.configURL, atomically: true, encoding: .utf8) + try FileManager.default.removeItem(at: store.registryURL) + try FileManager.default.createDirectory(at: store.registryURL, withIntermediateDirectories: false) + + do { + try await store.delete(profile) + XCTFail("Expected registry delete failure.") + } catch { + XCTAssertNotNil(error) + } + + XCTAssertNotNil(store.profile(id: profile.id)) + XCTAssertTrue(FileManager.default.fileExists(atPath: profile.configURL.deletingLastPathComponent().path)) + XCTAssertEqual(try String(contentsOf: profile.configURL, encoding: .utf8), "TC_HOST=root@10.0.0.2\n") + } + + func testUpdateProfileMissingIDFailsWithoutCreatingProfile() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + await 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" + + do { + _ = try await store.updateProfile(profile) + XCTFail("Expected missing profile update to fail.") + } catch { + XCTAssertEqual(error as? DeviceRegistryError, .profileNotFound("missing")) + } + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + } + + func testUpdateProfilePreservesOtherProfilesForLocalEdits() async throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url, now: { + Date(timeIntervalSince1970: 100) + }) + await store.load() + var first = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try await 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 await store.updateProfile(first) + + XCTAssertEqual(updated.displayName, "Office") + XCTAssertEqual(updated.settings.mountWaitSeconds, 45) + XCTAssertEqual(store.profile(id: second.id), second) + XCTAssertEqual(store.profiles.count, 2) + } + + func testLoadMarksInProgressDeployStateInterrupted() async throws { + let temp = try TemporaryDirectory() + let start = Date(timeIntervalSince1970: 200) + let interruptedAt = Date(timeIntervalSince1970: 300) + let store = DeviceRegistryStore(applicationSupportURL: temp.url, now: { start }) + await store.load() + let profile = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await store.updateDeployState(testDeployState( + status: .deploying, + startedAt: start, + updatedAt: start, + finishedAt: nil, + stage: "read_mast", + verified: nil, + summary: "" + ), for: profile.id) + + let reloaded = DeviceRegistryStore(applicationSupportURL: temp.url, now: { interruptedAt }) + await reloaded.load() + + let deployState = try XCTUnwrap(reloaded.profile(id: profile.id)?.lastDeployState) + XCTAssertEqual(deployState.status, .interrupted) + XCTAssertEqual(deployState.startedAt, start) + XCTAssertEqual(deployState.updatedAt, interruptedAt) + XCTAssertEqual(deployState.finishedAt, interruptedAt) + XCTAssertEqual(deployState.stage, "read_mast") + XCTAssertEqual(deployState.errorCode, "operation_interrupted") + XCTAssertEqual(deployState.localizedSummary, "Deploy was interrupted before it completed.") + let runtimeState = try XCTUnwrap(reloaded.profile(id: profile.id)?.runtimeState) + XCTAssertEqual(runtimeState.state, .installInterrupted) + XCTAssertEqual(runtimeState.stage, "read_mast") + XCTAssertEqual(runtimeState.errorCode, "operation_interrupted") + XCTAssertEqual(runtimeState.localizedSummary, "Deploy was interrupted before it completed.") + } + + func testInterruptedRuntimeStateOverridesSuccessfulCheckupAfterReload() async throws { + let temp = try TemporaryDirectory() + let start = Date(timeIntervalSince1970: 200) + let interruptedAt = Date(timeIntervalSince1970: 300) + let store = DeviceRegistryStore(applicationSupportURL: temp.url, now: { start }) + await store.load() + let profile = try await store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + await store.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 3, + warnCount: 0, + failCount: 0, + summary: "healthy" + ), for: profile.id) + await store.updateDeployState(testDeployState( + status: .deploying, + startedAt: start, + updatedAt: start, + finishedAt: nil, + stage: "read_mast", + verified: nil, + summary: "" + ), for: profile.id) + await store.updateRuntimeState(testRuntimeState( + state: .installing, + stage: "read_mast", + verified: nil, + summary: "" + ), for: profile.id) + + let reloaded = DeviceRegistryStore(applicationSupportURL: temp.url, now: { interruptedAt }) + await reloaded.load() + + let reloadedProfile = try XCTUnwrap(reloaded.profile(id: profile.id)) + XCTAssertEqual(reloadedProfile.lastCheckup?.state, .passed) + XCTAssertEqual(reloadedProfile.lastDeployState?.status, .interrupted) + XCTAssertEqual(reloadedProfile.runtimeState?.state, .installInterrupted) + XCTAssertEqual(DeviceStatusPolicy.status( + for: reloadedProfile, + passwordState: .available, + activeOperation: nil + ), .failed) + } + + 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 00000000..c7bd68be --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift @@ -0,0 +1,237 @@ +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 testInstallingStatusUsesInstallIcon() { + XCTAssertEqual(DeviceDisplayStatus.installing.systemImage, "square.and.arrow.down.on.square") + } + + 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", + "Settings" + ]) + } + + 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(runtimeState: testRuntimeState()) + + XCTAssertEqual(status(profile, .available, operation: "doctor"), .checking) + XCTAssertEqual(status(profile, .available, operation: "deploy"), .installing) + XCTAssertEqual(status(profile, .available, operation: "fsck"), .maintaining) + } + + func testHealthStatusComesFromRuntimeState() throws { + XCTAssertEqual(status(try makeProfile(), .available), .unchecked) + XCTAssertEqual(status(try makeProfile(runtimeState: testRuntimeState(state: .installedVerified)), .available), .healthy) + XCTAssertEqual(status(try makeProfile(runtimeState: testRuntimeState(state: .installedUnverified, verified: false)), .available), .warning) + XCTAssertEqual(status(try makeProfile(runtimeState: testRuntimeState(state: .notInstalled, source: .doctor, verified: false)), .available), .readyToInstall) + XCTAssertEqual(status(try makeProfile(runtimeState: testRuntimeState(state: .installing, verified: nil)), .available), .installing) + XCTAssertEqual(status(try makeProfile(runtimeState: testRuntimeState(state: .installFailed, verified: false)), .available), .failed) + XCTAssertEqual(status(try makeProfile(runtimeState: testRuntimeState(state: .installInterrupted, verified: nil)), .available), .failed) + XCTAssertEqual(status(try makeProfile(runtimeState: testRuntimeState(state: .unhealthy, source: .doctor, verified: false)), .available), .failed) + } + + func testCheckupAndDeployUISnapshotsDoNotDriveSidebarStatus() throws { + let profile = try makeProfile( + lastCheckup: passedCheckup(), + lastDeployState: deployed() + ) + + XCTAssertEqual(status(profile, .available), .unchecked) + } + + func testFailedRuntimeStateOverridesPreviousHealthyCheckupStatus() throws { + let profile = try makeProfile( + lastCheckup: passedCheckup(), + lastDeployState: deployed(), + runtimeState: testRuntimeState( + state: .installFailed, + verified: false, + errorMessage: "No deployable HFS disk was found after 10 MaSt queries spaced 3 seconds apart." + ) + ) + + XCTAssertEqual(status(profile, .available), .failed) + } + + func testNetBSD4ActivationNeededRuntimeStateMapsToActivationNeeded() throws { + let profile = try makeProfile( + payloadFamily: "netbsd4_samba4", + lastCheckup: warningCheckup(), + lastDeployState: deployed(), + runtimeState: testRuntimeState(state: .activationNeeded, source: .doctor, payloadFamily: "netbsd4_samba4", verified: false) + ) + + 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(runtimeState: testRuntimeState()), + passwordState: .available, + activeOperation: nil + ), .openSMB) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile( + lastCheckup: passedCheckup(), + lastDeployState: deployed(), + runtimeState: testRuntimeState() + ), + passwordState: .available, + activeOperation: nil + ), .openSMB) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(runtimeState: testRuntimeState(state: .notInstalled, source: .doctor, verified: false)), + passwordState: .available, + activeOperation: nil + ), .installSMB) + } + + 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, + lastDeployState: DeviceDeployStateSnapshot? = nil, + runtimeState: DeviceRuntimeStateSnapshot? = 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.lastDeployState = lastDeployState + profile.runtimeState = runtimeState + 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(verified: Bool = true) -> DeviceDeployStateSnapshot { + testDeployState( + startedAt: Date(timeIntervalSince1970: 11), + updatedAt: Date(timeIntervalSince1970: 11), + finishedAt: Date(timeIntervalSince1970: 11), + verified: verified + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DiagnosticsExportBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DiagnosticsExportBuilderTests.swift new file mode 100644 index 00000000..b7d5df0f --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DiagnosticsExportBuilderTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DiagnosticsExportBuilderTests: XCTestCase { + func testExportIncludesReleaseReadinessAndDeviceContext() { + let text = DiagnosticsExportBuilder().build(context: makeContext()) + + XCTAssertTrue(text.contains("TimeCapsuleSMB Diagnostics")) + XCTAssertTrue(text.contains("Generated: 2026-05-26T12:00:00Z")) + XCTAssertTrue(text.contains("- Version: 2.1.4")) + XCTAssertTrue(text.contains("- Appearance: system")) + XCTAssertTrue(text.contains("- State: Ready")) + XCTAssertTrue(text.contains("- Helper Version: 2.1.4 (20125)")) + XCTAssertTrue(text.contains("- Validation Counts: checks=1, fail=0, pass=1")) + XCTAssertTrue(text.contains("- Name: Office Capsule")) + XCTAssertTrue(text.contains("- Active device:profile-one: deploy")) + XCTAssertTrue(text.contains("- Pending Confirmation: none")) + } + + func testExportRedactsSecretsInSettingsEventsAndErrors() { + var context = makeContext() + context.events = [ + BackendEvent( + type: "error", + operation: "deploy", + code: "failed", + message: "deploy failed", + payload: .object([ + "credentials": .object(["password": .string("super-secret")]), + "token": .string("abc123"), + "host": .string("10.0.0.2") + ]), + debug: .object([ + "authorization": .string("Bearer abc123"), + "path": .string("/tmp/log") + ]) + ) + ] + + let text = DiagnosticsExportBuilder().build(context: context) + + XCTAssertFalse(text.contains("super-secret")) + XCTAssertFalse(text.contains("abc123")) + XCTAssertTrue(text.contains("")) + XCTAssertTrue(text.contains("10.0.0.2")) + } + + func testExportBoundsBackendEvents() { + var context = makeContext() + context.events = (0..<55).map { + BackendEvent(type: "stage", operation: "doctor", stage: "stage-\($0)") + } + + let text = DiagnosticsExportBuilder(maxEvents: 2).build(context: context) + + XCTAssertFalse(text.contains("stage-52")) + XCTAssertTrue(text.contains("stage-53")) + XCTAssertTrue(text.contains("stage-54")) + } + + private func makeContext() -> DiagnosticsExportContext { + DiagnosticsExportContext( + generatedAt: Date(timeIntervalSince1970: 1_779_796_800), + appVersion: "2.1.4", + appBuild: "20125", + applicationSupportPath: "/Users/test/Library/Application Support/TimeCapsuleSMB", + helperPath: "", + appSettings: .default, + readinessState: .ready, + readinessVersionPayload: versionPayload(), + capabilities: CapabilitiesPayload( + schemaVersion: 1, + apiSchemaVersion: 1, + helperVersion: "2.1.4", + helperVersionCode: 20125, + operations: ["deploy", "doctor"], + distributionRoot: "/Applications/TimeCapsuleSMB.app/Contents/Resources/Distribution", + artifactManifestSHA256: "abc", + confirmationSchemaVersion: 1, + summary: "Helper capabilities resolved." + ), + validation: InstallValidationPayload( + schemaVersion: 1, + ok: true, + checks: [InstallCheckPayload(id: "python_modules", ok: true, message: "required Python modules import", details: nil)], + counts: ["checks": 1, "pass": 1, "fail": 0], + summary: "Install validation passed." + ), + runtimeIssues: [], + updateState: .current, + updatePayload: versionPayload(), + updateError: nil, + selectedProfile: profile(), + activeOperations: [.device("profile-one"): ActiveOperation(operation: "deploy", profileID: "profile-one", context: nil)], + pendingConfirmation: nil, + events: [BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["summary": .string("Doctor checks passed.")]))] + ) + } + + private func versionPayload(source: String = "network") -> VersionCheckPayload { + VersionCheckPayload( + schemaVersion: 1, + shouldBlock: false, + updateAvailable: false, + checkedURL: "https://example.invalid/version.json", + message: "Current.", + downloadURL: "https://example.invalid/download", + localVersionCode: 20125, + currentVersion: 20125, + minSupportedVersion: 20000, + latestTag: "v2.1.4", + source: source, + summary: source == "unavailable" ? "Version metadata is unavailable." : "TimeCapsuleSMB is up to date." + ) + } + + private func profile() -> DeviceProfile { + DeviceProfile( + id: "profile-one", + displayName: "Office Capsule", + host: "root@10.0.0.2", + bonjourName: "Office Capsule", + bonjourFullname: "Office Capsule._airport._tcp.local.", + hostname: "office-capsule.local.", + addresses: ["10.0.0.2"], + syap: "119", + model: "TimeCapsule8,119", + osName: "NetBSD", + osRelease: "6.0", + arch: "evbarm", + elfEndianness: "little", + payloadFamily: "netbsd6", + deviceGeneration: "gen5", + configPath: "/tmp/profile-one/.env", + keychainAccount: "profile-one", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + lastCheckup: nil, + lastDeployState: nil, + settings: .default, + passwordState: .available + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift new file mode 100644 index 00000000..4809c7c8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift @@ -0,0 +1,250 @@ +import Combine +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DoctorStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DoctorWorkflowState.allCases, [ + .idle, + .running, + .passed, + .warning, + .failed, + .runFailed + ]) + } + + 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.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["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 testPublishesWhenBackendFinishesAfterTerminalResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload( + fatal: false, + checks: [check(status: "PASS", message: "runtime ok", domain: "Runtime")] + )) + ]) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + let finishPublished = expectation(description: "DoctorStore publishes after backend running state clears") + var didFulfill = false + var cancellables: Set = [] + store.objectWillChange + .sink { [weak store] _ in + Task { @MainActor in + guard !didFulfill, + store?.state == .passed, + store?.isRunning == false else { + return + } + didFulfill = true + finishPublished.fulfill() + } + } + .store(in: &cancellables) + + store.runDoctor(password: "") + + try await waitUntilStoreState { store.state == .passed } + await fulfillment(of: [finishPublished], timeout: 2) + XCTAssertFalse(store.isRunning) + _ = cancellables + } + + func testRejectedRunDoesNotEnterRunning() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: .object(["ok": .bool(true)])) + ], pauseBeforeEvents: true) + ]) + 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 && coordinator.backend.isRunning } + let result = store.runDoctor(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.state, .runFailed) + XCTAssertEqual(store.error?.code, "operation_already_running") + XCTAssertEqual(runner.calls.count, 1) + runner.finishAll() + 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 00000000..4bb3235e --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift @@ -0,0 +1,620 @@ +import AppKit +import Combine +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class FlashWorkflowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(FlashWorkflowState.allCases, [ + .unavailable, + .disabledInThisBuild, + .eligibleForReadOnlyAnalysis, + .readingBanks, + .savingBackup, + .analyzingBanks, + .planAvailable, + .appleCheckComplete, + .appleFirmwareMismatch, + .appleFirmwareReady, + .writeLocked, + .awaitingStrongConfirmation, + .writing, + .readbackValidating, + .writeValidated, + .writeValidatedSnapshotStale, + .manualPowerCycleRequired, + .restoreRebooting, + .failed + ]) + } + + func testDefaultPolicyEnablesFlashWritesForNetBSD4() throws { + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + let store = FlashWorkflowStore() + + store.refresh(profile: profile) + + XCTAssertEqual(store.state, .writeLocked) + XCTAssertTrue(store.canBackup) + } + + 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) + } + + func testBootHookSectionVisibilityIsLimitedToNetBSD4Profiles() throws { + let netbsd4 = try makeProfile(payloadFamily: "netbsd4_samba4") + let netbsd6 = try makeProfile(payloadFamily: "netbsd6_samba4") + + XCTAssertTrue(FlashBootHookVisibilityPolicy.isVisible(for: netbsd4)) + XCTAssertFalse(FlashBootHookVisibilityPolicy.isVisible(for: netbsd6)) + } + + func testFlashActionSymbolsResolveToSFSymbols() { + XCTAssertEqual(FlashUserAction.backupAndInspect.systemImage, "externaldrive.badge.questionmark") + for action in [ + FlashUserAction.backupAndInspect, + .planPatch, + .planRestore, + .checkApple, + .downloadApple, + .writePatch, + .writeRestore + ] { + XCTAssertNotNil(NSImage(systemSymbolName: action.systemImage, accessibilityDescription: nil), action.systemImage) + } + } + + func testFlashWriteParamsDefaultRestoreToRebootAndPatchToManualPowerCycle() { + let restore = OperationParams.Flash.write( + backupDir: "/tmp/flash-backup", + mode: .restore + ) + let patch = OperationParams.Flash.write( + backupDir: "/tmp/flash-backup", + mode: .patch + ) + + XCTAssertEqual(restore["reboot_after_write"], .bool(true)) + XCTAssertEqual(restore["wait_after_reboot"], .bool(true)) + XCTAssertEqual(patch["reboot_after_write"], .bool(false)) + XCTAssertNil(patch["wait_after_reboot"]) + } + + func testBackupAndPlanFlowTracksStructuredPayloads() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "flash", stage: "read_flash", risk: "remote_read", cancellable: true), + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "flash", stage: "plan_flash", risk: "local_write", cancellable: true), + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + XCTAssertEqual(store.state, .readingBanks) + try await waitUntilStoreState { store.backup != nil && store.state == .planAvailable } + + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.plan != nil && store.canWritePatch } + + XCTAssertEqual(runner.calls.count, 2) + XCTAssertEqual(runner.calls[0].operation, "flash") + XCTAssertEqual(runner.calls[0].params["action"], .string("backup")) + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(runner.calls[1].params["action"], .string("plan")) + XCTAssertEqual(runner.calls[1].params["backup_dir"], .string("/tmp/flash-backup")) + XCTAssertEqual(runner.calls[1].params["mode"], .string("patch")) + } + + func testPublishesWhenBackendFinishesAfterBackupResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + let finishPublished = expectation(description: "FlashWorkflowStore publishes after backend running state clears") + var didFulfill = false + var cancellables: Set = [] + store.objectWillChange + .sink { [weak store] _ in + Task { @MainActor in + guard !didFulfill, + store?.state == .planAvailable, + store?.isBusy == false else { + return + } + didFulfill = true + finishPublished.fulfill() + } + } + .store(in: &cancellables) + + store.backupAndInspect(password: "pw", profile: profile) + + try await waitUntilStoreState { store.state == .planAvailable } + await fulfillment(of: [finishPublished], timeout: 2) + XCTAssertFalse(store.isBusy) + _ = cancellables + } + + func testPlanFlashCarriesAppleFirmwareSelectionOptions() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .downloadOnly, writeRequested: false)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.firmwareVersion = " 7.8.1 " + store.firmwareTemplatePath = " /tmp/firmware.basebinary " + + store.planFlash(mode: .downloadOnly, profile: profile) + try await waitUntilStoreState { runner.calls.count == 2 && store.plan != nil } + + XCTAssertEqual(runner.calls[1].params["firmware_version"], .string("7.8.1")) + XCTAssertEqual(runner.calls[1].params["firmware_template"], .string("/tmp/firmware.basebinary")) + } + + func testFirmwareSelectionEditsInvalidateExistingWritePlan() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.firmwareVersion = "7.8.1" + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.canWritePatch } + + store.firmwareVersion = "7.8.2" + + XCTAssertNil(store.plan) + XCTAssertFalse(store.canWritePatch) + XCTAssertEqual(store.state, .planAvailable) + } + + func testAppleCheckPresentationShowsMatchDetails() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent( + type: "result", + operation: "flash", + ok: true, + payload: flashPlanPayload( + mode: .checkApple, + writeRequested: false, + alreadySatisfied: true, + appleMatched: true + ) + ) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .checkApple, profile: profile) + try await waitUntilStoreState { store.state == .appleCheckComplete } + + let presentation = FlashPresentation(store: store) + XCTAssertEqual(presentation.message, "Active firmware bank matches Apple stock firmware 7.8.1.") + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Apple Match", value: "yes"))) + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Apple Version", value: "7.8.1"))) + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Apple Payload SHA-256", value: "inner-sha"))) + } + + func testAppleCheckMismatchUsesDedicatedState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent( + type: "result", + operation: "flash", + ok: true, + payload: flashPlanPayload( + mode: .checkApple, + writeRequested: false, + alreadySatisfied: false, + appleMatched: false + ) + ) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .checkApple, profile: profile) + try await waitUntilStoreState { store.state == .appleFirmwareMismatch } + + XCTAssertTrue(FlashPresentation(store: store).rows.contains(PresentationRow(label: "Apple Match", value: "no"))) + } + + func testValidateAppleRestoreFirmwarePresentationShowsPayloadDetails() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent( + type: "result", + operation: "flash", + ok: true, + payload: flashPlanPayload(mode: .downloadOnly, writeRequested: false, includeFirmwarePayload: true) + ) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .downloadOnly, profile: profile) + try await waitUntilStoreState { store.state == .appleFirmwareReady } + + let presentation = FlashPresentation(store: store) + XCTAssertEqual(presentation.message, "Apple restore firmware validated (version 7.8.1, product 116).") + XCTAssertEqual(presentation.title(for: .downloadApple), "Validate Apple Restore Firmware") + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Firmware Payload", value: "/tmp/flash-backup/primary.download_only.basebinary"))) + XCTAssertTrue(presentation.rows.contains(PresentationRow(label: "Firmware Payload SHA-256", value: "payload-sha"))) + } + + func testWriteConfirmationCancellationRestoresPlanAvailable() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "flash", + code: "confirmation_required", + message: "Confirm?", + details: .object([ + "confirmation_id": .string("confirm-1"), + "presentation_id": .string("flash.patch_write"), + "presentation_values": .object(["host": .string("10.0.0.2")]) + ]) + ) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = FlashWorkflowStore(backend: backend) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.plan != nil } + + store.write(mode: .patch, password: "pw", profile: profile) + try await waitUntilStoreState { store.state == .awaitingStrongConfirmation && backend.pendingConfirmation != nil && !backend.isRunning } + + backend.cancelPendingConfirmation() + + try await waitUntilStoreState { store.state == .planAvailable && backend.pendingConfirmation == nil } + } + + func testValidatedPatchWriteShowsManualPowerCycleNotice() async throws { + let store = try await storeAfterValidatedWrite(mode: .patch) + + XCTAssertEqual(store.state, .writeValidatedSnapshotStale) + XCTAssertEqual(store.manualPowerCycleNotice?.mode, .patch) + XCTAssertEqual( + store.manualPowerCycleNotice?.message, + "Flash write validation completed. Unplug the device, wait 10 seconds, then plug it back in. Wait for it to finish booting, then run Checkup. One firmware bank was left untouched." + ) + XCTAssertEqual(store.manualPowerCycleNotice?.viewCheckupActionTitle, "View Checkup") + + store.dismissManualPowerCycleNotice() + + XCTAssertNil(store.manualPowerCycleNotice) + } + + func testValidatedRestoreWriteWithDefaultRebootDoesNotShowManualPowerCycleNotice() async throws { + let store = try await storeAfterValidatedWrite(mode: .restore) + + XCTAssertEqual(store.state, .writeValidatedSnapshotStale) + XCTAssertNil(store.manualPowerCycleNotice) + XCTAssertFalse(FlashPresentation(store: store).warnings.contains( + "Unplug the device, wait 10 seconds, then plug it back in." + )) + } + + func testValidatedRestoreWriteWithoutRebootShowsManualPowerCycleNotice() async throws { + let store = try await storeAfterValidatedWrite( + mode: .restore, + writePayload: flashWritePayload( + mode: .restore, + postWriteAction: "manual_reboot", + rebootRequested: false, + rebooted: false, + waitedAfterReboot: false, + summary: "Flash restore write validated; manual reboot required." + ) + ) + + XCTAssertEqual(store.state, .writeValidatedSnapshotStale) + XCTAssertEqual(store.manualPowerCycleNotice?.mode, .restore) + } + + func testValidatedWriteMarksSnapshotStaleAndDisablesPlanning() async throws { + let store = try await storeAfterValidatedWrite(mode: .patch) + let presentation = FlashPresentation(store: store) + + XCTAssertTrue(store.backupSnapshotStale) + XCTAssertNil(store.plan) + XCTAssertTrue(store.canBackup) + XCTAssertFalse(store.canPlan) + XCTAssertFalse(store.canPlanWrites) + XCTAssertFalse(store.canWritePatch) + XCTAssertEqual(presentation.title(for: .backupAndInspect), "Back Up and Inspect Again") + XCTAssertTrue(presentation.warnings.contains("Firmware was written after this backup. Back up and inspect again before planning another flash action.")) + } + + func testFreshBackupClearsStaleSnapshotAfterWrite() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: .patch, writeRequested: true)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashWritePayload(mode: .patch)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: .patch, profile: profile) + try await waitUntilStoreState { store.plan != nil } + store.write(mode: .patch, password: "pw", profile: profile) + try await waitUntilStoreState { store.backupSnapshotStale } + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { !store.backupSnapshotStale && store.state == .planAvailable } + + XCTAssertTrue(store.canPlan) + XCTAssertEqual(FlashPresentation(store: store).title(for: .backupAndInspect), "Back Up and Inspect") + } + + func testFlashPresentationUsesWriteResultSummaryAfterWrite() async throws { + let store = try await storeAfterValidatedWrite(mode: .patch) + + let presentation = FlashPresentation(store: store) + + XCTAssertEqual(presentation.message, "Flash patch write validated; manual power cycle required.") + } + + private func storeAfterValidatedWrite( + mode: FlashPlanMode, + writePayload: JSONValue? = nil + ) async throws -> FlashWorkflowStore { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashBackupPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: flashPlanPayload(mode: mode, writeRequested: true)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "flash", ok: true, payload: writePayload ?? flashWritePayload(mode: mode)) + ]) + ]) + let store = FlashWorkflowStore(backend: BackendClient(runner: runner)) + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + store.refresh(profile: profile) + + store.backupAndInspect(password: "pw", profile: profile) + try await waitUntilStoreState { store.backup != nil } + store.planFlash(mode: mode, profile: profile) + try await waitUntilStoreState { store.plan != nil } + store.write(mode: mode, password: "pw", profile: profile) + try await waitUntilStoreState { store.writeResult != nil } + return store + } + + 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) + ) + } + + private func flashBackupPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "backup_dir": .string("/tmp/flash-backup"), + "host": .string("10.0.0.2"), + "syap": .string("116"), + "active_bank": .string("primary"), + "banks": .array([ + .object([ + "name": .string("primary"), + "device": .string("/dev/rflash0.raw"), + "size": .number(128), + "sha256": .string("abc"), + "backup_valid": .bool(true), + "active_candidate": .bool(true), + "would_write": .bool(false), + "write_decision": .string("no write") + ]) + ]), + "counts": .object(["banks": .number(1)]), + "summary": .string("Flash backup saved to /tmp/flash-backup.") + ]) + } + + private func flashPlanPayload( + mode: FlashPlanMode, + writeRequested: Bool, + alreadySatisfied: Bool = false, + appleMatched: Bool? = nil, + includeFirmwarePayload: Bool = false + ) -> JSONValue { + var payload: [String: JSONValue] = [ + "schema_version": .number(1), + "backup_dir": .string("/tmp/flash-backup"), + "mode": .string(mode.rawValue), + "write_requested": .bool(writeRequested), + "already_satisfied": .bool(alreadySatisfied), + "active_bank": .string("primary"), + "banks": .array([]), + "flash_plan": .object(["mode": .string(mode.rawValue)]), + "summary": .string(summary(for: mode, alreadySatisfied: alreadySatisfied)) + ] + if let appleMatched { + payload["apple_firmware_match"] = .object([ + "matched": .bool(appleMatched), + "template_source": .string("catalog"), + "template_product_id": .string("116"), + "template_version": .string("7.8.1"), + "template_sha256": .string("template-sha"), + "inner_sha256": .string("inner-sha"), + "inner_size": .number(123), + "key_id": .string("key-one"), + "inner_model": .number(116), + "inner_version": .string("0x00070801") + ]) + } + if includeFirmwarePayload { + payload["firmware_payload"] = .object([ + "template_source": .string("catalog"), + "template_path": .string("/tmp/firmware.basebinary"), + "template_product_id": .string("116"), + "template_version": .string("7.8.1"), + "template_sha256": .string("template-sha"), + "payload_sha256": .string("payload-sha"), + "payload_size": .number(456), + "expected_prefix_sha256": .string("prefix-sha"), + "expected_prefix_size": .number(123), + "key_id": .string("key-one"), + "inner_model": .number(116), + "inner_version": .string("0x00070801"), + "inner_payload_size": .number(123) + ]) + payload["firmware_payload_path"] = .string("/tmp/flash-backup/primary.download_only.basebinary") + } + return .object(payload) + } + + private func summary(for mode: FlashPlanMode, alreadySatisfied: Bool) -> String { + switch mode { + case .checkApple: + return alreadySatisfied + ? "Active firmware bank matches Apple stock firmware 7.8.1." + : "Active firmware bank does not match Apple stock firmware 7.8.1." + case .downloadOnly: + return "Apple restore firmware validated (version 7.8.1, product 116)." + case .patch, .restore: + return "Flash \(mode.rawValue) plan generated." + } + } + + private func flashWritePayload( + mode: FlashPlanMode, + postWriteAction: String? = nil, + rebootRequested: Bool? = nil, + rebooted: Bool? = nil, + waitedAfterReboot: Bool? = nil, + summary: String? = nil + ) -> JSONValue { + let resolvedPostWriteAction = postWriteAction ?? (mode == .restore ? "ssh_reboot" : "manual_power_cycle") + let resolvedRebootRequested = rebootRequested ?? (mode == .restore) + let resolvedRebooted = rebooted ?? (mode == .restore) + let resolvedWaitedAfterReboot = waitedAfterReboot ?? (mode == .restore) + let resolvedSummary = summary ?? { + if mode == .restore { + return "Flash restore write validated; device rebooted." + } + return "Flash \(mode.rawValue) write validated; manual power cycle required." + }() + return .object([ + "schema_version": .number(1), + "backup_dir": .string("/tmp/flash-backup"), + "mode": .string(mode.rawValue), + "write_status": .string("validated"), + "write_validated": .bool(true), + "post_write_action": .string(resolvedPostWriteAction), + "reboot_requested": .bool(resolvedRebootRequested), + "rebooted": .bool(resolvedRebooted), + "waited_after_reboot": .bool(resolvedWaitedAfterReboot), + "write_outcome": .object([ + "status": .string("validated"), + "mode": .string(mode.rawValue), + "write_validated": .bool(true), + "write_may_have_modified_device": .bool(true), + "post_write_action": .string(resolvedPostWriteAction), + "reboot_requested": .bool(resolvedRebootRequested), + "rebooted": .bool(resolvedRebooted), + "waited_after_reboot": .bool(resolvedWaitedAfterReboot) + ]), + "summary": .string(resolvedSummary) + ]) + } + +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift new file mode 100644 index 00000000..bb9799d3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -0,0 +1,214 @@ +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["TCAPSULE_CLIENT"], "macos_gui") + XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1") + } + + func testLocatorPreservesExplicitTelemetryClientEnvironment() 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: ["TCAPSULE_CLIENT": "integration_test"], + currentDirectory: temp.url, + bundle: .main, + fileManager: .default + ) + + let resolution = try locator.resolve(helperPath: helper.path) + let environment = locator.helperEnvironment(for: resolution) + + XCTAssertEqual(environment["TCAPSULE_CLIENT"], "integration_test") + } + + 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) + let pythonPackages = resources.appendingPathComponent("Python/site-packages", 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 FileManager.default.createDirectory(at: pythonPackages, 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/bin", 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/HelperPipeReaderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperPipeReaderTests.swift new file mode 100644 index 00000000..ffd4eab8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperPipeReaderTests.swift @@ -0,0 +1,41 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class HelperPipeReaderTests: XCTestCase { + func testReadabilityPipeReaderStreamsChunksUntilWriterCloses() async throws { + let pipe = Pipe() + let reader = ReadabilityPipeReader() + let task = Task { + var chunks: [Data] = [] + for try await data in reader.chunks(from: pipe.fileHandleForReading) { + chunks.append(data) + } + return chunks + } + + try pipe.fileHandleForWriting.write(contentsOf: Data("first\n".utf8)) + try pipe.fileHandleForWriting.write(contentsOf: Data("second\n".utf8)) + try pipe.fileHandleForWriting.close() + + let chunks = try await task.value + let combined = chunks.reduce(into: Data()) { partial, chunk in + partial.append(chunk) + } + XCTAssertEqual(String(decoding: combined, as: UTF8.self), "first\nsecond\n") + } + + func testReadabilityPipeReaderCancellationStopsBlockedRead() async throws { + let pipe = Pipe() + let reader = ReadabilityPipeReader() + let task = Task { + for try await _ in reader.chunks(from: pipe.fileHandleForReading) {} + } + + try await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + _ = await task.result + + try pipe.fileHandleForWriting.close() + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift new file mode 100644 index 00000000..2ab8b8f9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -0,0 +1,310 @@ +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: """ + input=$(cat) + case "$input" in + *'"request_id":"request-1"'*) ;; + *) exit 2 ;; + esac + echo '{"schema_version":1,"request_id":"req","type":"stage","operation":"capabilities","stage":"start"}' + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"capabilities","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: "capabilities", params: [:], requestID: "request-1") { + 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":"capabilities","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: "capabilities", params: [:], requestID: "request-1") { 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: [:], requestID: "request-1") { + 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: [:], requestID: "request-1") { + 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 testRunnerWritesLargeRequestPayloadWithoutCorruption() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + exec python3 -c ' + import json, sys + request = json.load(sys.stdin) + payload = request["params"]["payload"] + print(json.dumps({ + "schema_version": 1, + "request_id": request["request_id"], + "type": "result", + "operation": request["operation"], + "ok": True, + "payload": { + "length": len(payload), + "prefix": payload[:16], + "suffix": payload[-16:] + } + }), flush=True) + ' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + let largePayload = String(repeating: "abcdef0123456789", count: 8192) + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: ["payload": .string(largePayload)], requestID: "request-1") { + await recorder.append($0) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.last?.type, "result") + XCTAssertEqual(events.last?.ok, true) + guard case .object(let payload)? = events.last?.payload else { + return XCTFail("Expected structured payload") + } + XCTAssertEqual(payload["length"], .number(Double(largePayload.count))) + XCTAssertEqual(payload["prefix"], .string(String(largePayload.prefix(16)))) + XCTAssertEqual(payload["suffix"], .string(String(largePayload.suffix(16)))) + } + + 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: [:], requestID: "request-1") { + 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: "capabilities", params: [:], requestID: "request-1") { + 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: [:], requestID: "request-1") { + 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 testRunnerLetsHelperEmitTerminalEventBeforeCancelledFallback() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + exec python3 -c ' + import json, signal, time + def handler(signum, frame): + print(json.dumps({"schema_version": 1, "request_id": "req", "type": "error", "operation": "doctor", "code": "cancelled", "message": "cancelled by helper"}), flush=True) + raise SystemExit(130) + signal.signal(signal.SIGINT, handler) + print(json.dumps({"schema_version": 1, "request_id": "req", "type": "stage", "operation": "doctor", "stage": "ready"}), flush=True) + while True: + time.sleep(1) + ' + """ + ) + 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: [:], requestID: "request-1") { + await recorder.append($0) + } + } + for _ in 0..<200 { + if await recorder.events.contains(where: { $0.type == "stage" && $0.stage == "ready" }) { + break + } + try await Task.sleep(nanoseconds: 10_000_000) + } + let sawReady = await recorder.events.contains(where: { $0.type == "stage" && $0.stage == "ready" }) + XCTAssertTrue(sawReady) + task.cancel() + let result = await task.value + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 130) + XCTAssertEqual(events.compactMap(\.code), ["cancelled"]) + XCTAssertEqual(events.last?.message, "cancelled by helper") + } + + 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)], requestID: "request-1") { + 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 00000000..3b14c25c --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift @@ -0,0 +1,27 @@ +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))) + } + + func testDisabledPolicyStillSuppressesWarnings() { + XCTAssertNil(HostCompatibilityPolicy.warning( + enabled: false, + for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 5) + )) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift new file mode 100644 index 00000000..6fb32226 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift @@ -0,0 +1,733 @@ +import Combine +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: testActivationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationResultPayload(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 testPublishesWhenBackendFinishesAfterActivationPlanResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + let finishPublished = expectation(description: "MaintenanceStore publishes after backend running state clears") + var didFulfill = false + var cancellables: Set = [] + store.objectWillChange + .sink { [weak store] _ in + Task { @MainActor in + guard !didFulfill, + store?.activateState == .planReady, + store?.isBusy == false else { + return + } + didFulfill = true + finishPublished.fulfill() + } + } + .store(in: &cancellables) + + store.planActivation(password: "pw") + + try await waitUntilStoreState { store.activateState == .planReady } + await fulfillment(of: [finishPublished], timeout: 2) + XCTAssertFalse(store.isBusy) + _ = cancellables + } + + func testSameDeviceRejectedActivationPlanDoesNotEnterPlanning() async throws { + let runner = PausingStoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], pauseBeforeEvents: true) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let profile = DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + let store = MaintenanceStore( + coordinator: coordinator, + laneKey: .deviceWorkflow(profile.id, .maintenance) + ) + + _ = coordinator.run(operation: "doctor", profile: profile) + try await waitUntilStoreState { runner.calls.count == 1 && coordinator.isDeviceBusy(profile) } + let result = store.planActivation(password: "pw", profile: profile) + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.activateState, .failed) + XCTAssertEqual(store.error(for: .activate)?.code, "operation_already_running") + XCTAssertEqual(runner.calls.count, 1) + runner.finishAll() + try await waitUntilStoreState { !store.isRunning } + } + + func testActivationRequiresPlanAndHandlesConfirmationReplay() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .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: testActivationResultPayload(alreadyActive: false)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.runActivation(password: "pw") + XCTAssertEqual(store.activateState, .failed) + XCTAssertEqual(store.error?.code, "activation_plan_required") + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + store.runActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .awaitingConfirmation && store.pendingConfirmation(for: .activate) != nil && !store.isRunning } + + store.confirmPending(for: .activate) + + 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 testConfirmationCancellationRestoresMaintenanceWorkflowState() async throws { + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "activate", id: "activate-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + store.runActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .awaitingConfirmation && store.pendingConfirmation(for: .activate) != nil && !store.isRunning } + store.cancelPendingConfirmation(for: .activate) + + try await waitUntilStoreState { store.activateState == .planReady && store.pendingConfirmation(for: .activate) == nil } + XCTAssertNil(store.error) + } + + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "uninstall", id: "uninstall-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .awaitingConfirmation && store.pendingConfirmation(for: .uninstall) != nil && !store.isRunning } + store.noWait = true + store.cancelPendingConfirmation(for: .uninstall) + + try await waitUntilStoreState { store.uninstallState == .planStale && store.pendingConfirmation(for: .uninstall) == nil } + XCTAssertNil(store.error) + } + + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "fsck", id: "fsck-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.refreshFsckTargets(password: "pw") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + store.runFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .awaitingConfirmation && store.pendingConfirmation(for: .fsck) != nil && !store.isRunning } + store.noWait = true + store.cancelPendingConfirmation(for: .fsck) + + try await waitUntilStoreState { store.fsckState == .planStale && store.pendingConfirmation(for: .fsck) == nil } + XCTAssertNil(store.error) + } + + do { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + confirmationRequired(operation: "repair-xattrs", id: "repair-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.repairPath = "/Volumes/Data" + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + store.runRepairXattrs() + try await waitUntilStoreState { store.repairState == .awaitingConfirmation && store.pendingConfirmation(for: .repairXattrs) != nil && !store.isRunning } + store.repairPath = "/Volumes/Other" + store.cancelPendingConfirmation(for: .repairXattrs) + + try await waitUntilStoreState { store.repairState == .scanStale && store.pendingConfirmation(for: .repairXattrs) == nil } + XCTAssertNil(store.error) + } + } + + 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: testUninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallResultPayload(waited: false, verified: false)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]), + .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, "uninstall_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, "mount_wait_invalid") + 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: testUninstallPlanPayload()) + ]), + .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: testUninstallResultPayload(waited: true, verified: true)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .awaitingConfirmation && store.pendingConfirmation(for: .uninstall) != nil && !store.isRunning } + + store.confirmPending(for: .uninstall) + + 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: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) + ]), + .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: testFsckResultPayload(returncode: 0)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + 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 && store.pendingConfirmation(for: .fsck) != nil && !store.isRunning } + + store.confirmPending(for: .fsck) + + 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: testFsckListPayload(targets: [])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [testFsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: false, payload: testFsckResultPayload(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, "fsck_target_required") + + 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 = testFsckTargetPayload(name: nil, device: "/dev/dk3", mountpoint: "/Volumes/External") + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckListPayload(targets: [ + targetWithoutName, + testFsckTargetPayload(name: "Data") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: testFsckPlanPayload(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, "fsck_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: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(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: testRepairXattrsPayload(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 store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.repairPath = "/Volumes/Data" + store.repairRecursive = false + store.repairMaxDepth = "2" + store.repairIncludeHidden = true + store.repairIncludeTimeMachine = true + store.repairFixPermissions = true + store.repairVerbose = true + + 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)) + XCTAssertEqual(runner.calls[0].params["recursive"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["max_depth"], .number(2)) + XCTAssertEqual(runner.calls[0].params["include_hidden"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["include_time_machine"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["fix_permissions"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["verbose"], .bool(true)) + + store.repairPath = "/Volumes/Other" + XCTAssertEqual(store.repairState, .scanStale) + store.repairPath = "/Volumes/Data" + store.runRepairXattrs() + XCTAssertEqual(store.repairState, .scanStale) + XCTAssertEqual(store.error?.code, "repair_xattrs_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 && store.pendingConfirmation(for: .repairXattrs) != nil && !store.isRunning } + store.confirmPending(for: .repairXattrs) + try await waitUntilStoreState { store.repairState == .repaired } + XCTAssertEqual(store.repairResult?.repairableCount, 0) + XCTAssertEqual(runner.calls[3].params["confirmation_id"], .string("repair-confirm")) + XCTAssertEqual(runner.calls[3].params["recursive"], .bool(false)) + XCTAssertEqual(runner.calls[3].params["max_depth"], .number(2)) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .failed } + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.recovery?.title, "repair-xattrs cannot run") + } + + func testRepairXattrsOptionChangesInvalidateScan() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(findings: 2, repairable: 1)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.repairPath = "/Volumes/Data" + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + XCTAssertTrue(store.canRepairXattrs) + XCTAssertEqual(runner.calls[0].params["recursive"], .bool(true)) + XCTAssertNil(runner.calls[0].params["max_depth"]) + + store.repairMaxDepth = "3" + XCTAssertEqual(store.repairState, .scanStale) + XCTAssertFalse(store.canRepairXattrs) + store.runRepairXattrs() + XCTAssertEqual(store.error?.code, "repair_xattrs_scan_stale") + XCTAssertEqual(runner.calls.count, 1) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + XCTAssertEqual(runner.calls[1].params["max_depth"], .number(3)) + } + + func testRepairXattrsMissingPathZeroRepairableAndMalformedPayload() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: testRepairXattrsPayload(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, "repair_xattrs_path_required") + XCTAssertFalse(store.canScanRepairXattrs) + + store.repairPath = "/Volumes/Data" + store.repairMaxDepth = "-1" + store.scanRepairXattrs() + XCTAssertEqual(store.repairState, .failed) + XCTAssertEqual(store.error?.code, "repair_xattrs_depth_invalid") + XCTAssertEqual(runner.calls, []) + + store.repairMaxDepth = "" + 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 testCoordinatorMaintenanceWorkflowsUseSeparateLanes() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = MaintenanceStore( + coordinator: coordinator, + laneKey: .deviceWorkflow("device-one", .maintenance) + ) + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + + let activateLane = OperationLaneKey.deviceWorkflow("device-one", .activate) + let uninstallLane = OperationLaneKey.deviceWorkflow("device-one", .uninstall) + let legacyMaintenanceLane = OperationLaneKey.deviceWorkflow("device-one", .maintenance) + XCTAssertEqual(coordinator.lane(for: activateLane).backend.events.last?.operation, "activate") + XCTAssertEqual(coordinator.lane(for: uninstallLane).backend.events.last?.operation, "uninstall") + XCTAssertTrue(coordinator.lane(for: legacyMaintenanceLane).backend.events.isEmpty) + XCTAssertEqual(store.timelineEvents(for: .activate).last?.operation, "activate") + XCTAssertEqual(store.timelineEvents(for: .uninstall).last?.operation, "uninstall") + } + + func testMaintenanceWorkflowErrorsDoNotBleedBetweenPages() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "activate", + code: "remote_error", + message: "activation failed" + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: testUninstallPlanPayload()) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .failed && !store.isRunning } + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + + XCTAssertEqual(store.error(for: .activate)?.code, "remote_error") + XCTAssertNil(store.error(for: .uninstall)) + } + + func testClearResetsMaintenanceState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]) + ]) + 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) + ]) + ) + } + +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift new file mode 100644 index 00000000..0fef7a21 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCoordinatorLaneTests.swift @@ -0,0 +1,598 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class OperationCoordinatorLaneTests: XCTestCase { + func testAppAndDeviceOperationsRunInParallel() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseBeforeEvents: true) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deviceContext = context("device-one") + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: deviceContext, + activeDeviceID: "device-one", + laneKey: .device("device-one") + )) + + let deviceLane = coordinator.lane(for: .device("device-one")) + try await waitUntilStoreState { + runner.calls.count == 2 && coordinator.appLane.backend.isRunning && deviceLane.backend.isRunning + } + XCTAssertNil(coordinator.rejectedOperationMessage) + XCTAssertEqual(Set(coordinator.activeOperations.keys), [.app, .device("device-one")]) + + runner.finishAll() + try await waitUntilStoreState { + !coordinator.appLane.backend.isRunning && !deviceLane.backend.isRunning + } + } + + func testSameDeviceLaneRejectsSecondOperation() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + let deviceContext = context("device-one") + + XCTAssertStarted(coordinator.run(operation: "doctor", context: deviceContext, activeDeviceID: "device-one", laneKey: laneKey)) + try await waitUntilStoreState { coordinator.lane(for: laneKey).backend.isRunning && runner.calls.count == 1 } + let second = coordinator.run(operation: "deploy", context: deviceContext, activeDeviceID: "device-one", laneKey: laneKey) + + XCTAssertEqual(second.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessages[laneKey], "Another operation is already running.") + XCTAssertEqual(runner.calls.count, 1) + runner.finishAll() + try await waitUntilStoreState { !coordinator.lane(for: laneKey).backend.isRunning } + } + + func testSameDeviceWorkflowLanesShareResourceLockWithoutSharingEvents() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_smbd") + ], pauseBeforeEvents: true) + ], + .init("reachability", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deployLane = OperationLaneKey.deviceWorkflow("device-one", .deploy) + let reachabilityLane = OperationLaneKey.deviceWorkflow("device-one", .reachability) + let deviceContext = context("device-one") + + XCTAssertStarted(coordinator.run( + operation: "deploy", + context: deviceContext, + activeDeviceID: "device-one", + laneKey: deployLane + )) + try await waitUntilStoreState { coordinator.lane(for: deployLane).backend.isRunning && runner.calls.count == 1 } + + let second = coordinator.run( + operation: "reachability", + context: deviceContext, + activeDeviceID: "device-one", + laneKey: reachabilityLane + ) + + XCTAssertEqual(second.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessages[reachabilityLane], "Another operation is already running.") + XCTAssertTrue(coordinator.isDeviceBusy("device-one")) + XCTAssertEqual(runner.calls.map(\.operation), ["deploy"]) + XCTAssertTrue(coordinator.lane(for: reachabilityLane).backend.events.isEmpty) + + runner.finishAll() + try await waitUntilStoreState { !coordinator.lane(for: deployLane).backend.events.isEmpty } + XCTAssertEqual(coordinator.lane(for: deployLane).backend.events.first?.operation, "deploy") + } + + func testDefaultDeviceOperationRoutesToWorkflowLane() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let doctorLane = OperationLaneKey.deviceWorkflow("device-one", .doctor) + + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one" + )) + + try await waitUntilStoreState { !coordinator.lane(for: doctorLane).backend.events.isEmpty } + XCTAssertEqual(runner.calls.map(\.operation), ["doctor"]) + XCTAssertEqual(coordinator.lane(for: doctorLane).backend.events.last?.operation, "doctor") + XCTAssertTrue(coordinator.lane(for: .device("device-one")).backend.events.isEmpty) + } + + func testDefaultMaintenanceOperationsRouteToWorkflowSpecificLanes() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("activate", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: testActivationPlanPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activateLane = OperationLaneKey.deviceWorkflow("device-one", .activate) + + XCTAssertStarted(coordinator.run( + operation: "activate", + context: context("device-one"), + activeDeviceID: "device-one" + )) + + try await waitUntilStoreState { !coordinator.lane(for: activateLane).backend.events.isEmpty } + XCTAssertEqual(coordinator.lane(for: activateLane).backend.events.last?.operation, "activate") + XCTAssertTrue(coordinator.lane(for: .deviceWorkflow("device-one", .maintenance)).backend.events.isEmpty) + } + + func testPendingWorkflowConfirmationBlocksOtherWorkflowForSameDevice() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "deploy-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deployLane = OperationLaneKey.deviceWorkflow("device-one", .deploy) + let doctorLane = OperationLaneKey.deviceWorkflow("device-one", .doctor) + + XCTAssertStarted(coordinator.run( + operation: "deploy", + params: ["dry_run": .bool(false)], + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: deployLane + )) + try await waitUntilStoreState { + coordinator.lane(for: deployLane).backend.pendingConfirmation != nil + && !coordinator.lane(for: deployLane).backend.isRunning + } + + let rejected = coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: doctorLane + ) + + XCTAssertEqual(rejected.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessages[doctorLane], "Another operation is already running.") + XCTAssertEqual(runner.calls.map(\.operation), ["deploy"]) + XCTAssertTrue(coordinator.lane(for: doctorLane).backend.events.isEmpty) + XCTAssertTrue(coordinator.isDeviceBusy("device-one")) + } + + func testCompletedWorkflowLaneEventsSurviveLaterSameDeviceWorkflowRun() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ]) + ], + .init("reachability", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "reachability", ok: true, payload: testReachabilityPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deployLane = OperationLaneKey.deviceWorkflow("device-one", .deploy) + let reachabilityLane = OperationLaneKey.deviceWorkflow("device-one", .reachability) + let deviceContext = context("device-one") + + XCTAssertStarted(coordinator.run( + operation: "deploy", + context: deviceContext, + activeDeviceID: "device-one", + laneKey: deployLane + )) + try await waitUntilStoreState { !coordinator.lane(for: deployLane).backend.isRunning && !coordinator.lane(for: deployLane).backend.events.isEmpty } + + XCTAssertStarted(coordinator.run( + operation: "reachability", + context: deviceContext, + activeDeviceID: "device-one", + laneKey: reachabilityLane + )) + try await waitUntilStoreState { !coordinator.lane(for: reachabilityLane).backend.events.isEmpty } + + XCTAssertEqual(coordinator.lane(for: deployLane).backend.events.last?.operation, "deploy") + XCTAssertEqual(coordinator.lane(for: reachabilityLane).backend.events.last?.operation, "reachability") + } + + func testSameWorkflowRunsInParallelForDifferentDevices() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ], pauseBeforeEvents: true) + ], + .init("deploy", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let firstLane = OperationLaneKey.deviceWorkflow("device-one", .deploy) + let secondLane = OperationLaneKey.deviceWorkflow("device-two", .deploy) + + XCTAssertStarted(coordinator.run( + operation: "deploy", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: firstLane + )) + XCTAssertStarted(coordinator.run( + operation: "deploy", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: secondLane + )) + + try await waitUntilStoreState { + runner.calls.count == 2 + && coordinator.lane(for: firstLane).backend.isRunning + && coordinator.lane(for: secondLane).backend.isRunning + } + XCTAssertEqual(Set(coordinator.activeOperations.keys), [firstLane, secondLane]) + XCTAssertTrue(coordinator.isDeviceBusy("device-one")) + XCTAssertTrue(coordinator.isDeviceBusy("device-two")) + runner.finishAll() + } + + func testDifferentDeviceLanesRunSameOperationInParallel() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ], + .init("doctor", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: .device("device-one") + )) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + )) + + try await waitUntilStoreState { + runner.calls.count == 2 + && coordinator.lane(for: .device("device-one")).backend.isRunning + && coordinator.lane(for: .device("device-two")).backend.isRunning + } + XCTAssertEqual(Set(runner.calls.compactMap { $0.context?.profileID }), ["device-one", "device-two"]) + XCTAssertEqual(Set(coordinator.activeOperations.keys), [.device("device-one"), .device("device-two")]) + runner.finishAll() + } + + func testAppLaneRejectsSecondAppOperation() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + try await waitUntilStoreState { coordinator.appLane.backend.isRunning && runner.calls.count == 1 } + let second = coordinator.run(operation: "capabilities", laneKey: .app) + + XCTAssertEqual(second.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessages[.app], "Another operation is already running.") + XCTAssertEqual(runner.calls.map(\.operation), ["discover"]) + runner.finishAll() + } + + func testPendingConfirmationBlocksSameLaneButNotOtherLaneAndReplayKeepsContext() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "deploy-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ]) + ], + .init("doctor", profileID: "device-two"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let firstLane = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run( + operation: "deploy", + params: ["dry_run": .bool(false)], + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: firstLane + )) + try await waitUntilStoreState { + coordinator.lane(for: firstLane).backend.pendingConfirmation != nil + && !coordinator.lane(for: firstLane).backend.isRunning + } + + let sameLaneResult = coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: firstLane + ) + XCTAssertEqual(sameLaneResult.rejectionMessage, "Another operation is already running.") + + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-two"), + activeDeviceID: "device-two", + laneKey: .device("device-two") + )) + try await waitUntilStoreState { runner.calls.count == 2 } + + coordinator.confirmPending() + try await waitUntilStoreState { runner.calls.count == 3 && coordinator.pendingConfirmation == nil } + XCTAssertEqual(runner.calls[2].operation, "deploy") + XCTAssertEqual(runner.calls[2].context, context("device-one")) + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("deploy-confirm")) + runner.finishAll() + } + + func testCancelPendingConfirmationClearsTargetLaneAndPublishesCancellationEvent() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "deploy-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run( + operation: "deploy", + params: ["dry_run": .bool(false)], + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: laneKey + )) + try await waitUntilStoreState { + coordinator.lane(for: laneKey).backend.pendingConfirmation != nil + && !coordinator.lane(for: laneKey).backend.isRunning + } + + coordinator.cancelPendingConfirmation() + + try await waitUntilStoreState { + coordinator.pendingConfirmation == nil && coordinator.activeOperations[laneKey] == nil + } + let events = coordinator.lane(for: laneKey).backend.events + XCTAssertEqual(events.last?.code, "confirmation_cancelled") + XCTAssertEqual(runner.calls.count, 1) + } + + func testHasActiveWorkTracksRunningAndPendingConfirmation() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("deploy", profileID: "device-one"): [ + .init(events: [ + confirmationRequiredEvent(operation: "deploy", id: "deploy-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let laneKey = OperationLaneKey.device("device-one") + + XCTAssertFalse(coordinator.hasActiveWork) + XCTAssertStarted(coordinator.run( + operation: "deploy", + params: ["dry_run": JSONValue.bool(false)], + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: laneKey + )) + XCTAssertTrue(coordinator.hasActiveWork) + + try await waitUntilStoreState { + coordinator.lane(for: laneKey).backend.pendingConfirmation != nil + && !coordinator.lane(for: laneKey).backend.isRunning + } + XCTAssertTrue(coordinator.hasActiveWork) + + coordinator.cancelPendingConfirmation() + + try await waitUntilStoreState { !coordinator.hasActiveWork } + } + + func testCancelOnlyCancelsTargetLane() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ], pauseBeforeEvents: true) + ], + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ], pauseBeforeEvents: true) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deviceLaneKey = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: deviceLaneKey + )) + try await waitUntilStoreState { + coordinator.appLane.backend.isRunning && + coordinator.lane(for: deviceLaneKey).backend.isRunning && + runner.calls.count == 2 + } + + coordinator.cancel(laneKey: deviceLaneKey) + runner.finish(.init("doctor", profileID: "device-one")) + + try await waitUntilStoreState { + !coordinator.lane(for: deviceLaneKey).backend.isRunning && coordinator.appLane.backend.isRunning + } + XCTAssertEqual(coordinator.lane(for: deviceLaneKey).backend.events.last?.code, "cancelled") + runner.finishAll() + try await waitUntilStoreState { !coordinator.appLane.backend.isRunning } + } + + func testClearingOneLaneDoesNotClearOtherLaneEvents() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("discover"): [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ], + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let deviceLaneKey = OperationLaneKey.device("device-one") + + XCTAssertStarted(coordinator.run(operation: "discover", laneKey: .app)) + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + laneKey: deviceLaneKey + )) + try await waitUntilStoreState { + !coordinator.appLane.backend.isRunning + && !coordinator.lane(for: deviceLaneKey).backend.isRunning + && !coordinator.appLane.backend.events.isEmpty + && !coordinator.lane(for: deviceLaneKey).backend.events.isEmpty + } + + coordinator.clear(laneKey: .app) + + XCTAssertTrue(coordinator.appLane.backend.events.isEmpty) + XCTAssertFalse(coordinator.lane(for: deviceLaneKey).backend.events.isEmpty) + } + + func testHelperPathChangesSyncToExistingAndNewLanes() async throws { + let coordinator = OperationCoordinator(backend: BackendClient(runner: StoreTestRunner(responses: []))) + let existingLane = coordinator.lane(for: .device("device-one")) + + coordinator.backend.helperPath = "/tmp/tcapsule" + + try await waitUntilStoreState { existingLane.backend.helperPath == "/tmp/tcapsule" } + XCTAssertEqual(existingLane.backend.helperPath, "/tmp/tcapsule") + XCTAssertEqual(coordinator.lane(for: .device("device-two")).backend.helperPath, "/tmp/tcapsule") + } + + func testPasswordCredentialInjectionIsScopedToStartedLane() async throws { + let runner = OperationKeyedStoreTestRunner(responses: [ + .init("doctor", profileID: "device-one"): [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload()) + ]) + ] + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + + XCTAssertStarted(coordinator.run( + operation: "doctor", + context: context("device-one"), + activeDeviceID: "device-one", + password: "secret", + laneKey: .device("device-one") + )) + + try await waitUntilStoreState { runner.calls.count == 1 } + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("secret")])) + XCTAssertEqual(runner.calls[0].context?.profileID, "device-one") + } + + private func XCTAssertStarted( + _ result: OperationStartResult, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .started = result else { + XCTFail("Expected operation to start, got \(result).", file: file, line: line) + return + } + } + + private func context(_ profileID: String) -> DeviceRuntimeContext { + DeviceRuntimeContext( + profileID: profileID, + configURL: URL(fileURLWithPath: "/tmp/\(profileID)/.env") + ) + } + + private func doctorPayload() -> JSONValue { + testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ]) + } + + private func confirmationRequiredEvent(operation: String, id: String) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: "confirmation_required", + message: "Confirm operation.", + details: .object(["confirmation_id": .string(id)]) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCredentialInjectorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCredentialInjectorTests.swift new file mode 100644 index 00000000..6c8e8696 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationCredentialInjectorTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class OperationCredentialInjectorTests: XCTestCase { + func testNilPasswordLeavesParamsUnchanged() { + let params: [String: JSONValue] = ["dry_run": .bool(true)] + + XCTAssertEqual(OperationCredentialInjector.injectingPassword(nil, into: params), params) + } + + func testEmptyPasswordLeavesParamsUnchanged() { + let params: [String: JSONValue] = ["dry_run": .bool(true)] + + XCTAssertEqual(OperationCredentialInjector.injectingPassword(" \n ", into: params), params) + } + + func testNonEmptyPasswordAddsCredentialsWithoutTrimmingValue() { + let params: [String: JSONValue] = ["dry_run": .bool(true)] + + let injected = OperationCredentialInjector.injectingPassword(" secret ", into: params) + + XCTAssertEqual(injected["dry_run"], .bool(true)) + XCTAssertEqual(injected["credentials"], .object(["password": .string(" secret ")])) + } + + func testExistingCredentialsArePreserved() { + let params: [String: JSONValue] = [ + "credentials": .object(["password": .string("existing")]) + ] + + let injected = OperationCredentialInjector.injectingPassword("replacement", into: params) + + XCTAssertEqual(injected["credentials"], .object(["password": .string("existing")])) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift new file mode 100644 index 00000000..4b21bee9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift @@ -0,0 +1,217 @@ +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), ["Upload Payload", "Needs Confirmation", "Done"]) + XCTAssertEqual(timeline[0].risk, "remote_write") + XCTAssertEqual(timeline[0].cancellable, false) + XCTAssertEqual(timeline[0].state, .succeeded) + XCTAssertEqual(timeline[1].state, .warning) + XCTAssertEqual(timeline[2].state, .succeeded) + XCTAssertEqual(timeline[2].detail, "Deployment completed.") + } + + func testStageBecomesSucceededWhenLaterStageForSameOperationAppears() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "validate_artifacts"), + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Check Local Files", "Running Checkup", "Upload Payload"]) + XCTAssertEqual(timeline.map(\.state), [.succeeded, .running, .running]) + } + + func testSuccessfulResultCompletesLastStage() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "uninstall", stage: "build_uninstall_plan"), + BackendEvent(type: "stage", operation: "uninstall", stage: "uninstall_payload"), + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: .object(["summary": .string("removed")])) + ]) + + XCTAssertEqual(timeline.map(\.title), ["Planning Uninstall", "Removing Managed Files", "Done"]) + XCTAssertEqual(timeline.map(\.state), [.succeeded, .succeeded, .succeeded]) + } + + func testFailureDoesNotMarkCurrentStageSucceeded() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload"), + BackendEvent(type: "result", operation: "deploy", ok: false, payload: .object(["summary": .string("upload failed")])) + ]) + + XCTAssertEqual(timeline.map(\.title), ["Upload Payload", "Failed"]) + XCTAssertEqual(timeline.map(\.state), [.failed, .failed]) + } + + func testErrorMarksCurrentStageFailed() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "read_mast"), + BackendEvent(type: "error", operation: "deploy", code: "remote_error", message: "No deployable HFS disk was found.") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Find Payload Volume", "Needs Attention"]) + XCTAssertEqual(timeline.map(\.state), [.failed, .failed]) + } + + func testOperationTitlesAreUserFacing() { + XCTAssertEqual(OperationTimelineBuilder.operationTitle("deploy"), "Install / Update") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("doctor"), "Checkup") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("repair-xattrs"), "File Metadata Repair") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("capabilities"), "App Readiness") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("flash"), "Persistent NetBSD4 Boot Hook") + } + + func testDeployStartupStagesAreUserFacing() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "probe_runtime"), + BackendEvent(type: "stage", operation: "deploy", stage: "post_reboot_activation"), + BackendEvent(type: "stage", operation: "deploy", stage: "verify_runtime_activation") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Check Boot Startup", "Start SMB After Reboot", "Verify SMB Startup"]) + XCTAssertEqual( + timeline.first?.detail, + "Checking whether the device will start TimeCapsuleSMB automatically." + ) + } + + func testConfigureAcpIdentityStageIsUserFacing() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "configure", stage: "acp_identity_probe") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Checking AirPort Identity"]) + } + + func testActivateRuntimeProbeStageIsUserFacing() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "activate", stage: "probe_runtime") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Check Existing Runtime"]) + XCTAssertEqual( + timeline.first?.detail, + "Checking whether TimeCapsuleSMB is already running before activating it." + ) + } + + func testDeployCleanupStageWarnsAboutOldFileDeletion() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "pre_upload_actions") + ]) + + XCTAssertEqual(timeline.map(\.title), ["Stop Existing Runtime"]) + } + + func testDeployUploadStagesAreUserFacingAndPresentTense() { + let timeline = OperationTimelineBuilder.timeline(from: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_smbd"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_mdns_advertiser"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_nbns_advertiser"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_boot_files"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_runtime_config"), + BackendEvent(type: "stage", operation: "deploy", stage: "upload_samba_accounts") + ]) + + XCTAssertEqual(timeline.map(\.title), [ + "Upload smbd", + "Upload mdns-advertiser", + "Upload nbns-advertiser", + "Upload Boot Files", + "Upload Runtime Config", + "Upload Samba Account Files" + ]) + } + + func testDeployRebootStagesUseLocalizedDetails() { + XCTAssertEqual( + OperationTimelineBuilder.stageDetail( + for: "deploy", + stage: "wait_for_reboot_up", + fallback: "raw backend detail" + ), + "Device went down; waiting for it to come back up." + ) + XCTAssertEqual( + OperationTimelineBuilder.stageDetail( + for: "deploy", + stage: "verify_runtime_reboot", + fallback: "raw backend detail" + ), + "Device is back online. Waiting for managed runtime to finish starting." + ) + } + + func testAllKnownDeployStagesHaveLocalizedTitlesAndDetails() { + let deployStages = [ + "load_config", + "resolve_managed_target", + "validate_artifacts", + "check_compatibility", + "read_mast", + "select_payload_home", + "build_deployment_plan", + "pre_upload_actions", + "prepare_deployment_files", + "upload_payload", + "upload_smbd", + "upload_mdns_advertiser", + "upload_nbns_advertiser", + "upload_boot_files", + "upload_runtime_config", + "upload_samba_accounts", + "post_upload_actions", + "verify_payload_upload", + "flush_payload_upload", + "verify_payload_upload_after_sync", + "reboot", + "wait_for_reboot_down", + "wait_for_reboot_up", + "probe_runtime", + "activate_runtime", + "post_reboot_activation", + "verify_runtime_activation", + "verify_runtime_reboot" + ] + + for stage in deployStages { + let title = OperationTimelineBuilder.stageTitle(for: "deploy", stage: stage) + let detail = OperationTimelineBuilder.stageDetail(for: "deploy", stage: stage, fallback: nil) + XCTAssertFalse(title.hasPrefix("timeline."), "\(stage) title should be localized") + XCTAssertFalse(title.contains("_"), "\(stage) title should not fall back to title-cased stage id") + XCTAssertNotNil(detail, "\(stage) should have a localized detail") + XCTAssertFalse(detail?.hasPrefix("timeline.") == true, "\(stage) detail should be localized") + } + } + + func testRemovedNetBSD4DeployActivationStageIsNotMapped() { + XCTAssertEqual(OperationTimelineBuilder.stageTitle(for: "deploy", stage: "netbsd4_activation"), "Netbsd4 Activation") + XCTAssertNil(OperationTimelineBuilder.stageDetail(for: "deploy", stage: "netbsd4_activation", fallback: nil)) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift new file mode 100644 index 00000000..0a501fa1 --- /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":"capabilities","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":"capabilities","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 00000000..62987937 --- /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 00000000..5cb228fa --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -0,0 +1,383 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class PendingConfirmationTests: XCTestCase { + func testLocalizedStringsLoadFromResourceBundle() { + XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") + XCTAssertEqual(L10n.string("toolbar.cancel"), "Cancel") + XCTAssertEqual(L10n.string("toolbar.diagnostics"), "Diagnostics") + XCTAssertEqual(L10n.string("helper.error.cancelled"), "Operation cancelled.") + XCTAssertEqual(L10n.string("confirm.backend.message"), "Continue with this operation?") + XCTAssertEqual(L10n.format("event.summary.result", "deploy", "Finished"), "deploy: Finished") + } + + func testUninstallPlanParamsCarryNoRebootSelection() { + let params = OperationParams.Uninstall.params(dryRun: true, noReboot: true, noWait: true, mountWait: 9) + + XCTAssertEqual(params["dry_run"], .bool(true)) + XCTAssertEqual(params["no_reboot"], .bool(true)) + XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertEqual(params["mount_wait"], .number(9)) + XCTAssertNil(params["credentials"]) + } + + func testDeployRunParamsCarryOptionsWithoutFrontendConsentFlags() { + let params = OperationParams.Deploy.params( + dryRun: false, + noReboot: false, + noWait: true, + nbnsEnabled: true, + debugLogging: true, + ataIdleSeconds: 0, + ataStandby: 0, + mountWait: 45 + ) + + 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["ata_idle_seconds"], .number(0)) + XCTAssertEqual(params["ata_standby"], .number(0)) + XCTAssertEqual(params["mount_wait"], .number(45)) + XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertEqual(params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(params["any_protocol"], .bool(false)) + XCTAssertNil(params["credentials"]) + } + + func testDeployPlanParamsCarryAdvancedRuntimeOverridesWhenEnabled() { + let params = OperationParams.Deploy.params( + dryRun: true, + noReboot: false, + noWait: false, + nbnsEnabled: true, + internalShareUseDiskRoot: true, + anyProtocol: true, + debugLogging: false, + ataIdleSeconds: 0, + ataStandby: nil, + mountWait: 30 + ) + + XCTAssertEqual(params["dry_run"], .bool(true)) + XCTAssertEqual(params["internal_share_use_disk_root"], .bool(true)) + XCTAssertEqual(params["any_protocol"], .bool(true)) + XCTAssertEqual(params["debug_logging"], .bool(false)) + XCTAssertEqual(params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(params["ata_standby"], .string("")) + 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.save( + host: "root@manual", + selectedRecord: selectedRecord, + password: "pw", + debugLogging: true, + internalShareUseDiskRoot: false, + anyProtocol: true, + ataIdleSeconds: 0, + ataStandby: nil, + includeAtaStandby: true + ) + + XCTAssertNil(params["host"]) + XCTAssertEqual(params["selected_record"], selectedRecord) + XCTAssertEqual(params["password"], .string("pw")) + XCTAssertEqual(params["debug_logging"], .bool(true)) + XCTAssertEqual(params["internal_share_use_disk_root"], .bool(false)) + XCTAssertEqual(params["any_protocol"], .bool(true)) + XCTAssertEqual(params["ata_idle_seconds"], .number(0)) + XCTAssertEqual(params["ata_standby"], .string("")) + } + + func testConfigureParamsDefaultBareManualHostToRootUser() { + let params = OperationParams.Configure.save( + 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)) + XCTAssertEqual(params["debug_logging"], .bool(false)) + XCTAssertNil(params["internal_share_use_disk_root"]) + XCTAssertNil(params["any_protocol"]) + } + + func testConfigureParamsDefaultBareIPv6ManualHostToRootUser() { + let params = OperationParams.Configure.save( + host: " fd00::2 ", + password: "pw", + debugLogging: false + ) + + XCTAssertEqual(params["host"], .string("root@fd00::2")) + } + + 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 = OperationCredentialInjector.injectingPassword( + "pw", + into: OperationParams.Uninstall.params(dryRun: false, noReboot: true, noWait: true, mountWait: 12) + ) + + 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 testPendingConfirmationPrefersLocalizedPresentationForKnownBackendKey() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.reboot"), + "presentation_values": .object(["device_name": .string("Time Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Deploy And Reboot?") + XCTAssertEqual(confirmation.message, "Deploy TimeCapsuleSMB and reboot this Time Capsule?") + XCTAssertEqual(confirmation.actionTitle, "Deploy and Reboot") + } + + func testPendingConfirmationUsesLocalizedConfigureEnableSSHCopy() throws { + let event = BackendEvent( + type: "error", + operation: "configure", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("configure.enable_ssh_reboot"), + "presentation_values": .object(["device_name": .string("Office Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Enable SSH And Reboot?") + XCTAssertEqual( + confirmation.message, + "SSH is closed on Office Capsule. Enable SSH using AirPort ACP and reboot this AirPort device?" + ) + XCTAssertEqual(confirmation.actionTitle, "Enable SSH and Reboot") + } + + func testPendingConfirmationUsesLocalizedActivationCopy() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.activate_now"), + "presentation_values": .object(["device_name": .string("Time Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Deploy And Start SMB?") + XCTAssertEqual(confirmation.message, "Deploy TimeCapsuleSMB to this Time Capsule and start SMB without rebooting it?") + XCTAssertEqual(confirmation.actionTitle, "Deploy and Start SMB") + } + + func testPendingConfirmationUsesRestoreWriteRebootCopy() throws { + let event = BackendEvent( + type: "error", + operation: "flash", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("flash.restore_write"), + "presentation_values": .object(["host": .string("10.0.0.2")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Restore Apple Firmware?") + XCTAssertEqual( + confirmation.message, + "Restore Apple stock firmware to the active firmware bank on 10.0.0.2 and reboot after validation?" + ) + XCTAssertEqual(confirmation.actionTitle, "Write Firmware") + } + + func testPendingConfirmationUsesLocalizedNoWaitDeployCopy() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message."), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.netbsd4_no_wait"), + "presentation_values": .object(["device_name": .string("Time Capsule")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Deploy And Request NetBSD4 Reboot?") + XCTAssertEqual( + confirmation.message, + "Deploy TimeCapsuleSMB to this Time Capsule, request reboot, and return immediately without running Samba activation after SSH returns?" + ) + XCTAssertEqual(confirmation.actionTitle, "Deploy and Request Reboot") + } + + func testPendingConfirmationUsesLocalizedQuestionForUninstallWithoutReboot() throws { + let event = BackendEvent( + type: "error", + operation: "uninstall", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "message": .string("Remove managed TimeCapsuleSMB files from the device."), + "action_title": .string("Backend uninstall"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("uninstall.no_reboot"), + "presentation_values": .object(["no_reboot": .bool(true)]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Uninstall?") + XCTAssertEqual(confirmation.message, "Remove managed TimeCapsuleSMB files from the device?") + XCTAssertEqual(confirmation.actionTitle, "Uninstall") + } + + func testPendingConfirmationFormatsLocalizedPresentationValues() throws { + let event = BackendEvent( + type: "error", + operation: "repair-xattrs", + code: "confirmation_required", + message: "Backend fallback.", + details: .object([ + "message": .string("Repair xattrs."), + "action_title": .string("Backend repair"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("repair_xattrs"), + "presentation_values": .object(["path": .string("/Volumes/Data")]) + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Repair Extended Attributes?") + XCTAssertEqual(confirmation.message, "Repair known-safe macOS metadata issues under /Volumes/Data?") + XCTAssertEqual(confirmation.actionTitle, "Repair xattrs") + } + + func testPendingConfirmationFallsBackToBackendTextForUnknownPresentationKey() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Backend event fallback.", + details: .object([ + "title": .string("Backend title"), + "message": .string("Backend message?"), + "action_title": .string("Backend action"), + "confirmation_id": .string("abc123"), + "presentation_id": .string("deploy.future") + ]) + ) + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: [:])) + + XCTAssertEqual(confirmation.title, "Backend title") + XCTAssertEqual(confirmation.message, "Backend message?") + XCTAssertEqual(confirmation.actionTitle, "Backend action") + } + + func testMaintenanceRunParamsDoNotCarryFrontendConsentFlags() { + let fsck = OperationParams.Fsck.run(dryRun: false, volume: "Data", noReboot: true, noWait: true, mountWait: 18) + let repair = OperationParams.RepairXattrs.params( + dryRun: false, + path: "/Volumes/Data", + options: RepairXattrsOptions( + recursive: false, + maxDepth: 4, + includeHidden: true, + includeTimeMachine: true, + fixPermissions: true, + verbose: true + ) + ) + + XCTAssertNil(fsck["confirm_fsck"]) + XCTAssertEqual(fsck["dry_run"], .bool(false)) + 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)) + XCTAssertEqual(repair["recursive"], .bool(false)) + XCTAssertEqual(repair["max_depth"], .number(4)) + XCTAssertEqual(repair["include_hidden"], .bool(true)) + XCTAssertEqual(repair["include_time_machine"], .bool(true)) + XCTAssertEqual(repair["fix_permissions"], .bool(true)) + XCTAssertEqual(repair["verbose"], .bool(true)) + XCTAssertNil(repair["confirm_repair"]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ProgressTextAnimatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ProgressTextAnimatorTests.swift new file mode 100644 index 00000000..0957ff2a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ProgressTextAnimatorTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class ProgressTextAnimatorTests: XCTestCase { + func testRunningMessageCyclesOneTwoAndThreeDots() { + let message = "Run local and remote diagnostic checks." + + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 0), "Run local and remote diagnostic checks.") + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 1), "Run local and remote diagnostic checks..") + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 2), "Run local and remote diagnostic checks...") + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: true, phase: 3), "Run local and remote diagnostic checks.") + } + + func testRunningMessageNormalizesExistingDotsBeforeAnimating() { + XCTAssertEqual(ProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 0), "Resolve target.") + XCTAssertEqual(ProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 1), "Resolve target..") + XCTAssertEqual(ProgressTextAnimator.message("Resolve target...", isRunning: true, phase: 2), "Resolve target...") + } + + func testInactiveMessagesRemainStable() { + let message = "Deployment completed." + + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: false, phase: 0), message) + XCTAssertEqual(ProgressTextAnimator.message(message, isRunning: false, phase: 2), message) + } + + func testEmptyMessagesDoNotAnimate() { + XCTAssertNil(ProgressTextAnimator.message(nil, isRunning: true, phase: 1)) + XCTAssertEqual(ProgressTextAnimator.message("", isRunning: true, phase: 1), "") + XCTAssertEqual(ProgressTextAnimator.message(" ", isRunning: true, phase: 1), " ") + } + + func testFrameIntervalMatchesProgressTextAnimationCadence() { + XCTAssertEqual(ProgressTextAnimator.frameInterval, 0.3) + XCTAssertEqual(ProgressTextAnimator.frameIntervalNanoseconds, 300_000_000) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift new file mode 100644 index 00000000..14b45fb8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift @@ -0,0 +1,154 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class RecoveryActionMapperTests: XCTestCase { + override func tearDown() { + L10n.apply(language: .system) + super.tearDown() + } + + 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: "doctor", + 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 testDeployRecoveryDoesNotShowFinderOrInstallSMBActions() throws { + let recovery = try recoveryValue( + title: "No HFS volumes found", + actions: ["Retry deploy."], + suggestedOperation: "deploy", + actionIDs: ["open_finder", "install_smb"] + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "No deployable HFS disk was found after 10 MaSt queries spaced 3 seconds apart.", + recovery: recovery + ) + + let actions = RecoveryActionMapper.actions(for: error) + + XCTAssertFalse(actions.contains { $0.kind == .openFinder }) + XCTAssertFalse(actions.contains { $0.kind == .installSMB }) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Retry", kind: .retry))) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Copy Diagnostics", kind: .copyDiagnostics))) + } + + 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))) + } + + func testRecoveryGuidancePresentationLocalizesRebootTimeoutDetails() throws { + let recovery = try recoveryValue( + title: "Reboot did not finish", + actions: [ + "Wait a few more minutes.", + "If the device is reachable at a new IP, update TC_HOST or rerun configure.", + "Make sure you are connected to the same network or Wi-Fi as the device.", + "On NetBSD 4 devices, run tcapsule activate once SSH is reachable; deploy did not get far enough to activate Samba after reboot." + ], + actionIDs: ["run_checkup"], + message: "The device went down but SSH did not return before the timeout.", + localizationKey: "deploy.remote_error.wait_for_reboot_up" + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "Timed out waiting for SSH after reboot.", + recovery: recovery + ) + + L10n.apply(language: .english) + let english = RecoveryGuidancePresentation(error: error) + XCTAssertEqual(english.title, "Reboot did not finish") + XCTAssertEqual( + english.detail, + "The payload was uploaded and the reboot request succeeded, but the device did not accept SSH again before the 4 minute timeout. It may still be booting, or it may have come back with a different IP address." + ) + XCTAssertEqual(english.steps.count, 4) + XCTAssertEqual(english.steps[1], "If the device is reachable at a new IP, update TC_HOST or rerun configure.") + + L10n.apply(language: .simplifiedChinese) + let chinese = RecoveryGuidancePresentation(error: error) + XCTAssertEqual(chinese.title, "é‡åÆęœŖå®Œęˆ") + XCTAssertEqual(chinese.steps[0], "å†ē­‰å¾…å‡ åˆ†é’Ÿć€‚") + XCTAssertEqual(chinese.steps.count, 4) + } + + func testRecoveryGuidancePresentationSuppressesDuplicateMessage() throws { + let recovery = try recoveryValue( + title: "No HFS volumes found", + actions: [], + message: "No HFS volumes found" + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "No HFS volumes found", + recovery: recovery + ) + + let presentation = RecoveryGuidancePresentation(error: error) + + XCTAssertNil(presentation.detail) + XCTAssertTrue(presentation.steps.isEmpty) + } + + func testDeployGuidanceUsesStructuredRecoveryInsteadOfGenericTip() throws { + let recovery = try recoveryValue( + title: "Reboot did not finish", + actions: ["Wait a few more minutes."], + message: "The payload was uploaded.", + localizationKey: "deploy.remote_error.wait_for_reboot_up" + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "Timed out waiting for SSH after reboot.", + recovery: recovery + ) + + XCTAssertNil(DeployFailureGuidancePolicy.guidance(for: error)) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAccountResolverTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAccountResolverTests.swift new file mode 100644 index 00000000..d31c480f --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAccountResolverTests.swift @@ -0,0 +1,118 @@ +import Security +import XCTest +@testable import TimeCapsuleSMBApp + +final class SMBAccountResolverTests: XCTestCase { + func testFindsSMBAccountForResolvedHostnameWithoutReadingPasswordData() { + let keychain = AccountLookupKeychainClient(accountsByServer: [ + "AirPort-Time-Capsule.local": "jameschang" + ]) + let resolver = KeychainSMBAccountResolver(keychainClient: keychain) + let profile = makeProfile( + host: "root@192.168.1.72", + bonjourName: "AirPort Time Capsule", + hostname: "AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(resolver.account(for: profile), "jameschang") + XCTAssertEqual(keychain.queries.count, 1) + XCTAssertEqual(keychain.queries[0][kSecClass as String] as? String, kSecClassInternetPassword as String) + XCTAssertEqual(keychain.queries[0][kSecAttrProtocol as String] as? String, kSecAttrProtocolSMB as String) + XCTAssertEqual(keychain.queries[0][kSecReturnAttributes as String] as? Bool, true) + XCTAssertNil(keychain.queries[0][kSecReturnData as String]) + } + + func testFallsBackToLowercaseServerCandidate() { + let keychain = AccountLookupKeychainClient(accountsByServer: [ + "jamess-airport-time-capsule.local": "admin" + ]) + let resolver = KeychainSMBAccountResolver(keychainClient: keychain) + let profile = makeProfile( + host: "root@192.168.1.217", + bonjourName: "James's AirPort Time Capsule", + hostname: "Jamess-AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(resolver.account(for: profile), "admin") + XCTAssertEqual(keychain.queries.map { $0[kSecAttrServer as String] as? String }, [ + "Jamess-AirPort-Time-Capsule.local", + "192.168.1.217", + "jamess-airport-time-capsule.local" + ]) + } + + func testReturnsNilWhenNoSMBAccountExists() { + let keychain = AccountLookupKeychainClient(accountsByServer: [:]) + let resolver = KeychainSMBAccountResolver(keychainClient: keychain) + let profile = makeProfile(host: "root@10.0.0.2", bonjourName: nil, hostname: nil) + + XCTAssertNil(resolver.account(for: profile)) + } + + private func makeProfile( + host: String, + bonjourName: String?, + hostname: String? + ) -> DeviceProfile { + DeviceProfile( + id: "device-one", + displayName: bonjourName ?? "Office Capsule", + host: host, + bonjourName: bonjourName, + bonjourFullname: bonjourName.map { "\($0)._airport._tcp.local." }, + hostname: hostname, + addresses: [], + syap: nil, + model: nil, + osName: nil, + osRelease: nil, + arch: nil, + elfEndianness: nil, + payloadFamily: nil, + deviceGeneration: nil, + configPath: "/tmp/device-one/.env", + keychainAccount: "device-one", + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + lastCheckup: nil, + lastDeployState: nil, + settings: .default, + passwordState: .available + ) + } +} + +private final class AccountLookupKeychainClient: KeychainClient { + let accountsByServer: [String: String] + private(set) var queries: [[String: Any]] = [] + + init(accountsByServer: [String: String]) { + self.accountsByServer = accountsByServer + } + + func copyMatching(_ query: [String: Any], result: inout CFTypeRef?) -> OSStatus { + queries.append(query) + guard let server = query[kSecAttrServer as String] as? String, + let account = accountsByServer[server] else { + return errSecItemNotFound + } + result = [kSecAttrAccount as String: account] as CFDictionary + return errSecSuccess + } + + func add(_ query: [String: Any]) -> OSStatus { + errSecSuccess + } + + func update(_ query: [String: Any], attributes: [String: Any]) -> OSStatus { + errSecSuccess + } + + func delete(_ query: [String: Any]) -> OSStatus { + errSecSuccess + } + + func message(for status: OSStatus) -> String? { + "status \(status)" + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift new file mode 100644 index 00000000..36d8566d --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/SMBAddressPolicyTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class SMBAddressPolicyTests: XCTestCase { + func testPrefersBonjourSMBServiceOverResolvedHostname() { + let profile = makeProfile( + host: "root@10.0.0.2", + bonjourName: "AirPort Time Capsule", + bonjourFullname: "AirPort Time Capsule._airport._tcp.local.", + hostname: "AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "AirPort Time Capsule._smb._tcp.local") + XCTAssertEqual( + SMBAddressPolicy.url(for: profile, account: "James Chang")?.absoluteString, + "smb://James%20Chang@AirPort%20Time%20Capsule._smb._tcp.local" + ) + } + + func testFallsBackToConfiguredHostWhenBonjourHostnameIsMissing() { + let profile = makeProfile(host: "root@10.0.0.2", bonjourName: nil, bonjourFullname: nil, hostname: nil) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "10.0.0.2") + XCTAssertEqual(SMBAddressPolicy.url(for: profile)?.absoluteString, "smb://10.0.0.2") + } + + func testFallsBackToAddressInventoryBeforeConfiguredHostAndFormatsIPv6URLs() { + let profile = makeProfile( + host: "root@capsule.local", + bonjourName: nil, + bonjourFullname: nil, + hostname: nil, + addresses: ["fd00::2"] + ) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "fd00::2") + XCTAssertEqual(SMBAddressPolicy.url(for: profile, account: "James Chang")?.absoluteString, "smb://James%20Chang@[fd00::2]") + } + + func testTrimsURLPathAndTrailingDotFromHostCandidates() { + let profile = makeProfile( + host: "smb://office-capsule.local./Data", + bonjourName: nil, + bonjourFullname: nil, + hostname: " " + ) + + XCTAssertEqual(SMBAddressPolicy.preferredHost(for: profile), "office-capsule.local") + XCTAssertEqual(SMBAddressPolicy.url(for: profile)?.absoluteString, "smb://office-capsule.local") + } + + func testCredentialServerCandidatesUseResolvedHostNotBonjourServiceName() { + let profile = makeProfile( + host: "root@10.0.0.2", + bonjourName: "AirPort Time Capsule", + bonjourFullname: "AirPort Time Capsule._airport._tcp.local.", + hostname: "AirPort-Time-Capsule.local." + ) + + XCTAssertEqual(SMBAddressPolicy.credentialServerCandidates(for: profile), [ + "AirPort-Time-Capsule.local", + "10.0.0.2" + ]) + } + + func testReturnsNilWhenNoUsableHostExists() { + let profile = makeProfile(host: " ", bonjourName: nil, bonjourFullname: nil, hostname: ".") + + XCTAssertNil(SMBAddressPolicy.preferredHost(for: profile)) + XCTAssertNil(SMBAddressPolicy.url(for: profile)) + } + + private func makeProfile( + host: String, + bonjourName: String? = "Office Capsule", + bonjourFullname: String? = "Office Capsule._airport._tcp.local.", + hostname: String?, + addresses: [String] = [] + ) -> DeviceProfile { + DeviceProfile( + id: "device-one", + displayName: "Office Capsule", + host: host, + bonjourName: bonjourName, + bonjourFullname: bonjourFullname, + hostname: hostname, + addresses: addresses, + syap: "119", + model: "TimeCapsule6,116", + osName: nil, + osRelease: nil, + arch: nil, + elfEndianness: nil, + payloadFamily: nil, + deviceGeneration: nil, + configPath: "/tmp/device-one/.env", + keychainAccount: "device-one", + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + lastCheckup: nil, + lastDeployState: nil, + settings: .default, + passwordState: .available + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift new file mode 100644 index 00000000..17560663 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -0,0 +1,956 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +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("In-memory password store 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("In-memory password store save failed.") + } + passwords[account] = password + invalidAccounts.remove(account) + } + + func deletePassword(for account: String) throws { + if deleteFailure != nil { + throw PasswordStoreError.unavailable("In-memory password store delete failed.") + } + passwords.removeValue(forKey: account) + invalidAccounts.remove(account) + } + + func markInvalid(account: String) { + invalidAccounts.insert(account) + } + + func credentialAvailability(for account: String) -> CredentialAvailability { + if readFailure != nil { + return .unavailable("In-memory password store read failed.") + } + return passwords[account] == nil ? .missing : .available + } + + func state(for account: String) -> DevicePasswordState { + if readFailure != nil { + return .keychainUnavailable + } + if invalidAccounts.contains(account) { + return .invalid + } + return passwords[account] == nil ? .missing : .available + } +} + +private func writeConfigureArtifactIfNeeded( + operation: String, + context: DeviceRuntimeContext?, + events: [BackendEvent] +) { + guard operation == "configure", + let context, + events.contains(where: { $0.operation == "configure" && $0.type == "result" && $0.ok == true }) else { + return + } + let text = "TC_HOST=root@10.0.0.2\nTC_SSH_OPTS=\n" + try? FileManager.default.createDirectory(at: context.configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? text.write(to: context.configURL, atomically: true, encoding: .utf8) +} + +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 + let pauseBeforeEvents: Bool + let pauseAfterEvents: Bool + + init( + events: [BackendEvent], + result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: UInt64 = 0, + pauseBeforeEvents: Bool = false, + pauseAfterEvents: Bool = false + ) { + self.events = events + self.result = result + self.delayNanoseconds = delayNanoseconds + self.pauseBeforeEvents = pauseBeforeEvents + self.pauseAfterEvents = pauseAfterEvents + } + } + + 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], + requestID: String, + 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.", + requestId: requestID + )], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + } + return storedResponses.removeFirst() + } + writeConfigureArtifactIfNeeded(operation: operation, context: context, events: response.events) + + 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"), + requestId: requestID + )) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in response.events { + await onEvent(event.withRequestId(requestID)) + } + return response.result + } +} + +final class PausingStoreTestRunner: HelperRunning, @unchecked Sendable { + typealias Call = StoreTestRunner.Call + typealias Response = StoreTestRunner.Response + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.PausingStoreTestRunner") + private let pauseGate = PauseGate() + private var storedResponses: [Response] + private var storedCalls: [Call] = [] + + init(responses: [Response]) { + self.storedResponses = responses + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func finishAll() { + pauseGate.resumeAll() + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + requestID: String, + 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 pausing test response queued.", + requestId: requestID + )], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + } + return storedResponses.removeFirst() + } + writeConfigureArtifactIfNeeded(operation: operation, context: context, events: response.events) + + if response.pauseBeforeEvents { + await pauseGate.wait() + } + if Task.isCancelled { + await onEvent(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + requestId: requestID + )) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in response.events { + await onEvent(event.withRequestId(requestID)) + } + if response.pauseAfterEvents { + await pauseGate.wait() + } + if Task.isCancelled { + await onEvent(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + requestId: requestID + )) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + return response.result + } +} + +private final class PauseGate: @unchecked Sendable { + private let lock = NSLock() + private var continuations: [UUID: CheckedContinuation] = [:] + private var isOpen = false + + func wait() async { + let id = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + lock.lock() + if isOpen { + lock.unlock() + continuation.resume() + return + } + continuations[id] = continuation + lock.unlock() + } + } onCancel: { + resume(id) + } + } + + func resumeAll() { + lock.lock() + isOpen = true + let pending = Array(continuations.values) + continuations.removeAll() + lock.unlock() + pending.forEach { $0.resume() } + } + + private func resume(_ id: UUID) { + lock.lock() + let continuation = continuations.removeValue(forKey: id) + lock.unlock() + continuation?.resume() + } +} + +final class OperationKeyedStoreTestRunner: HelperRunning, @unchecked Sendable { + struct Key: Hashable, Sendable { + let operation: String + let profileID: String? + + init(_ operation: String, profileID: String? = nil) { + self.operation = operation + self.profileID = profileID + } + } + + typealias Call = StoreTestRunner.Call + typealias Response = StoreTestRunner.Response + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.OperationKeyedStoreTestRunner") + private var storedResponses: [Key: [Response]] + private var storedCalls: [Call] = [] + private var pauseGates: [Key: PauseGate] = [:] + + init(responses: [Key: [Response]]) { + self.storedResponses = responses + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func finishAll() { + let gates = queue.sync { Array(pauseGates.values) } + gates.forEach { $0.resumeAll() } + } + + func finish(_ key: Key) { + let gate = queue.sync { pauseGates[key] } + gate?.resumeAll() + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + requestID: String, + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let (response, pauseGate) = queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params, context: context)) + let key = Key(operation, profileID: context?.profileID) + if var responses = storedResponses[key], !responses.isEmpty { + let response = responses.removeFirst() + storedResponses[key] = responses + let pauseGate = pauseGates[key] ?? PauseGate() + pauseGates[key] = pauseGate + return (response, pauseGate) + } + let fallbackKey = Key(operation) + if var responses = storedResponses[fallbackKey], !responses.isEmpty { + let response = responses.removeFirst() + storedResponses[fallbackKey] = responses + let pauseGate = pauseGates[fallbackKey] ?? PauseGate() + pauseGates[fallbackKey] = pauseGate + return (response, pauseGate) + } + let pauseGate = pauseGates[key] ?? PauseGate() + pauseGates[key] = pauseGate + return (Response( + events: [BackendEvent.error( + operation: operation, + code: "missing_test_response", + message: "No keyed test response queued.", + requestId: requestID + )], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ), pauseGate) + } + writeConfigureArtifactIfNeeded(operation: operation, context: context, events: response.events) + + if response.delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: response.delayNanoseconds) + } + if response.pauseBeforeEvents { + await pauseGate.wait() + } + if Task.isCancelled { + await onEvent(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + requestId: requestID + )) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in response.events { + await onEvent(event.withRequestId(requestID)) + } + if response.pauseAfterEvents { + await pauseGate.wait() + } + 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] = [], + message: String? = nil, + localizationKey: String? = nil +) -> JSONValue { + var values: [String: JSONValue] = [ + "title": .string(title), + "message": .string(message ?? title), + "actions": .array(actions.map(JSONValue.string)), + "action_ids": .array(actionIDs.map(JSONValue.string)), + "retryable": .bool(true), + "suggested_operation": .string(suggestedOperation) + ] + if let localizationKey { + values["localization_key"] = .string(localizationKey) + } + return .object(values) +} + +func testDeviceRecord( + name: String = "Office Capsule", + hostname: String = "office-capsule.local.", + ipv4: [String] = ["10.0.0.2"], + ipv6: [String] = [], + 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(ipv6.map(JSONValue.string)), + "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? = nil, + sshHost: String? = nil, + linkLocalOnly: Bool = false, + syap: String? = "119", + model: String? = "Time Capsule", + fullname: String = "Office Capsule._airport._tcp.local.", + selectedRecord: JSONValue? = nil +) -> JSONValue { + let hostIsIPv6 = host.contains(":") + let resolvedIPv4 = ipv4 ?? (hostIsIPv6 ? [] : [host]) + let resolvedIPv6 = ipv6.isEmpty && hostIsIPv6 ? [host] : ipv6 + let resolvedPreferredIPv4 = preferredIPv4 ?? resolvedIPv4.first { !$0.hasPrefix("169.254.") } + let resolvedAddresses = addresses ?? (resolvedIPv4 + resolvedIPv6) + let resolvedSSHHost = sshHost ?? ((resolvedPreferredIPv4 != nil || !resolvedIPv6.isEmpty) ? "root@\(host)" : nil) + let record = selectedRecord ?? testDeviceRecord( + name: name, + hostname: hostname, + ipv4: resolvedIPv4, + ipv6: resolvedIPv6, + syap: syap ?? "", + model: model ?? "", + fullname: fullname + ) + return .object([ + "id": .string(id), + "name": .string(name), + "host": .string(host), + "ssh_host": resolvedSSHHost.map(JSONValue.string) ?? .null, + "hostname": .string(hostname), + "addresses": .array(resolvedAddresses.map(JSONValue.string)), + "ipv4": .array(resolvedIPv4.map(JSONValue.string)), + "ipv6": .array(resolvedIPv6.map(JSONValue.string)), + "preferred_ipv4": resolvedPreferredIPv4.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 ipv4 = testStringArray(record, for: "ipv4") + let ipv6 = testStringArray(record, for: "ipv6") + let preferredIPv4 = ipv4.first { !$0.hasPrefix("169.254.") } + let host = preferredIPv4 ?? ipv6.first ?? hostname + let sshHost = preferredIPv4 != nil || !ipv6.isEmpty ? "root@\(host)" : nil + return testDiscoveredDevice( + id: "bonjour:\(fullname.lowercased())", + name: name, + host: host, + hostname: hostname, + addresses: ipv4 + ipv6, + ipv4: ipv4, + ipv6: ipv6, + preferredIPv4: preferredIPv4, + sshHost: sshHost, + 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) device(s).") + ]) +} + +func testStringArray(_ value: JSONValue, for key: String) -> [String] { + guard case .object(let object) = value, + case .array(let values)? = object[key] else { + return [] + } + return values.compactMap { item in + guard case .string(let string) = item else { + return nil + } + return string + } +} + +extension DiscoveredDevice { + 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: "|") + + let resolvedName = record.name.isEmpty ? (record.hostname.isEmpty ? "AirPort Device" : record.hostname) : record.name + let addresses = Self.testNetworkAddresses(ipv4: record.ipv4, ipv6: record.ipv6) + let identity = DeviceNetworkIdentity( + configuredSSHTarget: "", + hostname: record.hostname, + bonjourName: resolvedName, + bonjourFullname: record.fullname, + addresses: addresses + ) + + let connectionTarget = identity.preferredSetupTarget + self.init( + id: stableID.isEmpty ? "discovered-\(index)" : stableID, + name: resolvedName, + connectionTarget: connectionTarget, + sshHost: DeviceEndpointPolicy.rootSSHTarget(connectionTarget), + hostname: record.hostname, + networkAddresses: identity.addresses, + syap: Self.testNonEmpty(record.properties["syAP"] ?? record.properties["syap"]), + model: Self.testNonEmpty(record.properties["model"] ?? record.properties["am"]), + rawRecord: record.jsonValue + ) + } + + private static func testNetworkAddresses(ipv4: [String], ipv6: [String]) -> [DeviceNetworkAddress] { + var addresses = ipv4.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + addresses += ipv6.compactMap { DeviceNetworkAddress(value: $0, source: .bonjour) } + return DeviceEndpointPolicy.uniqueAddresses(addresses) + } + + private static func testNonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} + +func testConfigurePayload( + host: String = "10.0.0.2", + configPath: String = "/tmp/profile/.env", + syap: String = "119", + model: String = "Time Capsule", + payloadFamily: String = "netbsd6_samba4", + deviceGeneration: String = "tc_gen4" +) -> 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(deviceGeneration), + "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", + deviceGeneration: String = "tc_gen4" +) throws -> ConfiguredDeviceState { + ConfiguredDeviceState(payload: try testConfigurePayload( + host: host, + configPath: configPath, + syap: syap, + model: model, + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration + ).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, code: String? = nil) -> JSONValue { + var details: [String: JSONValue] = ["domain": .string(domain)] + if let code { + details["code"] = .string(code) + } + return .object([ + "status": .string(status), + "message": .string(message), + "details": .object(details) + ]) +} + +func testReachabilityPayload( + status: String = "reachable", + summary: String = "SSH reachable; SMB port reachable." +) -> JSONValue { + .object([ + "schema_version": .number(1), + "status": .string(status), + "ssh_host": .string("root@10.0.0.2"), + "smb_host": .string("10.0.0.2"), + "checks": .array([ + .object([ + "id": .string("ssh_port"), + "status": .string(status == "unreachable" ? "FAIL" : "PASS"), + "message": .string("SSH port checked."), + "host": .string("10.0.0.2") + ]), + .object([ + "id": .string("smb_port"), + "status": .string(status == "reachable" ? "PASS" : "FAIL"), + "message": .string("SMB port checked."), + "host": .string("10.0.0.2") + ]) + ]), + "counts": .object([ + "PASS": .number(status == "reachable" ? 2 : (status == "partial" ? 1 : 0)), + "FAIL": .number(status == "reachable" ? 0 : (status == "partial" ? 1 : 2)) + ]), + "summary": .string(summary) + ]) +} + +func testDeployPlanPayload( + payloadFamily: String = "netbsd6_samba4", + netbsd4: Bool? = nil, + requiresReboot: Bool = true, + startupMode: DeployStartupMode? = nil +) -> JSONValue { + let isNetBSD4 = netbsd4 ?? payloadFamily.localizedCaseInsensitiveContains("netbsd4") + let resolvedStartupMode = startupMode ?? DeployStartupMode.fallback( + netbsd4: isNetBSD4, + requiresReboot: requiresReboot + ) + return .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(isNetBSD4), + "requires_reboot": .bool(requiresReboot), + "reboot_required": .bool(requiresReboot), + "startup_mode": .string(resolvedStartupMode.rawValue), + "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, + netbsd4: Bool = false +) -> JSONValue { + .object([ + "schema_version": .number(1), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "netbsd4": .bool(netbsd4), + "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.") + ]) +} + +func testDeployState( + status: DeviceDeployStateStatus = .succeeded, + startedAt: Date = Date(timeIntervalSince1970: 120), + updatedAt: Date = Date(timeIntervalSince1970: 120), + finishedAt: Date? = Date(timeIntervalSince1970: 120), + stage: String? = nil, + payloadFamily: String? = "netbsd6_samba4", + rebootRequested: Bool? = true, + verified: Bool? = true, + summary: String = "installed", + errorCode: String? = nil, + errorMessage: String? = nil, + recovery: DeviceRecoverySnapshot? = nil +) -> DeviceDeployStateSnapshot { + DeviceDeployStateSnapshot( + operationID: nil, + startedAt: startedAt, + updatedAt: updatedAt, + finishedAt: finishedAt, + status: status, + stage: stage, + payloadFamily: payloadFamily, + rebootRequested: rebootRequested, + verified: verified, + summary: summary, + errorCode: errorCode, + errorMessage: errorMessage, + recovery: recovery + ) +} + +func testRuntimeState( + state: DeviceRuntimeState = .installedVerified, + source: DeviceRuntimeEvidenceSource = .deploy, + stage: String? = nil, + payloadFamily: String? = "netbsd6_samba4", + verified: Bool? = true, + summary: String = "installed", + errorCode: String? = nil, + errorMessage: String? = nil, + recovery: DeviceRecoverySnapshot? = nil +) -> DeviceRuntimeStateSnapshot { + DeviceRuntimeStateSnapshot( + state: state, + source: source, + stage: stage, + payloadFamily: payloadFamily, + verified: verified, + summary: summary, + errorCode: errorCode, + errorMessage: errorMessage, + recovery: recovery + ) +} + +func testActivationPlanPayload() -> 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.") + ]) +} + +func testActivationResultPayload(alreadyActive: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "already_active": .bool(alreadyActive), + "summary": .string(alreadyActive ? "NetBSD4 payload was already active." : "NetBSD4 activation completed.") + ]) +} + +func testUninstallPlanPayload() -> 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.") + ]) +} + +func testUninstallResultPayload(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) + ]) +} + +func testFsckListPayload(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).") + ]) +} + +func testFsckTargetPayload( + 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) +} + +func testFsckPlanPayload( + target: JSONValue? = nil, + device: String = "/dev/dk2", + mountpoint: String = "/Volumes/dk2" +) -> JSONValue { + .object([ + "schema_version": .number(1), + "target": target ?? testFsckTargetPayload(name: "Data"), + "device": .string(device), + "mountpoint": .string(mountpoint), + "reboot_required": .bool(true), + "wait_after_reboot": .bool(false), + "summary": .string("Dry-run plan generated for fsck.") + ]) +} + +func testFsckResultPayload(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("Disk repair completed with fsck.") + ]) +} + +func testRepairXattrsPayload(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("Found \(findings) metadata issue(s), \(repairable) repairable."), + "summary_text": .string("Found \(findings) metadata issue(s), \(repairable) repairable.") + ]) +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift new file mode 100644 index 00000000..9e16daeb --- /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/Tests/TimeCapsuleSMBAppTests/ViewRenderTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ViewRenderTestSupport.swift new file mode 100644 index 00000000..0bc5398c --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ViewRenderTestSupport.swift @@ -0,0 +1,138 @@ +import AppKit +import SwiftUI +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +struct AppViewFixture { + let temp: TemporaryDirectory + let appStore: AppStore + let registry: DeviceRegistryStore + let passwordStore: InMemoryPasswordStore + let runner: any HelperRunning + let composition: AppViewComposition + + init(responses: [StoreTestRunner.Response] = []) async throws { + try await self.init(runner: StoreTestRunner(responses: responses)) + } + + init(runner: any HelperRunning) async throws { + temp = try TemporaryDirectory() + registry = DeviceRegistryStore(applicationSupportURL: temp.url) + await registry.load() + self.runner = runner + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + passwordStore = InMemoryPasswordStore() + appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.backend), + appSettingsStore: AppSettingsStore(settingsURL: temp.url.appendingPathComponent("app-settings.json")), + deviceRegistry: registry, + operationCoordinator: coordinator, + passwordStore: passwordStore + ) + composition = AppViewComposition(appStore: appStore) + } + + var contentView: ContentView { + ContentView(composition: composition, startsAutomatically: false) + } + + func saveProfile( + id: DeviceProfile.ID, + host: String = "root@10.0.0.2", + passwordState: DevicePasswordState = .available, + password: String? = "pw" + ) async throws -> DeviceProfile { + let profile = try await registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: host), + discoveredDevice: nil, + passwordState: passwordState, + preferredID: id + ) + if let password { + try passwordStore.save(password, for: profile.keychainAccount) + } + return profile + } + + func dashboardSession(for profile: DeviceProfile) -> DeviceDashboardSession { + composition.dashboardStore.session(for: profile) + } +} + +@MainActor +@discardableResult +func assertRendersNonBlank( + _ view: V, + size: CGSize = CGSize(width: 1200, height: 800), + minimumDistinctPixelCount: Int = 8, + file: StaticString = #filePath, + line: UInt = #line +) throws -> NSBitmapImageRep { + let bitmap = try renderView(view, size: size) + XCTAssertGreaterThan(bitmap.pixelsWide, 0, file: file, line: line) + XCTAssertGreaterThan(bitmap.pixelsHigh, 0, file: file, line: line) + XCTAssertGreaterThan( + sampledDistinctPixelCount(in: bitmap), + minimumDistinctPixelCount, + "Rendered view appears blank or visually uniform.", + file: file, + line: line + ) + return bitmap +} + +@MainActor +func renderView(_ view: V, size: CGSize) throws -> NSBitmapImageRep { + let host = NSHostingView(rootView: view) + host.frame = CGRect(origin: .zero, size: size) + host.wantsLayer = true + host.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + host.layoutSubtreeIfNeeded() + host.displayIfNeeded() + + guard let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { + throw ViewRenderError.bitmapCreationFailed + } + host.cacheDisplay(in: host.bounds, to: bitmap) + return bitmap +} + +private enum ViewRenderError: Error { + case bitmapCreationFailed +} + +private func sampledDistinctPixelCount(in bitmap: NSBitmapImageRep) -> Int { + let width = bitmap.pixelsWide + let height = bitmap.pixelsHigh + guard width > 0, height > 0, let baseline = bitmap.colorAt(x: width / 2, y: height / 2) else { + return 0 + } + + let xStep = max(width / 32, 1) + let yStep = max(height / 32, 1) + var distinct = 0 + for y in stride(from: 0, to: height, by: yStep) { + for x in stride(from: 0, to: width, by: xStep) { + guard let color = bitmap.colorAt(x: x, y: y) else { + continue + } + if color.distance(from: baseline) > 0.08 { + distinct += 1 + } + } + } + return distinct +} + +private extension NSColor { + func distance(from other: NSColor) -> CGFloat { + let left = usingColorSpace(.deviceRGB) ?? self + let right = other.usingColorSpace(.deviceRGB) ?? other + return abs(left.redComponent - right.redComponent) + + abs(left.greenComponent - right.greenComponent) + + abs(left.blueComponent - right.blueComponent) + + abs(left.alphaComponent - right.alphaComponent) + } +} diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py new file mode 100755 index 00000000..4e1bbdd3 --- /dev/null +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -0,0 +1,1799 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import plistlib +import shutil +import subprocess +import sys +import tempfile +import urllib.request +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = PACKAGE_ROOT.parents[1] +SRC_ROOT = REPO_ROOT / "src" +sys.path.insert(0, str(SRC_ROOT)) + +from timecapsulesmb.core.release import CLI_VERSION, CLI_VERSION_CODE # noqa: E402 + +APP_NAME = "TimeCapsuleSMB" +PRODUCT_NAME = "TimeCapsuleSMB" +APP_VERSION = CLI_VERSION +APP_VERSION_CODE = str(CLI_VERSION_CODE) +APP_ICON_FILE = f"{PRODUCT_NAME}.icns" +APP_ICON_NAME = PRODUCT_NAME +DEFAULT_ICON_SOURCE = PACKAGE_ROOT / "Assets" / "AppIcon" / "tcs.jpg" +ARTIFACT_MANIFEST = REPO_ROOT / "src" / "timecapsulesmb" / "assets" / "artifact-manifest.json" +RESOURCE_BUNDLE_NAME = "TimeCapsuleSMBMac_TimeCapsuleSMBApp.bundle" +PYTHON_RUNTIME_VERSION = "3.13.13" +PYTHON_RUNTIME_URL = f"https://www.python.org/ftp/python/{PYTHON_RUNTIME_VERSION}/python-{PYTHON_RUNTIME_VERSION}-macos11.pkg" +PYTHON_FRAMEWORK_NAME = "Python.framework" +APP_BUNDLED_PYTHON_REQUIREMENTS = ("certifi>=2024.8.30",) +DEFAULT_ARCHITECTURES = ("arm64", "x86_64") +CACHE_KEY_VERSION = 1 +PYTHON_RUNTIME_CACHE_VERSION = 2 +PYTHON_SITE_PACKAGES_CACHE_VERSION = 2 +APP_ICON_CACHE_VERSION = 1 +NATIVE_TOOLS_CACHE_VERSION = 1 +CACHE_COMPLETE_MARKER = ".complete" +CACHE_MANIFEST_FILE = "manifest.json" +PACKAGE_CACHE_IGNORED_NAMES = {"__pycache__", ".DS_Store"} +PACKAGE_CACHE_IGNORED_SUFFIXES = {".pyc", ".pyo"} +PYTHON_SUBPROCESS_BYTECODE_CACHE = "python-bytecode" +APP_ICON_ENTRIES = [ + ("icon_16x16.png", 16), + ("icon_16x16@2x.png", 32), + ("icon_32x32.png", 32), + ("icon_32x32@2x.png", 64), + ("icon_128x128.png", 128), + ("icon_128x128@2x.png", 256), + ("icon_256x256.png", 256), + ("icon_256x256@2x.png", 512), + ("icon_512x512.png", 512), + ("icon_512x512@2x.png", 1024), +] +SWIFT_TRIPLES = { + "arm64": "arm64-apple-macosx14.0", + "x86_64": "x86_64-apple-macosx14.0", +} +REQUIRED_HOST_TOOLS = ("sshpass", "smbclient") +BONJOUR_SERVICE_TYPES = [ + "_airport._tcp", + "_smb._tcp", + "_adisk._tcp", + "_device-info._tcp", +] + + +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 run_quiet(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + text=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def native_architecture() -> str: + machine = platform.machine().lower() + if machine in {"arm64", "arm64e"}: + return "arm64" + if machine in {"x86_64", "amd64"}: + return "x86_64" + raise RuntimeError(f"Unsupported macOS build architecture: {machine}") + + +def resolve_architectures(values: list[str] | None) -> tuple[str, ...]: + requested = values or ["universal"] + architectures: list[str] = [] + for value in requested: + if value == "universal": + candidates = list(DEFAULT_ARCHITECTURES) + elif value == "native": + candidates = [native_architecture()] + else: + candidates = [value] + for candidate in candidates: + if candidate not in SWIFT_TRIPLES: + raise RuntimeError(f"Unsupported architecture: {candidate}") + if candidate not in architectures: + architectures.append(candidate) + return tuple(architectures) + + +def swift_build_dir(configuration: str, architecture: str) -> Path: + return PACKAGE_ROOT / ".build" / f"{architecture}-apple-macosx" / configuration + + +def build_swift(configuration: str, architectures: tuple[str, ...]) -> tuple[Path, Path]: + executables: list[Path] = [] + build_dirs: list[Path] = [] + for architecture in architectures: + run([ + "swift", + "build", + "-c", + configuration, + "--triple", + SWIFT_TRIPLES[architecture], + "--product", + PRODUCT_NAME, + ], cwd=PACKAGE_ROOT) + build_dir = swift_build_dir(configuration, architecture) + executable = build_dir / PRODUCT_NAME + if not executable.is_file(): + raise RuntimeError(f"Swift build did not produce {executable}") + executables.append(executable) + build_dirs.append(build_dir) + + if len(executables) == 1: + return executables[0], build_dirs[0] + + universal_dir = PACKAGE_ROOT / ".build" / "package-app" / configuration + universal_dir.mkdir(parents=True, exist_ok=True) + universal_executable = universal_dir / PRODUCT_NAME + run(["lipo", "-create", *[str(path) for path in executables], "-output", str(universal_executable)]) + universal_executable.chmod(0o755) + return universal_executable, build_dirs[0] + + +def copy_resources(build_dir: Path, resources_dir: Path) -> None: + 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, *, icon_name: str | None = None) -> None: + info = { + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": APP_NAME, + "CFBundleExecutable": PRODUCT_NAME, + "CFBundleIdentifier": "com.timecapsulesmb.TimeCapsuleSMB", + "CFBundleName": APP_NAME, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": APP_VERSION, + "CFBundleVersion": APP_VERSION_CODE, + "LSMinimumSystemVersion": "14.0", + "NSBonjourServices": BONJOUR_SERVICE_TYPES, + "NSHighResolutionCapable": True, + "NSLocalNetworkUsageDescription": "TimeCapsuleSMB discovers and connects to Apple AirPort devices on your local network.", + } + if icon_name: + info["CFBundleIconFile"] = icon_name + with (contents_dir / "Info.plist").open("wb") as handle: + plistlib.dump(info, handle) + (contents_dir / "PkgInfo").write_text("APPL????", encoding="utf-8") + + +def app_icon_cache_entry(source: Path) -> Path: + key = cache_key({ + "kind": "app-icon", + "version": APP_ICON_CACHE_VERSION, + "source": str(source.resolve()), + "source_sha256": sha256_file(source), + "entries": APP_ICON_ENTRIES, + }) + return package_cache_dir("app-icon") / f"{key}.icns" + + +def create_app_icon(source: Path, resources_dir: Path, *, use_cache: bool = True) -> None: + if not source.is_file(): + raise RuntimeError(f"App icon source does not exist: {source}") + + icon_path = resources_dir / APP_ICON_FILE + if use_cache: + cached_icon = app_icon_cache_entry(source) + if cached_icon.is_file(): + print("Using cached app icon.", file=sys.stderr) + shutil.copy2(cached_icon, icon_path) + return + + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-iconset-") as tmp: + iconset = Path(tmp) / f"{APP_ICON_NAME}.iconset" + iconset.mkdir() + for filename, size in APP_ICON_ENTRIES: + run([ + "sips", + "-s", + "format", + "png", + "-z", + str(size), + str(size), + str(source), + "--out", + str(iconset / filename), + ]) + run(["iconutil", "-c", "icns", str(iconset), "-o", str(icon_path)]) + + if not icon_path.is_file(): + raise RuntimeError(f"App icon generation did not produce {icon_path}") + if use_cache: + cached_icon = app_icon_cache_entry(source) + cached_icon.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(icon_path, cached_icon) + + +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_HOME="$RESOURCES_DIR/Python/Runtime/Python.framework/Versions/Current" +if [ -z "${TCAPSULE_APP_PYTHON:-}" ]; then + PYTHON="$PYTHON_HOME/bin/python3" + export PYTHONHOME="$PYTHON_HOME" +else + PYTHON="$TCAPSULE_APP_PYTHON" +fi +PYTHON_PACKAGES="$RESOURCES_DIR/Python/site-packages" +CA_CERT_FILE="$PYTHON_PACKAGES/certifi/cacert.pem" + +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 PYTHONPATH="$PYTHON_PACKAGES${PYTHONPATH:+:$PYTHONPATH}" +export PYTHONNOUSERSITE=1 +export PYTHONDONTWRITEBYTECODE=1 +if [ -f "$CA_CERT_FILE" ]; then + export SSL_CERT_FILE="${SSL_CERT_FILE:-$CA_CERT_FILE}" + export REQUESTS_CA_BUNDLE="${REQUESTS_CA_BUNDLE:-$CA_CERT_FILE}" +fi + +exec "$PYTHON" -m timecapsulesmb.cli.main "$@" +""", + encoding="utf-8", + ) + helper_path.chmod(0o755) + + +def python_major_minor(python: str) -> tuple[int, int]: + code = "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" + completed = subprocess.run( + [python, "-c", code], + env=python_subprocess_env(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + major, minor = completed.stdout.strip().split(".", 1) + return int(major), int(minor) + + +def package_cache_dir(name: str) -> Path: + path = PACKAGE_ROOT / ".build" / "package-app" / name + path.mkdir(parents=True, exist_ok=True) + return path + + +def python_subprocess_env( + env: dict[str, str] | None = None, + *, + python_home: Path | None = None, + pycache_prefix: Path | None = None, +) -> dict[str, str]: + merged = os.environ.copy() + if env: + merged.update(env) + if python_home is not None: + merged["PYTHONHOME"] = str(python_home) + prefix = pycache_prefix or package_cache_dir(PYTHON_SUBPROCESS_BYTECODE_CACHE) + prefix.mkdir(parents=True, exist_ok=True) + merged["PYTHONPYCACHEPREFIX"] = str(prefix) + merged["PYTHONNOUSERSITE"] = "1" + merged["PYTHONDONTWRITEBYTECODE"] = "1" + return merged + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def should_ignore_cache_path(path: Path) -> bool: + return ( + path.name in PACKAGE_CACHE_IGNORED_NAMES + or path.suffix in PACKAGE_CACHE_IGNORED_SUFFIXES + or any(part in PACKAGE_CACHE_IGNORED_NAMES for part in path.parts) + ) + + +def sha256_tree(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + if should_ignore_cache_path(path): + continue + if path.is_symlink(): + digest.update(f"link:{relative}\0{os.readlink(path)}\0".encode("utf-8")) + elif path.is_file(): + digest.update(f"file:{relative}\0".encode("utf-8")) + digest.update(sha256_file(path).encode("ascii")) + digest.update(b"\0") + return digest.hexdigest() + + +def cache_key(data: dict[str, object]) -> str: + encoded = json.dumps( + {"cache_key_version": CACHE_KEY_VERSION, **data}, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:24] + + +def cache_is_complete(entry: Path, required_path: Path) -> bool: + return (entry / CACHE_COMPLETE_MARKER).is_file() and required_path.exists() + + +def mark_cache_complete(entry: Path) -> None: + (entry / CACHE_COMPLETE_MARKER).write_text("ok\n", encoding="utf-8") + + +def replace_path(source: Path, destination: Path) -> None: + if destination.exists() or destination.is_symlink(): + if destination.is_dir() and not destination.is_symlink(): + shutil.rmtree(destination) + else: + destination.unlink() + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(destination)) + + +def copy_path(source: Path, destination: Path) -> None: + if destination.exists() or destination.is_symlink(): + if destination.is_dir() and not destination.is_symlink(): + shutil.rmtree(destination) + else: + destination.unlink() + destination.parent.mkdir(parents=True, exist_ok=True) + if source.is_dir(): + shutil.copytree(source, destination, symlinks=True) + else: + shutil.copy2(source, destination) + + +def cache_manifest_path(entry: Path) -> Path: + return entry / CACHE_MANIFEST_FILE + + +def input_fingerprint(path: Path) -> dict[str, str]: + resolved = path.resolve() + return {"path": str(resolved), "sha256": sha256_file(resolved)} + + +def input_fingerprints(paths: list[Path] | set[Path]) -> list[dict[str, str]]: + return [input_fingerprint(path) for path in sorted({path.resolve() for path in paths})] + + +def write_cache_manifest(entry: Path, manifest: dict[str, object]) -> None: + cache_manifest_path(entry).write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def read_cache_manifest(entry: Path) -> dict[str, object] | None: + path = cache_manifest_path(entry) + if not path.is_file(): + return None + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else None + + +def cache_manifest_inputs_current(entry: Path) -> bool: + manifest = read_cache_manifest(entry) + if manifest is None: + return False + inputs = manifest.get("inputs") + if not isinstance(inputs, list): + return False + for record in inputs: + if not isinstance(record, dict): + return False + path_value = record.get("path") + sha256_value = record.get("sha256") + if not isinstance(path_value, str) or not isinstance(sha256_value, str): + return False + path = Path(path_value) + if not path.is_file() or sha256_file(path) != sha256_value: + return False + return True + + +def cache_manifest_output_current(entry: Path, output_root: Path) -> bool: + manifest = read_cache_manifest(entry) + if manifest is None: + return False + expected = manifest.get("output_tree_sha256") + return isinstance(expected, str) and output_root.is_dir() and sha256_tree(output_root) == expected + + +def download_file(url: str, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + with urllib.request.urlopen(url, timeout=60) as response: + with destination.open("wb") as handle: + shutil.copyfileobj(response, handle) + + +def python_runtime_pkg(args: argparse.Namespace) -> Path: + if args.python_runtime_pkg: + return args.python_runtime_pkg.resolve() + cache_dir = package_cache_dir("python-runtime") + filename = Path(args.python_runtime_url).name or f"python-{PYTHON_RUNTIME_VERSION}-macos11.pkg" + destination = cache_dir / filename + if not destination.is_file(): + print(f"Downloading bundled Python runtime: {args.python_runtime_url}", file=sys.stderr) + download_file(args.python_runtime_url, destination) + return destination + + +def python_framework_executable(framework: Path) -> Path: + return framework_version_dir(framework) / "bin" / "python3" + + +def python_framework_dylib(framework: Path) -> Path: + return framework_version_dir(framework) / "Python" + + +def python_runtime_source(args: argparse.Namespace) -> tuple[str, Path, dict[str, object]]: + if args.python_runtime_framework: + source = args.python_runtime_framework.resolve() + return ( + "framework", + source, + { + "source": str(source), + "tree_sha256": sha256_tree(source), + }, + ) + source = python_runtime_pkg(args) + return ( + "pkg", + source, + { + "source": str(source), + "source_sha256": sha256_file(source), + "url": args.python_runtime_url, + }, + ) + + +def prepared_python_framework(args: argparse.Namespace, architectures: tuple[str, ...]) -> Path: + source_kind, source, source_fingerprint = python_runtime_source(args) + cache_root = package_cache_dir("python-framework") + key = cache_key({ + "kind": "python-framework", + "version": PYTHON_RUNTIME_CACHE_VERSION, + "source_kind": source_kind, + "source_fingerprint": source_fingerprint, + "architectures": architectures, + }) + entry = cache_root / key + framework = entry / PYTHON_FRAMEWORK_NAME + + if cache_is_complete(entry, framework / "Versions" / "Current" / "bin" / "python3"): + print("Using cached Python.framework.", file=sys.stderr) + return framework + + with tempfile.TemporaryDirectory(prefix=f"{key}.tmp-", dir=cache_root) as tmp: + staging = Path(tmp) / "entry" + staging.mkdir() + staged_framework = staging / PYTHON_FRAMEWORK_NAME + if source_kind == "framework": + shutil.copytree(source, staged_framework, symlinks=True) + else: + extract_python_framework(source, staged_framework) + prune_python_runtime(staged_framework) + remove_python_bytecode(staged_framework) + rewrite_python_framework_install_names(staged_framework) + assert_macho_has_architectures(python_framework_executable(staged_framework), architectures, "Bundled Python executable") + assert_macho_has_architectures(python_framework_dylib(staged_framework), architectures, "Bundled Python framework") + assert_macho_architectures_for_roots([staged_framework], architectures, "Bundled Python runtime architecture") + assert_no_external_macho_dependencies_for_roots([staged_framework]) + ad_hoc_codesign_python_framework(staged_framework) + assert_macho_code_signatures_valid_for_roots([staged_framework]) + mark_cache_complete(staging) + replace_path(staging, entry) + return framework + + +def extract_python_framework(pkg_path: Path, destination: Path) -> Path: + if destination.exists(): + shutil.rmtree(destination) + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-python-runtime-") as tmp: + expanded = Path(tmp) / "expanded" + run(["pkgutil", "--expand-full", str(pkg_path), str(expanded)]) + payload = expanded / "Python_Framework.pkg" / "Payload" + if (payload / "Versions" / "Current" / "Python").is_file(): + shutil.copytree(payload, destination, symlinks=True) + return destination + frameworks = [path for path in expanded.rglob(PYTHON_FRAMEWORK_NAME) if (path / "Versions" / "Current" / "Python").is_file()] + if not frameworks: + raise RuntimeError(f"Python runtime package does not contain {PYTHON_FRAMEWORK_NAME}: {pkg_path}") + shutil.copytree(frameworks[0], destination, symlinks=True) + return destination + + +def copy_python_runtime(args: argparse.Namespace, resources_dir: Path, architectures: tuple[str, ...]) -> Path: + runtime_dir = resources_dir / "Python" / "Runtime" + if runtime_dir.exists(): + shutil.rmtree(runtime_dir) + runtime_dir.mkdir(parents=True) + framework = runtime_dir / PYTHON_FRAMEWORK_NAME + + if getattr(args, "no_cache", False): + if args.python_runtime_framework: + shutil.copytree(args.python_runtime_framework.resolve(), framework, symlinks=True) + else: + extract_python_framework(python_runtime_pkg(args), framework) + prune_python_runtime(framework) + remove_python_bytecode(framework) + rewrite_python_framework_install_names(framework) + assert_macho_architectures_for_roots([framework], architectures, "Bundled Python runtime architecture") + assert_no_external_macho_dependencies_for_roots([framework]) + ad_hoc_codesign_python_framework(framework) + assert_macho_code_signatures_valid_for_roots([framework]) + else: + cached_framework = prepared_python_framework(args, architectures) + shutil.copytree(cached_framework, framework, symlinks=True) + python_executable = bundled_python_executable_from_resources(resources_dir) + if not python_executable.is_file(): + raise RuntimeError(f"Bundled Python executable is missing: {python_executable}") + assert_macho_has_architectures(python_executable, architectures, "Bundled Python executable") + assert_macho_has_architectures(bundled_python_dylib_from_resources(resources_dir), architectures, "Bundled Python framework") + return python_executable + + +def bundled_python_home(app: Path) -> Path: + return bundled_python_framework(app) / "Versions" / "Current" + + +def bundled_python_framework(app: Path) -> Path: + return app / "Contents" / "Resources" / "Python" / "Runtime" / PYTHON_FRAMEWORK_NAME + + +def bundled_python_executable(app: Path) -> Path: + return bundled_python_home(app) / "bin" / "python3" + + +def bundled_python_dylib(app: Path) -> Path: + return bundled_python_home(app) / "Python" + + +def bundled_python_executable_from_resources(resources_dir: Path) -> Path: + return resources_dir / "Python" / "Runtime" / PYTHON_FRAMEWORK_NAME / "Versions" / "Current" / "bin" / "python3" + + +def bundled_python_dylib_from_resources(resources_dir: Path) -> Path: + return resources_dir / "Python" / "Runtime" / PYTHON_FRAMEWORK_NAME / "Versions" / "Current" / "Python" + + +def framework_version_dir(framework: Path) -> Path: + current = framework / "Versions" / "Current" + if (current / "Python").exists(): + return current.resolve() + versions = [path for path in (framework / "Versions").iterdir() if path.is_dir() and (path / "Python").is_file()] + if not versions: + raise RuntimeError(f"Bundled Python framework has no version directory: {framework}") + return versions[0] + + +def loader_relative_reference(loader: Path, dependency: Path) -> str: + return f"@loader_path/{os.path.relpath(dependency, loader.parent)}" + + +def rewrite_python_framework_install_names(framework: Path) -> None: + version_dir = framework_version_dir(framework) + original_prefix = f"/Library/Frameworks/{PYTHON_FRAMEWORK_NAME}/Versions/{version_dir.name}/" + changed: set[Path] = set() + for path in macho_files_under([framework]): + if not macho_architectures(path): + continue + dependencies = macho_dependencies(path) + if dependencies is None: + continue + for dependency in dependencies: + if not dependency.startswith(original_prefix): + continue + bundled_dependency = version_dir / dependency.removeprefix(original_prefix) + if not bundled_dependency.exists(): + continue + run_quiet([ + "install_name_tool", + "-change", + dependency, + loader_relative_reference(path, bundled_dependency), + str(path), + ]) + changed.add(path) + if path.resolve() == (version_dir / "Python").resolve(): + run_quiet([ + "install_name_tool", + "-id", + f"@rpath/{PYTHON_FRAMEWORK_NAME}/Versions/{version_dir.name}/Python", + str(path), + ]) + changed.add(path) + elif is_library_like_macho(path) and path.suffix not in {".a", ".so"}: + run_quiet(["install_name_tool", "-id", f"@loader_path/{path.name}", str(path)]) + changed.add(path) + for path in changed: + ad_hoc_codesign(path) + + +def prune_python_runtime(framework: Path) -> None: + version_dir = framework_version_dir(framework) + for path in (version_dir / "bin").glob("*-intel64"): + path.unlink() + for relative_path in ( + "Frameworks/Tcl.framework", + "Frameworks/Tk.framework", + "lib/tcl8", + "lib/tcl8.6", + "lib/tk8.6", + "lib/python3.13/idlelib", + "lib/python3.13/tkinter", + "lib/python3.13/test", + ): + path = version_dir / relative_path + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + for path in (version_dir / "lib" / "python3.13" / "lib-dynload").glob("_tkinter*.so"): + path.unlink() + + +def remove_python_bytecode(root: Path) -> None: + if not root.exists(): + return + pycache_dirs = [ + path + for path in root.rglob("__pycache__") + if path.is_dir() and not path.is_symlink() + ] + for path in sorted(pycache_dirs, key=lambda candidate: len(candidate.parts), reverse=True): + shutil.rmtree(path) + for path in root.rglob("*"): + if path.is_file() and path.suffix in PACKAGE_CACHE_IGNORED_SUFFIXES: + path.unlink() + + +def python_cache_identity(python: str) -> dict[str, object]: + code = ( + "import json, platform, sys, sysconfig\n" + "print(json.dumps({\n" + " 'version': sys.version,\n" + " 'cache_tag': sys.implementation.cache_tag,\n" + " 'platform': sysconfig.get_platform(),\n" + " 'machine': platform.machine(),\n" + "}, sort_keys=True))\n" + ) + completed = subprocess.run( + [python, "-c", code], + env=python_subprocess_env(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + return json.loads(completed.stdout) + + +def python_package_source_fingerprint() -> dict[str, object]: + inputs = { + "pyproject.toml": sha256_file(REPO_ROOT / "pyproject.toml"), + "requirements.txt": sha256_file(REPO_ROOT / "requirements.txt") if (REPO_ROOT / "requirements.txt").is_file() else None, + "src/timecapsulesmb": sha256_tree(SRC_ROOT / "timecapsulesmb"), + } + return inputs + + +def python_site_packages_cache_entry(python: str, architectures: tuple[str, ...]) -> Path: + key = cache_key({ + "kind": "python-site-packages", + "version": PYTHON_SITE_PACKAGES_CACHE_VERSION, + "python": python_cache_identity(python), + "architectures": architectures, + "bundled_requirements": APP_BUNDLED_PYTHON_REQUIREMENTS, + "source": python_package_source_fingerprint(), + }) + return package_cache_dir("python-site-packages") / key + + +def build_python_packages(python: str, site_packages: Path) -> None: + major, minor = python_major_minor(python) + if (major, minor) < (3, 9): + raise RuntimeError(f"TimeCapsuleSMB.app requires Python 3.9 or newer, got {major}.{minor} from {python}") + + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-package-python-") as tmp: + build_venv = Path(tmp) / "venv" + env = python_subprocess_env(pycache_prefix=Path(tmp) / "pycache") + run([python, "-m", "venv", str(build_venv)], env=env) + build_python = build_venv / "bin" / "python" + run([str(build_python), "-m", "pip", "install", "-U", "pip"], env=env) + generated_build_lib = REPO_ROOT / "build" / "lib" + build_lib_existed = generated_build_lib.exists() + try: + run([str(build_python), "-m", "pip", "install", "--target", str(site_packages), str(REPO_ROOT)], env=env) + run([str(build_python), "-m", "pip", "install", "--target", str(site_packages), *APP_BUNDLED_PYTHON_REQUIREMENTS], env=env) + finally: + if not build_lib_existed and generated_build_lib.exists(): + shutil.rmtree(generated_build_lib) + remove_optional_zeroconf_extensions(site_packages) + remove_python_bytecode(site_packages) + + +def create_python_packages( + python: str, + resources_dir: Path, + architectures: tuple[str, ...], + *, + use_cache: bool = True, +) -> None: + python_root = resources_dir / "Python" + site_packages = python_root / "site-packages" + if site_packages.exists(): + shutil.rmtree(site_packages) + python_root.mkdir(parents=True, exist_ok=True) + + if use_cache: + entry = python_site_packages_cache_entry(python, architectures) + cached_site_packages = entry / "site-packages" + if cache_is_complete(entry, cached_site_packages): + print("Using cached Python site-packages.", file=sys.stderr) + shutil.copytree(cached_site_packages, site_packages) + remove_python_bytecode(site_packages) + return + + cache_root = entry.parent + cache_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"{entry.name}.tmp-", dir=cache_root) as tmp: + staging = Path(tmp) / "entry" + staged_site_packages = staging / "site-packages" + staged_site_packages.mkdir(parents=True) + build_python_packages(python, staged_site_packages) + remove_python_bytecode(staged_site_packages) + assert_macho_architectures_for_roots([staged_site_packages], architectures, "Bundled Python package architecture") + assert_no_external_macho_dependencies_for_roots([staged_site_packages]) + ad_hoc_codesign_site_packages(staged_site_packages) + assert_macho_code_signatures_valid_for_roots([staged_site_packages]) + mark_cache_complete(staging) + replace_path(staging, entry) + shutil.copytree(cached_site_packages, site_packages) + return + + site_packages.mkdir() + build_python_packages(python, site_packages) + remove_python_bytecode(site_packages) + assert_macho_architectures_for_roots([site_packages], architectures, "Bundled Python package architecture") + assert_no_external_macho_dependencies_for_roots([site_packages]) + ad_hoc_codesign_site_packages(site_packages) + assert_macho_code_signatures_valid_for_roots([site_packages]) + + +def remove_optional_zeroconf_extensions(site_packages: Path) -> None: + # zeroconf's Cython modules are optional. PyPI currently publishes arm64-only + # macOS wheels for CPython 3.9, so keep the app bundle portable by using the + # pure-Python modules that ship in the same package. + zeroconf = site_packages / "zeroconf" + if not zeroconf.is_dir(): + return + for extension in zeroconf.rglob("*.so"): + extension.unlink() + + +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") + shutil.copy2(ARTIFACT_MANIFEST, distribution / "artifact-manifest.json") + assert_distribution_artifacts(distribution) + + +def artifact_paths() -> list[str]: + data = json.loads(ARTIFACT_MANIFEST.read_text(encoding="utf-8")) + artifacts = data.get("artifacts", {}) + paths: list[str] = [] + for record in artifacts.values(): + if isinstance(record, dict) and isinstance(record.get("path"), str): + paths.append(record["path"]) + return sorted(paths) + + +def assert_distribution_artifacts(distribution: Path) -> None: + missing = [path for path in artifact_paths() if not (distribution / path).is_file()] + if missing: + joined = "\n - ".join(missing) + raise RuntimeError(f"Bundled distribution is missing payload artifact(s):\n - {joined}") + + +def macho_architectures(path: Path) -> set[str]: + completed = subprocess.run( + ["lipo", "-archs", str(path)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + return set() + return set(completed.stdout.strip().split()) + + +def tool_env_names(name: str, architecture: str) -> list[str]: + tool = name.upper().replace("-", "_") + arch = architecture.upper().replace("-", "_") + return [ + f"TCAPSULE_PACKAGE_{tool}_{arch}", + f"TCAPSULE_PACKAGE_{tool}", + ] + + +def unique_paths(paths: list[Path]) -> list[Path]: + result: list[Path] = [] + seen: set[Path] = set() + for path in paths: + resolved = path.expanduser() + if resolved in seen: + continue + seen.add(resolved) + result.append(resolved) + return result + + +def tool_candidates(name: str, architecture: str) -> list[Path]: + paths: list[Path] = [] + for env_name in tool_env_names(name, architecture): + value = os.getenv(env_name) + if value: + paths.append(Path(value)) + + preferred_prefixes = { + "arm64": [Path("/opt/homebrew/bin")], + "x86_64": [Path("/usr/local/bin")], + } + paths.extend(prefix / name for prefix in preferred_prefixes.get(architecture, ())) + if found := shutil.which(name): + paths.append(Path(found)) + paths.extend([ + Path("/opt/homebrew/bin") / name, + Path("/usr/local/bin") / name, + ]) + return unique_paths(paths) + + +def find_tool_for_architecture(name: str, architecture: str) -> Path | None: + for candidate in tool_candidates(name, architecture): + if not candidate.is_file() or not os.access(candidate, os.X_OK): + continue + if architecture in macho_architectures(candidate): + return candidate + return None + + +def copy_arch_tool(source: Path, tools_bin: Path, name: str, architecture: str) -> None: + destination = tools_bin / architecture / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + destination.chmod(0o755) + + +def write_tool_arch_wrapper(tools_bin: Path, name: str, architectures: tuple[str, ...]) -> None: + cases = "\n".join( + f" {architecture}) exec \"$tool_dir/{architecture}/{name}\" \"$@\" ;;" + for architecture in architectures + ) + wrapper = tools_bin / name + wrapper.write_text( + f"""#!/bin/sh +set -eu +tool_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +arch="$(/usr/bin/uname -m)" +case "$arch" in +{cases} +esac +echo "{name} is not bundled for architecture $arch" >&2 +exit 127 +""", + encoding="utf-8", + ) + wrapper.chmod(0o755) + + +def resolve_tool_sources(architectures: tuple[str, ...]) -> dict[tuple[str, str], Path]: + sources: dict[tuple[str, str], Path] = {} + missing: list[str] = [] + + for tool in REQUIRED_HOST_TOOLS: + for architecture in architectures: + source = find_tool_for_architecture(tool, architecture) + if source is None: + missing.append(f"{tool} ({architecture})") + continue + sources[(tool, architecture)] = source + + if missing: + joined = ", ".join(missing) + raise RuntimeError(f"Missing required host tool(s) for bundling: {joined}") + return sources + + +def tool_source_records(sources: dict[tuple[str, str], Path]) -> list[dict[str, str]]: + records: list[dict[str, str]] = [] + for tool, architecture in sorted(sources): + fingerprint = input_fingerprint(sources[(tool, architecture)]) + records.append({ + "tool": tool, + "architecture": architecture, + **fingerprint, + }) + return records + + +def copy_tools_from_sources( + resources_dir: Path, + architectures: tuple[str, ...], + sources: dict[tuple[str, str], Path], +) -> None: + tools_bin = resources_dir / "Tools" / "bin" + tools_bin.mkdir(parents=True, exist_ok=True) + + if len(architectures) == 1: + architecture = architectures[0] + for tool in REQUIRED_HOST_TOOLS: + source = sources[(tool, architecture)] + destination = tools_bin / tool + shutil.copy2(source, destination) + destination.chmod(0o755) + return + + for tool in REQUIRED_HOST_TOOLS: + for architecture in architectures: + copy_arch_tool(sources[(tool, architecture)], tools_bin, tool, architecture) + write_tool_arch_wrapper(tools_bin, tool, architectures) + + +def copy_tools(resources_dir: Path, architectures: tuple[str, ...]) -> None: + copy_tools_from_sources(resources_dir, architectures, resolve_tool_sources(architectures)) + + +def macho_dependencies(path: Path) -> list[str] | None: + completed = subprocess.run( + ["otool", "-L", str(path)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + return None + dependencies: list[str] = [] + resolved_path = path.resolve() + for line in completed.stdout.splitlines()[1:]: + stripped = line.strip() + if not stripped: + continue + dependency = stripped.split(" ", 1)[0] + if dependency.startswith("/") and Path(dependency).resolve() == resolved_path: + continue + dependencies.append(dependency) + return dependencies + + +def is_system_macho_dependency(dependency: str) -> bool: + return ( + dependency.startswith("/usr/lib/") + or dependency.startswith("/System/Library/") + or dependency.startswith("@executable_path/") + or dependency.startswith("@loader_path/") + or dependency.startswith("@rpath/") + ) + + +def is_external_macho_dependency(dependency: str) -> bool: + return dependency.startswith("/") and not is_system_macho_dependency(dependency) + + +def is_inside(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def resolve_macho_dependency(loader: Path, app: Path, dependency: str) -> Path | None: + if dependency.startswith("@loader_path/"): + return (loader.parent / dependency.removeprefix("@loader_path/")).resolve() + if dependency.startswith("@executable_path/"): + return (app / "Contents" / "MacOS" / dependency.removeprefix("@executable_path/")).resolve() + if dependency.startswith("/"): + return Path(dependency).resolve() + return None + + +def bundled_dependency_name(source: Path, used_names: set[str], *, preferred_name: str | None = None) -> str: + name = preferred_name or source.name + if name not in used_names: + used_names.add(name) + return name + digest = hashlib.sha256(str(source).encode("utf-8")).hexdigest()[:10] + suffix = "".join(source.suffixes) + stem = source.name[: -len(suffix)] if suffix else source.name + candidate = f"{stem}-{digest}{suffix}" + used_names.add(candidate) + return candidate + + +def loader_path_reference(loader: Path, dependency: Path, frameworks_dir: Path) -> str: + relative_frameworks = os.path.relpath(frameworks_dir, loader.parent) + relative_dependency = Path(relative_frameworks) / dependency.name + return f"@loader_path/{relative_dependency.as_posix()}" + + +def is_library_like_macho(path: Path) -> bool: + framework_binary = path.parent.name.endswith(".framework") and path.name == path.parent.stem + return path.suffix in {".dylib", ".so"} or framework_binary or path.parent.name in {"lib", "private"} + + +def set_macho_id_if_supported(path: Path) -> None: + if not is_library_like_macho(path): + return + subprocess.run( + ["install_name_tool", "-id", f"@loader_path/{path.name}", str(path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + +def files_under(roots: list[Path]) -> list[Path]: + candidates: list[Path] = [] + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*"): + if path.is_symlink(): + continue + if path.is_file(): + candidates.append(path) + return candidates + + +def is_macho_candidate(path: Path) -> bool: + if path.suffix == ".a": + return False + return path.name == "Python" or path.suffix in {".dylib", ".so"} or os.access(path, os.X_OK) + + +def macho_files_under(roots: list[Path]) -> list[Path]: + return [path for path in files_under(roots) if is_macho_candidate(path)] + + +def macho_vendor_roots(app: Path) -> list[Path]: + contents = app / "Contents" + return macho_files_under([ + contents / "Resources" / "Tools" / "bin", + contents / "Frameworks", + ]) + + +def macho_validation_roots(app: Path) -> list[Path]: + contents = app / "Contents" + return macho_files_under([ + contents / "MacOS", + contents / "Resources" / "Tools" / "bin", + contents / "Resources" / "Python" / "Runtime", + contents / "Resources" / "Python" / "site-packages", + contents / "Frameworks", + ]) + + +def ad_hoc_codesign(path: Path) -> None: + run_quiet(["codesign", "--force", "--sign", "-", str(path)]) + + +def ad_hoc_codesign_macho_roots(roots: list[Path]) -> None: + for path in sorted(macho_files_under(roots), key=str): + if macho_architectures(path): + ad_hoc_codesign(path) + + +def ad_hoc_codesign_python_framework(framework: Path) -> None: + ad_hoc_codesign_macho_roots([framework]) + if framework.is_dir(): + ad_hoc_codesign(framework) + + +def ad_hoc_codesign_site_packages(site_packages: Path) -> None: + ad_hoc_codesign_macho_roots([site_packages]) + + +def finalize_python_bundle(resources_dir: Path) -> None: + framework = resources_dir / "Python" / "Runtime" / PYTHON_FRAMEWORK_NAME + site_packages = resources_dir / "Python" / "site-packages" + remove_python_bytecode(framework) + remove_python_bytecode(site_packages) + if framework.is_dir(): + ad_hoc_codesign_python_framework(framework) + if site_packages.is_dir(): + ad_hoc_codesign_site_packages(site_packages) + assert_macho_code_signatures_valid_for_roots([framework, site_packages]) + + +def codesign_order(path: Path, app: Path) -> tuple[int, str]: + try: + relative = path.resolve().relative_to(app.resolve()) + except ValueError: + return (50, str(path)) + parts = relative.parts + if len(parts) >= 3 and parts[:2] == ("Contents", "MacOS"): + return (90, str(path)) + if len(parts) >= 2 and parts[:2] == ("Contents", "Frameworks"): + return (10, str(path)) + return (20, str(path)) + + +def should_codesign_packaged_macho(path: Path, app: Path) -> bool: + try: + relative = path.resolve().relative_to(app.resolve()) + except ValueError: + return False + parts = relative.parts + return len(parts) >= 2 and parts[:2] in { + ("Contents", "Frameworks"), + ("Contents", "Resources"), + } + + +def ad_hoc_codesign_macho_bundle(app: Path) -> None: + for path in sorted(macho_validation_roots(app), key=lambda candidate: codesign_order(candidate, app)): + if should_codesign_packaged_macho(path, app) and macho_architectures(path): + ad_hoc_codesign(path) + framework = bundled_python_framework(app) + if framework.is_dir(): + ad_hoc_codesign(framework) + + +def vendor_macho_dependencies(app: Path) -> set[Path]: + frameworks_dir = app / "Contents" / "Frameworks" + frameworks_dir.mkdir(exist_ok=True) + source_to_bundle: dict[Path, Path] = {} + bundle_to_source: dict[Path, Path] = {} + vendored_sources: set[Path] = set() + used_names: set[str] = set() + queue = macho_vendor_roots(app) + visited: set[Path] = set() + + while queue: + current = queue.pop(0) + current_resolved = current.resolve() + if current_resolved in visited: + continue + visited.add(current_resolved) + + dependencies = macho_dependencies(current) + if dependencies is None: + continue + + for dependency in dependencies: + preferred_name: str + if is_external_macho_dependency(dependency): + source_path = Path(dependency) + source = source_path.resolve() + preferred_name = source_path.name + elif dependency.startswith("@loader_path/") and current_resolved in bundle_to_source: + relative_dependency = dependency.removeprefix("@loader_path/") + source = (bundle_to_source[current_resolved].parent / relative_dependency).resolve() + preferred_name = Path(relative_dependency).name + if not source.is_file(): + resolved_dependency = resolve_macho_dependency(current, app, dependency) + if resolved_dependency is None or not resolved_dependency.exists(): + raise RuntimeError(f"Mach-O dependency does not exist: {dependency} referenced by {current}") + continue + else: + continue + if not source.is_file(): + raise RuntimeError(f"Mach-O dependency does not exist: {dependency} referenced by {current}") + vendored_sources.add(source) + bundled = source_to_bundle.get(source) + if bundled is None: + bundled = frameworks_dir / bundled_dependency_name(source, used_names, preferred_name=preferred_name) + shutil.copy2(source, bundled) + bundled.chmod(bundled.stat().st_mode | 0o200) + source_to_bundle[source] = bundled + bundle_to_source[bundled.resolve()] = source + queue.append(bundled) + run_quiet([ + "install_name_tool", + "-change", + dependency, + loader_path_reference(current, bundled, frameworks_dir), + str(current), + ]) + + set_macho_id_if_supported(current) + return vendored_sources + + +def native_tools_cache_entry( + architectures: tuple[str, ...], + sources: dict[tuple[str, str], Path], +) -> Path: + key = cache_key({ + "kind": "native-tools", + "version": NATIVE_TOOLS_CACHE_VERSION, + "architectures": architectures, + "tool_sources": tool_source_records(sources), + }) + return package_cache_dir("native-tools") / key + + +def native_tools_cache_is_complete(entry: Path) -> bool: + tools_bin = entry / "Contents" / "Resources" / "Tools" / "bin" + frameworks = entry / "Contents" / "Frameworks" + contents = entry / "Contents" + return ( + cache_is_complete(entry, tools_bin) + and frameworks.is_dir() + and cache_manifest_inputs_current(entry) + and cache_manifest_output_current(entry, contents) + ) + + +def write_native_tools_manifest( + entry: Path, + architectures: tuple[str, ...], + sources: dict[tuple[str, str], Path], + dependency_sources: set[Path], +) -> None: + input_paths = set(sources.values()) | dependency_sources + write_cache_manifest(entry, { + "schema_version": 1, + "kind": "native-tools", + "cache_version": NATIVE_TOOLS_CACHE_VERSION, + "architectures": list(architectures), + "tool_sources": tool_source_records(sources), + "inputs": input_fingerprints(input_paths), + "output_tree_sha256": sha256_tree(entry / "Contents"), + }) + + +def prepared_native_tools_layer(architectures: tuple[str, ...]) -> Path: + sources = resolve_tool_sources(architectures) + entry = native_tools_cache_entry(architectures, sources) + if native_tools_cache_is_complete(entry): + print("Using cached native tool layer.", file=sys.stderr) + return entry + + cache_root = entry.parent + cache_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"{entry.name}.tmp-", dir=cache_root) as tmp: + staging = Path(tmp) / "entry" + resources = staging / "Contents" / "Resources" + resources.mkdir(parents=True) + copy_tools_from_sources(resources, architectures, sources) + dependency_sources = vendor_macho_dependencies(staging) + ad_hoc_codesign_macho_bundle(staging) + assert_tool_architectures(staging, architectures) + assert_runtime_macho_architectures(staging, architectures) + assert_no_external_macho_dependencies(staging) + assert_macho_code_signatures_valid(staging) + write_native_tools_manifest(staging, architectures, sources, dependency_sources) + mark_cache_complete(staging) + replace_path(staging, entry) + return entry + + +def copy_native_tools_layer(app: Path, architectures: tuple[str, ...], *, use_cache: bool = True) -> None: + contents = app / "Contents" + if use_cache: + layer = prepared_native_tools_layer(architectures) + copy_path(layer / "Contents" / "Resources" / "Tools", contents / "Resources" / "Tools") + copy_path(layer / "Contents" / "Frameworks", contents / "Frameworks") + return + + copy_tools(contents / "Resources", architectures) + vendor_macho_dependencies(app) + ad_hoc_codesign_macho_bundle(app) + assert_tool_architectures(app, architectures) + assert_runtime_macho_architectures(app, architectures) + assert_no_external_macho_dependencies(app) + assert_macho_code_signatures_valid(app) + + +def assert_macho_code_signatures_valid_for_paths(paths: list[Path]) -> None: + failures: list[str] = [] + for path in paths: + if not macho_architectures(path): + continue + completed = subprocess.run( + ["codesign", "--verify", "--verbose=4", str(path)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip().splitlines() + reason = detail[-1] if detail else f"codesign verification failed with rc={completed.returncode}" + failures.append(f"{path}: {reason}") + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"App bundle contains invalid Mach-O code signature(s):\n - {joined}") + + +def assert_macho_code_signatures_valid_for_roots(roots: list[Path]) -> None: + assert_macho_code_signatures_valid_for_paths(macho_files_under(roots)) + + +def assert_macho_code_signatures_valid(app: Path) -> None: + paths = [ + path + for path in macho_validation_roots(app) + if should_codesign_packaged_macho(path, app) + ] + assert_macho_code_signatures_valid_for_paths(paths) + + +def assert_no_external_macho_dependencies_for_paths(paths: list[Path]) -> None: + external: list[str] = [] + for path in paths: + dependencies = macho_dependencies(path) + if dependencies is None: + continue + for dependency in dependencies: + if is_external_macho_dependency(dependency): + external.append(f"{path}: {dependency}") + if external: + joined = "\n - ".join(external) + raise RuntimeError(f"App bundle contains non-system Mach-O dependency reference(s):\n - {joined}") + + +def assert_no_external_macho_dependencies_for_roots(roots: list[Path]) -> None: + assert_no_external_macho_dependencies_for_paths(macho_files_under(roots)) + + +def assert_no_external_macho_dependencies(app: Path) -> None: + assert_no_external_macho_dependencies_for_paths(macho_validation_roots(app)) + + +def assert_python_dependencies_are_bundled(app: Path) -> None: + site_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + python_home = bundled_python_home(app) + env = python_subprocess_env( + {"PYTHONPATH": str(site_packages)}, + python_home=python_home, + ) + code = ( + "from pathlib import Path\n" + "import certifi, Crypto, ifaddr, pexpect, timecapsulesmb, zeroconf, zopfli.gzip\n" + f"site = Path({str(site_packages)!r}).resolve()\n" + "paths = [certifi.__file__, Crypto.__file__, ifaddr.__file__, pexpect.__file__, timecapsulesmb.__file__, zeroconf.__file__, zopfli.__file__]\n" + "bad = [p for p in paths if not p or not Path(p).resolve().is_relative_to(site)]\n" + "raise SystemExit('\\n'.join(bad) if bad else 0)\n" + ) + completed = subprocess.run( + [str(bundled_python_executable(app)), "-c", code], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError( + "Bundled Python dependencies are not importable from the app package:\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + + +def bundled_ca_certificate_path(app: Path) -> Path: + return app / "Contents" / "Resources" / "Python" / "site-packages" / "certifi" / "cacert.pem" + + +def assert_bundled_ca_certificates(app: Path) -> None: + ca_certificates = bundled_ca_certificate_path(app) + if not ca_certificates.is_file(): + raise RuntimeError(f"App bundle is missing bundled CA certificates: {ca_certificates}") + + +def assert_macho_has_architectures(path: Path, architectures: tuple[str, ...], label: str) -> None: + actual = macho_architectures(path) + missing = [architecture for architecture in architectures if architecture not in actual] + if missing: + raise RuntimeError( + f"{label} is missing architecture(s) {', '.join(missing)}: {path} " + f"(found: {', '.join(sorted(actual)) or 'none'})" + ) + + +def assert_python_extension_architectures(app: Path, architectures: tuple[str, ...]) -> None: + site_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + failures: list[str] = [] + for path in site_packages.rglob("*"): + if not path.is_file() or path.suffix not in {".so", ".dylib"}: + continue + actual = macho_architectures(path) + missing = [architecture for architecture in architectures if architecture not in actual] + if missing: + failures.append(f"{path}: missing {', '.join(missing)} (found: {', '.join(sorted(actual)) or 'none'})") + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"Bundled Python extension(s) are missing required architecture(s):\n - {joined}") + + +def assert_tool_architectures(app: Path, architectures: tuple[str, ...]) -> None: + tools_bin = app / "Contents" / "Resources" / "Tools" / "bin" + failures: list[str] = [] + for tool in REQUIRED_HOST_TOOLS: + if len(architectures) == 1: + candidate = tools_bin / tool + if not candidate.is_file() or not os.access(candidate, os.X_OK): + failures.append(f"{candidate}: missing") + continue + if candidate.is_file() and macho_architectures(candidate): + missing = [architecture for architecture in architectures if architecture not in macho_architectures(candidate)] + if missing: + failures.append(f"{candidate}: missing {', '.join(missing)}") + continue + + wrapper = tools_bin / tool + if not wrapper.is_file() or not os.access(wrapper, os.X_OK): + failures.append(f"{wrapper}: missing architecture dispatch wrapper") + for architecture in architectures: + candidate = tools_bin / architecture / tool + if not candidate.is_file() or not os.access(candidate, os.X_OK): + failures.append(f"{candidate}: missing") + continue + if architecture not in macho_architectures(candidate): + failures.append( + f"{candidate}: missing {architecture} " + f"(found: {', '.join(sorted(macho_architectures(candidate))) or 'none'})" + ) + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"Bundled tool architecture validation failed:\n - {joined}") + + +def assert_macho_architectures_for_roots(roots: list[Path], architectures: tuple[str, ...], label: str) -> None: + failures: list[str] = [] + for path in macho_files_under(roots): + actual = macho_architectures(path) + if not actual: + continue + missing = [architecture for architecture in architectures if architecture not in actual] + if missing: + failures.append(f"{path}: missing {', '.join(missing)} (found: {', '.join(sorted(actual)) or 'none'})") + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"{label} validation failed:\n - {joined}") + + +def runtime_architecture_roots(app: Path, architectures: tuple[str, ...]) -> list[tuple[Path, str]]: + contents = app / "Contents" + roots: list[tuple[Path, str]] = [] + executable = contents / "MacOS" / PRODUCT_NAME + roots.extend((executable, architecture) for architecture in architectures) + python_runtime = contents / "Resources" / "Python" / "Runtime" + roots.extend( + (path, architecture) + for path in macho_files_under([python_runtime]) + for architecture in architectures + ) + + site_packages = contents / "Resources" / "Python" / "site-packages" + if site_packages.is_dir(): + for path in site_packages.rglob("*"): + if path.is_file() and path.suffix in {".so", ".dylib"}: + roots.extend((path, architecture) for architecture in architectures) + + tools_bin = contents / "Resources" / "Tools" / "bin" + for tool in REQUIRED_HOST_TOOLS: + if len(architectures) == 1: + roots.append((tools_bin / tool, architectures[0])) + continue + roots.extend((tools_bin / architecture / tool, architecture) for architecture in architectures) + return roots + + +def assert_runtime_macho_architectures(app: Path, architectures: tuple[str, ...]) -> None: + failures: list[str] = [] + queue = runtime_architecture_roots(app, architectures) + visited: set[tuple[Path, str]] = set() + + while queue: + path, architecture = queue.pop(0) + resolved_path = path.resolve() + key = (resolved_path, architecture) + if key in visited: + continue + visited.add(key) + + actual = macho_architectures(path) + if actual and architecture not in actual: + failures.append(f"{path}: missing {architecture} (found: {', '.join(sorted(actual)) or 'none'})") + continue + + dependencies = macho_dependencies(path) + if dependencies is None: + continue + for dependency in dependencies: + dependency_path = resolve_macho_dependency(path, app, dependency) + if dependency_path is None: + if is_system_macho_dependency(dependency): + continue + continue + if not is_inside(dependency_path, app): + continue + if not dependency_path.is_file(): + failures.append(f"{path}: missing bundled dependency {dependency} -> {dependency_path}") + continue + queue.append((dependency_path, architecture)) + + if failures: + joined = "\n - ".join(failures) + raise RuntimeError(f"Bundled Mach-O runtime architecture validation failed:\n - {joined}") + + +def validate_app_resources(app: Path) -> None: + executable = app / "Contents" / "MacOS" / PRODUCT_NAME + completed = subprocess.run( + [str(executable), "--validate-resources"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + timeout=10, + ) + if completed.returncode != 0: + raise RuntimeError( + "App executable resource validation failed:\n" + f"stdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}" + ) + + +def parse_helper_events(stdout: str) -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + for line in stdout.splitlines(): + stripped = line.strip() + if not stripped: + continue + try: + value = json.loads(stripped) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + events.append(value) + return events + + +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) + result_event = next( + ( + event + for event in parse_helper_events(completed.stdout) + if event.get("operation") == operation and event.get("type") == "result" + ), + None, + ) + if result_event is None: + raise RuntimeError(f"{operation} smoke test did not emit a result event:\n{completed.stdout}\n{completed.stderr}") + if result_event.get("ok") is not True: + raise RuntimeError(f"{operation} smoke test failed:\n{completed.stdout}\n{completed.stderr}") + + +def assert_bundle_layout( + app: Path, + *, + icon_name: str | None = None, + architectures: tuple[str, ...] = (), + full_validation: bool = False, +) -> None: + executable = app / "Contents" / "MacOS" / PRODUCT_NAME + helper = app / "Contents" / "Helpers" / "tcapsule" + python_executable = bundled_python_executable(app) + python_dylib = bundled_python_dylib(app) + info_plist = app / "Contents" / "Info.plist" + resource_bundle = app / "Contents" / "Resources" / RESOURCE_BUNDLE_NAME + distribution = app / "Contents" / "Resources" / "Distribution" + artifact_manifest = distribution / "artifact-manifest.json" + tools_bin = app / "Contents" / "Resources" / "Tools" / "bin" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + required_executables = [executable, helper, python_executable] + missing_executables = [path for path in required_executables if not path.is_file() or not os.access(path, os.X_OK)] + if missing_executables: + joined = "\n - ".join(str(path) for path in missing_executables) + raise RuntimeError(f"App bundle is missing required executable(s):\n - {joined}") + if not python_dylib.is_file(): + raise RuntimeError(f"App bundle is missing bundled Python framework: {python_dylib}") + if architectures: + assert_macho_has_architectures(executable, architectures, "App executable") + assert_macho_has_architectures(python_executable, architectures, "Bundled Python executable") + assert_macho_has_architectures(python_dylib, architectures, "Bundled Python framework") + assert_python_extension_architectures(app, architectures) + assert_tool_architectures(app, architectures) + if full_validation: + assert_runtime_macho_architectures(app, architectures) + if not (resource_bundle / "en.lproj" / "Localizable.strings").is_file(): + raise RuntimeError(f"App bundle is missing Swift resource bundle localizations: {resource_bundle}") + if not python_packages.is_dir(): + raise RuntimeError(f"App bundle is missing bundled Python packages: {python_packages}") + if not (distribution / "bin").is_dir(): + raise RuntimeError(f"App bundle is missing bundled payload directory: {distribution / 'bin'}") + if not artifact_manifest.is_file(): + raise RuntimeError(f"App bundle is missing bundled artifact manifest: {artifact_manifest}") + if not tools_bin.is_dir(): + raise RuntimeError(f"App bundle is missing bundled tools directory: {tools_bin}") + if icon_name: + icon_file = app / "Contents" / "Resources" / f"{icon_name}.icns" + if not icon_file.is_file(): + raise RuntimeError(f"App bundle is missing app icon: {icon_file}") + with info_plist.open("rb") as handle: + info = plistlib.load(handle) + if info.get("CFBundleIconFile") != icon_name: + raise RuntimeError(f"Info.plist does not reference app icon {icon_name}") + assert_distribution_artifacts(distribution) + assert_bundled_ca_certificates(app) + assert_python_dependencies_are_bundled(app) + if full_validation: + assert_no_external_macho_dependencies(app) + assert_macho_code_signatures_valid(app) + validate_app_resources(app) + + +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: + architectures = resolve_architectures(args.arch) + executable, resource_build_dir = build_swift(args.configuration, architectures) + 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() + + icon_name = APP_ICON_NAME if args.icon else None + write_info_plist(contents, icon_name=icon_name) + shutil.copy2(executable, macos / PRODUCT_NAME) + (macos / PRODUCT_NAME).chmod(0o755) + copy_resources(resource_build_dir, resources) + if args.icon: + create_app_icon(args.icon.resolve(), resources, use_cache=not args.no_cache) + write_helper_wrapper(helpers / "tcapsule") + python_executable = copy_python_runtime(args, resources, architectures) + create_python_packages(str(python_executable), resources, architectures, use_cache=not args.no_cache) + finalize_python_bundle(resources) + copy_distribution(resources) + copy_native_tools_layer(app, architectures, use_cache=not args.no_cache) + assert_bundle_layout( + app, + icon_name=icon_name, + architectures=architectures, + full_validation=args.full_validation, + ) + + 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( + "--arch", + action="append", + choices=("universal", "native", "arm64", "x86_64"), + help="Architecture to build; repeat for multiple architectures. Defaults to universal.", + ) + parser.add_argument( + "--icon", + type=Path, + default=DEFAULT_ICON_SOURCE, + help="Source image to convert into the app bundle .icns icon.", + ) + parser.add_argument( + "--python-runtime-framework", + type=Path, + default=Path(os.environ["TCAPSULE_PACKAGE_PYTHON_FRAMEWORK"]) if os.getenv("TCAPSULE_PACKAGE_PYTHON_FRAMEWORK") else None, + help="Existing universal Python.framework to copy into the app bundle.", + ) + parser.add_argument( + "--python-runtime-pkg", + type=Path, + default=Path(os.environ["TCAPSULE_PACKAGE_PYTHON_PKG"]) if os.getenv("TCAPSULE_PACKAGE_PYTHON_PKG") else None, + help="Universal python.org macOS installer package to extract into the app bundle.", + ) + parser.add_argument( + "--python-runtime-url", + default=os.getenv("TCAPSULE_PACKAGE_PYTHON_URL", PYTHON_RUNTIME_URL), + help="Universal python.org macOS installer URL used when no local runtime source is provided.", + ) + parser.add_argument("--skip-smoke", action="store_true", help="Skip bundled helper capabilities and validate-install smoke tests.") + parser.add_argument("--no-cache", action="store_true", help="Rebuild package-only cached artifacts instead of reusing them.") + parser.add_argument("--full-validation", action="store_true", help="Run the full Mach-O dependency and signature validation pass even for trusted cached layers.") + 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/pyproject.toml b/pyproject.toml index 4ca8c1ba..a0becf92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "timecapsulesmb" -version = "2.1.7" +version = "2.2.0-beta3" description = "Deploy modern Samba to Apple AirPort Time Capsules." readme = "README.md" requires-python = ">=3.9" @@ -16,6 +16,13 @@ dependencies = [ "zeroconf>=0.132.2", ] +[project.optional-dependencies] +dev = [ + "coverage>=7.6.0", + "pytest>=8.4.0,<9", + "pytest-xdist>=3.6.1,<4", +] + [project.scripts] tcapsule = "timecapsulesmb.cli.main:main" diff --git a/requirements.txt b/requirements.txt index b694bb0b..5746bf60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ ifaddr>=0.2.0 pexpect>=4.9.0 pycryptodome>=3.20.0,<4 pytest>=8.4.0,<9 +pytest-xdist>=3.6.1,<4 zopfli>=0.2.3.post1 coverage>=7.6.0 diff --git a/src/timecapsulesmb/app/__init__.py b/src/timecapsulesmb/app/__init__.py new file mode 100644 index 00000000..bd0eaf19 --- /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 00000000..ed7c17b4 --- /dev/null +++ b/src/timecapsulesmb/app/confirmations.py @@ -0,0 +1,118 @@ +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 +_CONFIRMATION_ONLY_KEYS = frozenset({ + "confirmation_id", + "confirmation", +}) +_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] + presentation_id: str + presentation_values: 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)), + "presentation_id": self.presentation_id, + "presentation_values": jsonable(dict(self.presentation_values)), + } + + +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], + presentation_id: str, + presentation_values: Mapping[str, object] | None = None, +) -> 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, + presentation_id=presentation_id, + presentation_values=presentation_values or {}, + ) + + +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 require_confirmation( + params: Mapping[str, object], + confirmation: ConfirmationRequest, +) -> None: + if supplied_confirmation_id(params) == confirmation.confirmation_id: + return + raise AppConfirmationRequired(confirmation) diff --git a/src/timecapsulesmb/app/context.py b/src/timecapsulesmb/app/context.py new file mode 100644 index 00000000..e505cb91 --- /dev/null +++ b/src/timecapsulesmb/app/context.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.context import OperationContext +from timecapsulesmb.telemetry import build_device_os_version + +if TYPE_CHECKING: + from collections.abc import Mapping + + from timecapsulesmb.core.config import AppConfig + from timecapsulesmb.device.probe import ProbedDeviceState + from timecapsulesmb.services.runtime import ManagedTargetState + from timecapsulesmb.transport.ssh import SshConnection + + +class AppOperationContext: + """GUI/API operation adapter around shared diagnostic context and app events.""" + + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.diagnostics = OperationContext(operation) + self.result = "failure" + self.error: str | None = None + + @property + def current_stage(self) -> str | None: + return self.diagnostics.debug_stage or self.sink.current_stage(self.operation) + + @property + def current_risk(self) -> str | None: + return self.sink.current_risk(self.operation) + + @property + def values(self) -> Mapping[str, str] | None: + return self.diagnostics.values + + @values.setter + def values(self, value: Mapping[str, str] | None) -> None: + self.diagnostics.values = value + + @property + def config(self) -> AppConfig | None: + return self.diagnostics.config + + @config.setter + def config(self, value: AppConfig | None) -> None: + self.diagnostics.config = value + + @property + def connection(self) -> SshConnection | None: + return self.diagnostics.connection + + @connection.setter + def connection(self, value: SshConnection | None) -> None: + self.diagnostics.connection = value + + @property + def probe_state(self) -> ProbedDeviceState | None: + return self.diagnostics.probe_state + + @probe_state.setter + def probe_state(self, value: ProbedDeviceState | None) -> None: + self.diagnostics.probe_state = value + + @property + def finish_fields(self) -> dict[str, object]: + return self.diagnostics.finish_fields + + def stage(self, stage: str) -> None: + self.diagnostics.set_stage(stage) + self.sink.stage(self.operation, stage) + + def log(self, message: str, *, level: str = "info") -> None: + self.sink.log(self.operation, message, level=level) + + def check(self, *, status: str, message: str, details: dict[str, object] | None = None) -> None: + self.sink.check(self.operation, status=status, message=message, details=details) + + def emit_result(self, *, ok: bool, payload: object | None = None) -> None: + self.sink.result(self.operation, ok=ok, payload=payload) + + def to_operation_callbacks(self) -> OperationCallbacks: + return OperationCallbacks( + set_stage=self.stage, + log=self.log, + add_debug_fields=self.add_debug_fields, + update_fields=self.update_fields, + ) + + def update_fields(self, **fields: object) -> None: + self.diagnostics.update_fields(**fields) + + def add_debug_fields(self, **fields: object) -> None: + self.diagnostics.add_debug_fields(**fields) + + def set_error(self, message: str) -> None: + self.error = message + self.diagnostics.set_error(message) + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.set_error(message) + + def build_error(self) -> str | None: + return self.diagnostics.build_error() + + def diagnostic_error(self, message: object | None = None) -> str | None: + if message is not None and not self.diagnostics.error_lines: + self.set_error(str(message)) + return self.build_error() + + def apply_managed_target(self, target: ManagedTargetState) -> ManagedTargetState: + self.connection = target.connection + if target.probe_state is not None: + self.apply_probe_state(target.probe_state) + return target + + def apply_probe_state(self, probe_state: ProbedDeviceState) -> None: + self.probe_state = probe_state + probe = probe_state.probe_result + self.update_fields(device_model=probe.airport_model, device_syap=probe.airport_syap) + compatibility = probe_state.compatibility + if compatibility is not None: + self.update_fields( + device_os_version=build_device_os_version( + compatibility.os_name, + compatibility.os_release, + compatibility.arch, + ), + device_family=compatibility.payload_family, + ) diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py new file mode 100644 index 00000000..b11a254d --- /dev/null +++ b/src/timecapsulesmb/app/contracts.py @@ -0,0 +1,472 @@ +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 +from timecapsulesmb.services.reachability import ReachabilityResult +from timecapsulesmb.services.version_check import VersionCheckResult + + +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)} device(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 telemetry_preference_payload(*, install_id: str, telemetry_enabled: bool, bootstrap_path: str) -> dict[str, object]: + return _with_schema({ + "install_id": install_id, + "telemetry_enabled": telemetry_enabled, + "bootstrap_path": bootstrap_path, + "summary": "Telemetry is enabled." if telemetry_enabled else "Telemetry is disabled.", + }) + + +def version_check_payload(result: VersionCheckResult) -> dict[str, object]: + update_available = ( + result.current_version is not None + and result.current_version > result.local_version_code + ) + if result.should_block: + summary = "Update required." + elif update_available: + summary = "Update available." + else: + summary = "TimeCapsuleSMB is up to date." + if result.source == "unavailable": + summary = "Version metadata is unavailable." + return _with_schema({ + "should_block": result.should_block, + "update_available": update_available, + "checked_url": result.checked_url, + "message": result.message, + "download_url": result.download_url, + "local_version_code": result.local_version_code, + "current_version": result.current_version, + "min_supported_version": result.min_supported_version, + "latest_tag": result.latest_tag, + "source": result.source, + "summary": summary, + }) + + +def reachability_payload(result: ReachabilityResult) -> dict[str, object]: + checks = jsonable(result.checks) + if not isinstance(checks, list): + checks = [] + counts: dict[str, int] = {} + for check in checks: + if not isinstance(check, dict): + continue + status = str(check.get("status") or "").upper() + if status: + counts[status] = counts.get(status, 0) + 1 + return _with_schema({ + "status": result.status, + "ssh_host": result.ssh_host, + "smb_host": result.smb_host, + "checks": checks, + "counts": counts, + "summary": result.summary, + }) + + +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": 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": "Dry-run plan generated for fsck.", + }) + + +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": "Disk repair completed with fsck.", + } + 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"Found {finding_count} metadata 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 flash_backup_payload(raw: Mapping[str, object]) -> dict[str, object]: + banks = raw.get("banks") + bank_count = len(banks) if isinstance(banks, list) else 0 + return _with_schema({ + **raw, + "counts": {"banks": bank_count}, + "summary": f"Flash backup saved to {raw.get('backup_dir')}.", + }) + + +def _flash_plan_dict(raw: Mapping[str, object]) -> dict[str, object]: + plan = raw.get("flash_plan") + return plan if isinstance(plan, dict) else {} + + +def _flash_plan_child(plan: Mapping[str, object], key: str) -> dict[str, object] | None: + value = plan.get(key) + return dict(value) if isinstance(value, dict) else None + + +def _firmware_payload_path(raw: Mapping[str, object], plan: Mapping[str, object]) -> str | None: + target_bank = plan.get("target_bank") + mode = plan.get("mode") + if not isinstance(target_bank, str) or not isinstance(mode, str): + return None + files = raw.get("files") + if not isinstance(files, dict): + return None + value = files.get(f"{target_bank}_{mode}_basebinary_payload") + return value if isinstance(value, str) and value.strip() else None + + +def _apple_firmware_summary(mode: str, match: Mapping[str, object] | None, payload: Mapping[str, object] | None) -> str | None: + if mode == "check_apple": + version = None if match is None else match.get("template_version") + version_text = f" {version}" if isinstance(version, str) and version.strip() else "" + if match is not None and match.get("matched") is True: + return f"Active firmware bank matches Apple stock firmware{version_text}." + return f"Active firmware bank does not match Apple stock firmware{version_text}." + if mode == "download_only": + version = None if payload is None else payload.get("template_version") + product = None if payload is None else payload.get("template_product_id") + detail_parts = [] + if isinstance(version, str) and version.strip(): + detail_parts.append(f"version {version}") + if isinstance(product, str) and product.strip(): + detail_parts.append(f"product {product}") + detail = f" ({', '.join(detail_parts)})" if detail_parts else "" + return f"Apple restore firmware validated{detail}." + return None + + +def flash_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: + plan = _flash_plan_dict(raw) + mode = "unknown" + write_requested = False + already_satisfied = False + if plan: + mode = str(plan.get("mode") or mode) + write_requested = bool(plan.get("write_requested")) + already_satisfied = bool(plan.get("already_satisfied")) + apple_firmware_match = _flash_plan_child(plan, "apple_match") + firmware_payload = _flash_plan_child(plan, "payload") + firmware_payload_path = _firmware_payload_path(raw, plan) + apple_summary = _apple_firmware_summary(mode, apple_firmware_match, firmware_payload) + if apple_summary is not None: + summary = apple_summary + elif already_satisfied: + summary = "Flash plan is already satisfied; no write is needed." + elif write_requested: + summary = f"Flash {mode} write plan generated." + else: + summary = f"Flash {mode} plan generated." + return _with_schema({ + **raw, + "mode": mode, + "write_requested": write_requested, + "already_satisfied": already_satisfied, + "apple_firmware_match": apple_firmware_match, + "firmware_payload": firmware_payload, + "firmware_payload_path": firmware_payload_path, + "summary": summary, + }) + + +def flash_write_payload(raw: Mapping[str, object]) -> dict[str, object]: + outcome = raw.get("write_outcome") + status = "unknown" + mode = "unknown" + write_validated = False + post_write_action = "" + reboot_requested = False + rebooted = False + waited_after_reboot = False + if isinstance(outcome, dict): + status = str(outcome.get("status") or status) + mode = str(outcome.get("mode") or mode) + write_validated = bool(outcome.get("write_validated")) + post_write_action = str(outcome.get("post_write_action") or "") + reboot_requested = bool(outcome.get("reboot_requested")) + rebooted = bool(outcome.get("rebooted")) + waited_after_reboot = bool(outcome.get("waited_after_reboot")) + if status == "not_needed": + summary = "Flash write was not needed." + elif write_validated and mode == "patch": + summary = "Flash patch write validated; manual power cycle required." + elif write_validated and mode == "restore": + if post_write_action == "ssh_reboot" and rebooted: + summary = "Flash restore write validated; device rebooted." + elif post_write_action == "ssh_reboot" and reboot_requested: + summary = "Flash restore write validated; reboot requested." + else: + summary = "Flash restore write validated; manual reboot required." + elif write_validated: + summary = f"Flash {mode} write validated." + else: + summary = "Flash write completed." + return _with_schema({ + **raw, + "mode": mode, + "write_status": status, + "write_validated": write_validated, + "post_write_action": post_write_action, + "reboot_requested": reboot_requested, + "rebooted": rebooted, + "waited_after_reboot": waited_after_reboot, + "summary": summary, + }) + + +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 00000000..c68cfe0b --- /dev/null +++ b/src/timecapsulesmb/app/events.py @@ -0,0 +1,133 @@ +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] = {} + self._current_risk_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 current_risk(self, operation: str) -> str | None: + return self._current_risk_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()) + risk = fields.get("risk") + if isinstance(risk, str): + self._current_risk_by_operation[operation] = risk + 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 00000000..15178b9b --- /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 00000000..6db8a811 --- /dev/null +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.ops.configure import configure_operation +from timecapsulesmb.app.ops.deploy import deploy_operation +from timecapsulesmb.app.ops.discovery import discover_operation +from timecapsulesmb.app.ops.doctor import doctor_operation +from timecapsulesmb.app.ops.flash import flash_operation +from timecapsulesmb.app.ops.maintenance import ( + activate_operation, + fsck_operation, + repair_xattrs_operation, + uninstall_operation, +) +from timecapsulesmb.app.ops.reachability import reachability_operation +from timecapsulesmb.app.ops.readiness import ( + capabilities_operation, + set_telemetry_operation, + validate_install_operation, + version_check_operation, +) +from timecapsulesmb.services.app import OperationResult + + +OperationHandler = Callable[[dict[str, object], AppOperationContext], OperationResult] + + +@dataclass(frozen=True) +class OperationSpec: + name: str + handler: OperationHandler + telemetry: bool = False + public: bool = True + + +OPERATION_SPECS: tuple[OperationSpec, ...] = ( + OperationSpec("activate", activate_operation, telemetry=True), + OperationSpec("capabilities", capabilities_operation), + OperationSpec("configure", configure_operation, telemetry=True), + OperationSpec("deploy", deploy_operation, telemetry=True), + OperationSpec("discover", discover_operation, telemetry=True), + OperationSpec("doctor", doctor_operation, telemetry=True), + OperationSpec("flash", flash_operation, telemetry=True), + OperationSpec("fsck", fsck_operation, telemetry=True), + OperationSpec("reachability", reachability_operation), + OperationSpec("repair-xattrs", repair_xattrs_operation, telemetry=True), + OperationSpec("set-telemetry", set_telemetry_operation), + OperationSpec("uninstall", uninstall_operation, telemetry=True), + OperationSpec("validate-install", validate_install_operation), + OperationSpec("version-check", version_check_operation), +) + + +OPERATIONS: dict[str, OperationHandler] = {spec.name: spec.handler for spec in OPERATION_SPECS} +TELEMETRY_OPERATIONS = frozenset(spec.name for spec in OPERATION_SPECS if spec.telemetry) + + +def public_operation_names() -> list[str]: + return [spec.name for spec in OPERATION_SPECS if spec.public] diff --git a/src/timecapsulesmb/app/ops/common.py b/src/timecapsulesmb/app/ops/common.py new file mode 100644 index 00000000..cba1c55a --- /dev/null +++ b/src/timecapsulesmb/app/ops/common.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.services.app import config_path +from timecapsulesmb.services.credentials import overlay_request_credentials +from timecapsulesmb.services.runtime import ( + ManagedTargetState, + load_env_config, + load_optional_env_config, + resolve_env_connection, + resolve_validated_managed_target, +) +from timecapsulesmb.transport.ssh import SshConnection + +if TYPE_CHECKING: + from timecapsulesmb.core.config import AppConfig + + +def load_request_config(params: dict[str, object], context: AppOperationContext) -> "AppConfig": + context.stage("load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + context.config = config + return config + + +def load_optional_request_config(params: dict[str, object], context: AppOperationContext) -> "AppConfig": + context.stage("load_config") + config = overlay_request_credentials(load_optional_env_config(env_path=config_path(params)), params) + context.config = config + return config + + +def resolve_request_connection( + config: "AppConfig", + context: AppOperationContext, + *, + allow_empty_password: bool = True, +) -> SshConnection: + context.stage("resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=allow_empty_password) + context.connection = connection + return connection + + +def resolve_request_target( + config: "AppConfig", + context: AppOperationContext, + *, + profile: str, + include_probe: bool, +) -> ManagedTargetState: + context.stage("resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=context.operation, + profile=profile, + include_probe=include_probe, + ) + context.apply_managed_target(target) + return target diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py new file mode 100644 index 00000000..a2c86b43 --- /dev/null +++ b/src/timecapsulesmb/app/ops/configure.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import uuid + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.app.contracts import configure_payload +from timecapsulesmb.app.ops.discovery import selected_record_host, selected_record_properties +from timecapsulesmb.core.config import ( + DEFAULTS, + parse_bool, + parse_env_file, +) +from timecapsulesmb.core.net import endpoint_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.device.probe import probe_connection_state +from timecapsulesmb.integrations.acp import ACPAuthError, ACPConnectionError, ACPError +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, + jsonable, + require_string_param, + string_param, +) +from timecapsulesmb.services import configure as configure_service +from timecapsulesmb.services.configure import ( + ConfigureFlowError, + ConfigureFlowHooks, + ConfigureFlowRequest, +) +from timecapsulesmb.services.callbacks import OperationCallbacks + + +def selected_record_name(params: dict[str, object]) -> str: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return "" + name = str(selected.get("name") or "").strip() + return name + + +def require_enable_ssh_confirmation(params: dict[str, object], *, host: str) -> None: + device_name = selected_record_name(params) or endpoint_host(host) + require_confirmation( + params, + build_confirmation( + operation="configure", + params=params, + title="Enable SSH and reboot?", + message=f"SSH is closed on {device_name}. Enable SSH using AirPort ACP and reboot this AirPort device?", + action_title="Enable SSH and reboot", + risk="reboot", + summary="Enable SSH through AirPort ACP and reboot the AirPort device", + context={ + "host": host, + "device_name": device_name, + "requires_reboot": True, + }, + presentation_id="configure.enable_ssh_reboot", + presentation_values={ + "device_name": device_name, + "requires_reboot": True, + }, + ), + ) + + +def configure_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("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"])) + try: + host = configure_service.configure_ssh_target( + string_param(params, "host") or selected_record_host(params) or existing.get("TC_HOST", ""), + ssh_opts, + ) + except ValueError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + password = require_string_param(params, "password") + selected_record = params.get("selected_record") + if isinstance(selected_record, dict): + context.add_debug_fields(selected_bonjour_record=selected_record) + selected_props = selected_record_properties(params) + + def before_enable_ssh(_connection, _probed_state) -> None: + context.stage("confirm_enable_ssh") + require_enable_ssh_confirmation(params, host=host) + + def probe_for_context(connection): + context.connection = connection + probed_state = probe_connection_state(connection) + context.apply_probe_state(probed_state) + return probed_state + + def apply_probe_to_context(connection, probed_state) -> None: + context.connection = connection + context.apply_probe_state(probed_state) + + try: + result = configure_service.run_configure_flow( + ConfigureFlowRequest( + existing=existing, + env_path=env_path, + host=host, + password=password, + ssh_opts=ssh_opts, + configure_id=configure_id, + persist_password=bool_param(params, "persist_password"), + discovered_airport_syap=selected_props.get("syAP") or None, + enable_ssh=bool_param(params, "enable_ssh", True), + ssh_wait_timeout=int_param(params, "ssh_wait_timeout", 180), + 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"])), + ), + ata_idle_seconds=params.get("ata_idle_seconds") if "ata_idle_seconds" in params else None, + ata_standby=params.get("ata_standby") if "ata_standby" in params else None, + probe=probe_for_context, + ), + callbacks=OperationCallbacks( + set_stage=context.stage, + add_debug_fields=context.add_debug_fields, + update_fields=context.update_fields, + log=context.log, + ), + hooks=ConfigureFlowHooks( + after_probe=apply_probe_to_context, + before_enable_ssh=before_enable_ssh, + ), + ) + except ConfigureFlowError as exc: + if exc.code == "auth_failed": + raise AppOperationError(str(exc), code="auth_failed") from exc + if exc.code == "unsupported_device": + raise AppOperationError(str(exc), code="unsupported_device") from exc + raise AppOperationError(str(exc), code="remote_error") from exc + except ValueError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc + except ACPConnectionError as exc: + if context.current_stage == "acp_identity_probe": + raise AppOperationError( + f"No AirPort ACP service responded at this address: {exc}", + code="remote_error", + ) from exc + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc + except ACPError as exc: + if context.current_stage == "acp_identity_probe": + raise AppOperationError(f"Failed to read AirPort identity via ACP: {exc}", code="remote_error") from exc + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc + + context.connection = result.connection + context.apply_probe_state(result.probe_state) + context.values = result.values + return OperationResult(True, configure_payload( + config_path=str(env_path), + host=result.host, + configure_id=configure_id, + ssh_authenticated=True, + device_syap=result.identity.syap, + device_model=result.identity.model, + compatibility=jsonable(result.compatibility) if result.compatibility is not None else None, + )) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py new file mode 100644 index 00000000..b2a9950e --- /dev/null +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.core.config import ( + DEFAULTS, + airport_family_display_name_from_identity, + parse_bool, +) +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.app.ops.common import ( + load_request_config, + resolve_request_target, +) +from timecapsulesmb.device.errors import DeviceError +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, + optional_bool_param, +) +from timecapsulesmb.services.deploy import ( + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + DEPLOY_STARTUP_ACTIVATE_NOW, + DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, + DeployArtifactValidationError, + DeployCompletionMessages, + DeployOptions, + DeployRuntimeConfig, + DeploymentStartupMode, + complete_deployment_after_upload, + deployment_plan_to_jsonable, + deploy_upload_stage, + payload_family_description, + prepare_deploy_preflight, + prepare_deployment_plan, + upload_and_verify_deployment_payload, +) +from timecapsulesmb.services.reboot import RebootFlowError +from timecapsulesmb.services.runtime_verification import verify_managed_runtime_ready + +if TYPE_CHECKING: + from timecapsulesmb.transport.ssh import SshConnection + + +@dataclass(frozen=True) +class DeployConfirmationPresentation: + title: str + message: str + action_title: str + risk: str + summary: str + presentation_id: str + + +def optional_unsigned_int_override_param(params: dict[str, object], name: str) -> int | str | None: + if name not in params or params.get(name) is None: + return None + value = params.get(name) + if isinstance(value, str) and value.strip() == "": + return "" + return int_param(params, name, 0) + + +def confirmation_presentation_for_startup_mode( + *, + startup_mode: DeploymentStartupMode, + no_wait: bool, + device_name: str, +) -> DeployConfirmationPresentation: + if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: + if no_wait: + return DeployConfirmationPresentation( + title="Confirm NetBSD4 deployment and reboot request", + message=( + f"Deploy TimeCapsuleSMB to this {device_name}, request reboot, and return immediately " + "without running Samba activation after SSH returns?" + ), + action_title="Deploy and request reboot", + risk="reboot", + summary="NetBSD4 deployment with reboot request and no post-reboot activation wait", + presentation_id="deploy.netbsd4_no_wait", + ) + return DeployConfirmationPresentation( + title="Confirm NetBSD4 deployment", + message=f"Deploy TimeCapsuleSMB to this {device_name}, reboot it, then activate Samba after SSH returns?", + action_title="Deploy, reboot, and activate", + risk="reboot", + summary="NetBSD4 deployment with reboot and service activation", + presentation_id="deploy.netbsd4", + ) + if startup_mode == DEPLOY_STARTUP_ACTIVATE_NOW: + return DeployConfirmationPresentation( + title="Confirm deployment and runtime start", + message=f"Deploy TimeCapsuleSMB to this {device_name} and start Samba without rebooting it?", + action_title="Deploy and start SMB", + risk="remote_write", + summary="Deployment without reboot and runtime start", + presentation_id="deploy.activate_now", + ) + if no_wait: + return DeployConfirmationPresentation( + title="Confirm deployment and reboot request", + message=f"Deploy TimeCapsuleSMB to this {device_name}, request reboot, and return immediately?", + action_title="Deploy and request reboot", + risk="reboot", + summary="Deployment with reboot request and no post-reboot verification wait", + presentation_id="deploy.reboot_no_wait", + ) + return DeployConfirmationPresentation( + 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", + presentation_id="deploy.reboot", + ) + + +def _deploy_completion_payload(result) -> object: + return deploy_result_payload( + payload_dir=result.payload_dir, + netbsd4=result.is_netbsd4, + rebooted=result.rebooted, + reboot_requested=result.reboot_requested, + waited=result.waited, + verified=result.verified, + message=result.message, + payload_family=result.payload_family, + ) + + +def _verify_runtime_for_service( + connection: SshConnection, + *, + callbacks, + stage: str, + timeout_seconds: int, + heading: str, + failure_message: str, +) -> object: + return verify_managed_runtime_ready( + connection, + callbacks=callbacks, + stage=stage, + timeout_seconds=timeout_seconds, + heading=heading, + failure_message=failure_message, + ) + + +def deploy_operation(params: dict[str, object], context: AppOperationContext) -> 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") + deploy_options = DeployOptions( + dry_run=dry_run, + no_reboot=no_reboot, + no_wait=no_wait, + mount_wait_seconds=mount_wait, + allow_unsupported=allow_unsupported, + ) + no_wait = deploy_options.effective_no_wait + debug_logging = optional_bool_param(params, "debug_logging") + ata_idle_seconds = ( + int_param(params, "ata_idle_seconds", int(DEFAULTS["TC_ATA_IDLE_SECONDS"])) + if "ata_idle_seconds" in params and params.get("ata_idle_seconds") is not None + else None + ) + ata_standby = optional_unsigned_int_override_param(params, "ata_standby") + context.update_fields( + nbns_enabled=nbns_enabled, + reboot_was_attempted=False, + device_came_back_after_reboot=False, + ) + + config = load_request_config(params, context) + target = resolve_request_target(config, context, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=config_path(params)) + internal_share_use_disk_root = bool_param( + params, + "internal_share_use_disk_root", + parse_bool(config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ) + any_protocol = bool_param( + params, + "any_protocol", + parse_bool(config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ) + + try: + preflight = prepare_deploy_preflight( + connection, + target, + app_paths.distribution_root, + deploy_options, + callbacks=context.to_operation_callbacks(), + ) + except DeployArtifactValidationError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + except DeviceError as exc: + raise AppOperationError(str(exc), code="unsupported_device") from exc + payload_context = preflight.payload_context + payload_family = preflight.payload_family + is_netbsd4 = preflight.is_netbsd4 + startup_mode = preflight.startup_mode + context.log(f"Using {payload_family_description(payload_family)} payload.") + if not dry_run: + 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, + ) + presentation = confirmation_presentation_for_startup_mode( + startup_mode=startup_mode, + no_wait=no_wait, + device_name=device_name, + ) + presentation_values = { + "device_name": device_name, + "netbsd4": is_netbsd4, + "requires_reboot": preflight.requires_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + "startup_mode": startup_mode, + } + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title=presentation.title, + message=presentation.message, + action_title=presentation.action_title, + risk=presentation.risk, + summary=presentation.summary, + context={ + "host": connection.host, + "payload_family": payload_family, + "netbsd4": is_netbsd4, + "requires_reboot": preflight.requires_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + "startup_mode": startup_mode, + }, + presentation_id=presentation.presentation_id, + presentation_values=presentation_values, + ), + ) + try: + prepared_plan = prepare_deployment_plan( + connection, + app_paths.distribution_root, + payload_context, + dry_run=dry_run, + payload_dir_name=deploy_options.payload_dir_name, + mount_wait_seconds=mount_wait, + callbacks=context.to_operation_callbacks(), + artifacts=preflight.artifacts, + wait_after_reboot=not no_wait, + ) + except DeviceError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + plan = prepared_plan.plan + if dry_run: + return OperationResult(True, deploy_plan_payload( + deployment_plan_to_jsonable(plan), + payload_family=payload_family, + netbsd4=is_netbsd4, + )) + + current_upload_stage: str | None = None + + def stage_upload(transfer) -> None: + nonlocal current_upload_stage + stage = deploy_upload_stage(transfer) + if stage == current_upload_stage: + return + current_upload_stage = stage + context.stage(stage) + + def log_payload_verification(verification, _post_sync: bool) -> None: + context.log(verification.detail) + + try: + upload_and_verify_deployment_payload( + config, + connection=connection, + prepared_plan=prepared_plan, + runtime_config=DeployRuntimeConfig( + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + internal_share_use_disk_root=internal_share_use_disk_root, + any_protocol=any_protocol, + ata_idle_seconds=ata_idle_seconds, + ata_standby=ata_standby, + ), + callbacks=context.to_operation_callbacks(), + initial_upload_stage=None, + on_uploading=stage_upload, + on_before_flush=lambda: context.log("Flushing deployed payload to disk..."), + on_verified=log_payload_verification, + ) + except ValueError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + except DeviceError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + + try: + completion = complete_deployment_after_upload( + connection, + prepared_plan, + no_wait=no_wait, + callbacks=context.to_operation_callbacks(), + messages=DeployCompletionMessages(), + verify_runtime_func=_verify_runtime_for_service, + ) + except RebootFlowError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + except DeviceError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + return OperationResult(True, _deploy_completion_payload(completion)) + + +def verify_runtime( + context: AppOperationContext, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, + failure_message: str = "Managed runtime did not become ready.", +) -> None: + try: + _verify_runtime_for_service( + connection, + callbacks=context.to_operation_callbacks(), + stage=stage, + timeout_seconds=timeout_seconds, + heading="Waiting for managed runtime to finish starting...", + failure_message=failure_message, + ) + except DeviceError as exc: + raise AppOperationError( + str(exc), + code="remote_error", + ) from exc diff --git a/src/timecapsulesmb/app/ops/discovery.py b/src/timecapsulesmb/app/ops/discovery.py new file mode 100644 index 00000000..f9f2ba8a --- /dev/null +++ b/src/timecapsulesmb/app/ops/discovery.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.contracts import discover_payload +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot_merged_detailed, + 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.services.app import OperationResult, 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], context: AppOperationContext) -> OperationResult: + timeout = float_param(params, "timeout", DEFAULT_BROWSE_TIMEOUT_SEC) + context.stage("bonjour_discovery") + snapshot, diagnostics = discover_snapshot_merged_detailed(timeout=timeout) + payload = discover_payload(snapshot_payload(snapshot)) + counts = payload.get("counts") + devices = payload.get("devices") + context.update_fields( + discovery_timeout_sec=timeout, + discovery_instance_count=len(snapshot.instances), + discovery_resolved_count=len(snapshot.resolved), + discovery_device_count=len(devices) if isinstance(devices, list) else None, + ) + if isinstance(counts, dict): + context.update_fields(discovery_counts=counts) + context.add_debug_fields(discovery_diagnostics=diagnostics) + return OperationResult(True, payload) diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py new file mode 100644 index 00000000..e7c3c349 --- /dev/null +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.contracts import doctor_payload +from timecapsulesmb.app.ops.common import load_request_config, resolve_request_connection +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.services.app import OperationResult, bool_param, config_path +from timecapsulesmb.services.doctor import build_doctor_error, doctor_status_counts + + +def doctor_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + config = load_request_config(params, context) + app_paths = resolve_app_paths(config_path=config_path(params)) + skip_ssh = bool_param(params, "skip_ssh") + skip_bonjour = bool_param(params, "skip_bonjour") + skip_smb = bool_param(params, "skip_smb") + context.update_fields(skip_ssh=skip_ssh, skip_bonjour=skip_bonjour, skip_smb=skip_smb) + connection = None + if not skip_ssh and config.has_value("TC_HOST"): + connection = resolve_request_connection(config, context, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + context.check(status=result.status, message=result.message, details=result.details) + + context.stage("run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=skip_ssh, + skip_bonjour=skip_bonjour, + skip_smb=skip_smb, + on_result=on_result, + debug_fields=debug_fields, + ) + context.add_debug_fields(**debug_fields) + status_counts = doctor_status_counts(results) + context.update_fields( + fatal=fatal, + check_count=len(results), + pass_count=status_counts["PASS"], + warn_count=status_counts["WARN"], + fail_count=status_counts["FAIL"], + info_count=status_counts["INFO"], + ) + error = build_doctor_error(results, debug_fields) if fatal else None + if error: + context.set_error(error) + return OperationResult( + not fatal, + doctor_payload(fatal=fatal, results=results, error=error), + diagnostic_error=context.build_error() if fatal else None, + ) diff --git a/src/timecapsulesmb/app/ops/flash.py b/src/timecapsulesmb/app/ops/flash.py new file mode 100644 index 00000000..f9577ab4 --- /dev/null +++ b/src/timecapsulesmb/app/ops/flash.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from pathlib import Path + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.app.contracts import flash_backup_payload, flash_plan_payload, flash_write_payload +from timecapsulesmb.app.ops.common import ( + load_request_config, + resolve_request_target, +) +from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.device.errors import DeviceError +from timecapsulesmb.flash import FlashAnalysisError +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + optional_bool_param, + required_path_param, + string_param, +) +from timecapsulesmb.services.flash import ( + WRITE_OPERATIONS, + FlashTarget, + backup_flash, + plan_flash_from_backup, + record_post_write_action, + record_write_outcome, + require_netbsd4_flash_target, + validate_live_target_matches_backup, + write_flash_plan, +) +from timecapsulesmb.services.runtime import ( + require_connection_compatibility, +) +from timecapsulesmb.services.reboot import RebootFlowError, request_reboot, request_reboot_and_wait +from timecapsulesmb.transport.errors import TransportError + + +FLASH_ACTIONS = {"backup", "plan", "write"} +PLAN_OPERATIONS = {"patch", "restore", "check_apple", "download_only"} +FLASH_RESTORE_REBOOT_STRATEGY = "ssh_shutdown_then_reboot" +FLASH_RESTORE_REBOOT_NO_DOWN_MESSAGE = ( + "Firmware restore write validated, but the device did not go down after reboot request." +) +FLASH_RESTORE_REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after firmware restore reboot." + + +def flash_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + action = string_param(params, "action", "backup").strip() or "backup" + if action not in FLASH_ACTIONS: + raise AppOperationError(f"Unsupported flash action: {action}", code="validation_failed") + context.update_fields(flash_action=action) + if action == "backup": + return _backup_operation(params, context) + if action == "plan": + return _plan_operation(params, context) + return _write_operation(params, context) + + +def _optional_path_param(params: dict[str, object], name: str) -> Path | None: + value = params.get(name) + if value in (None, ""): + return None + return Path(str(value)).expanduser() + + +def _firmware_version_param(params: dict[str, object]) -> str | None: + value = string_param(params, "firmware_version").strip() + return value or None + + +def _plan_operation_param(params: dict[str, object]) -> str: + plan_operation = string_param(params, "mode", "patch").strip() or "patch" + if plan_operation not in PLAN_OPERATIONS: + raise AppOperationError(f"Unsupported flash plan mode: {plan_operation}", code="validation_failed") + return plan_operation + + +def _write_operation_param(params: dict[str, object]) -> str: + plan_operation = _plan_operation_param(params) + if plan_operation not in WRITE_OPERATIONS: + raise AppOperationError(f"Flash mode {plan_operation} does not write firmware", code="validation_failed") + return plan_operation + + +def _write_reboot_policy(params: dict[str, object], plan_operation: str) -> tuple[bool, bool]: + explicit_reboot = optional_bool_param(params, "reboot_after_write") + reboot_after_write = explicit_reboot if explicit_reboot is not None else plan_operation == "restore" + if plan_operation == "patch" and reboot_after_write: + raise AppOperationError( + "Flash patch cannot request reboot; power cycle manually after the validated write", + code="validation_failed", + ) + wait_after_reboot = bool_param(params, "wait_after_reboot", True) if reboot_after_write else False + return reboot_after_write, wait_after_reboot + + +def _resolve_flash_target(config: AppConfig, context: AppOperationContext) -> FlashTarget: + target = resolve_request_target(config, context, profile="flash", include_probe=False) + context.stage("check_compatibility") + try: + compatibility = require_connection_compatibility(target.connection) + except DeviceError as exc: + raise AppOperationError(str(exc), code="unsupported_device") from exc + try: + return require_netbsd4_flash_target( + target.connection, + compatibility, + update_fields=context.update_fields, + log=context.log, + ) + except DeviceError as exc: + raise AppOperationError(str(exc), code="unsupported_device") from exc + + +def _backup_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + config = load_request_config(params, context) + target = _resolve_flash_target(config, context) + backup_dir = _optional_path_param(params, "backup_dir") + context.update_fields(backup_dir=str(backup_dir) if backup_dir is not None else None) + try: + bundle = backup_flash( + target=target, + backup_dir=backup_dir, + operation="read_only", + log=context.log, + stage=context.stage, + ) + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + except TransportError as exc: + raise AppOperationError(f"SSH flash read failed: {exc}", code="remote_error") from exc + return OperationResult(True, flash_backup_payload(bundle.manifest)) + + +def _plan_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + plan_operation = _plan_operation_param(params) + force = bool_param(params, "force") + backup_dir = required_path_param(params, "backup_dir") + firmware_template = _optional_path_param(params, "firmware_template") + firmware_version = _firmware_version_param(params) + context.update_fields( + flash_mode=plan_operation, + force=force, + backup_dir=str(backup_dir), + firmware_template=str(firmware_template) if firmware_template is not None else None, + firmware_version=firmware_version, + ) + try: + context.stage("inspect_backup") + context.stage("plan_flash") + bundle, _plan = plan_flash_from_backup( + backup_dir=backup_dir, + operation=plan_operation, + force=force, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + return OperationResult(True, flash_plan_payload(bundle.manifest)) + + +def _confirmation_message(target: FlashTarget, mode: str, bank: str | None, *, reboot_after_write: bool) -> str: + if mode == "patch": + return ( + f"Patch the primary firmware bank boot hook on {target.acp_host} " + "and acknowledge that manual power cycle is required after a successful write?" + ) + bank_text = f" {bank}" if bank else "" + if reboot_after_write: + return f"Restore Apple stock firmware to the active{bank_text} bank on {target.acp_host} and reboot after validation?" + return f"Restore Apple stock firmware to the active{bank_text} bank on {target.acp_host}?" + + +def _finish_validated_write( + *, + context: AppOperationContext, + target: FlashTarget, + bundle, + plan_operation: str, + reboot_after_write: bool, + wait_after_reboot: bool, +) -> None: + if plan_operation == "patch": + record_post_write_action( + bundle=bundle, + post_write_action="manual_power_cycle", + reboot_requested=False, + rebooted=False, + waited_after_reboot=False, + ) + return + if not reboot_after_write: + record_post_write_action( + bundle=bundle, + post_write_action="manual_reboot", + reboot_requested=False, + rebooted=False, + waited_after_reboot=False, + ) + return + + record_post_write_action( + bundle=bundle, + post_write_action="ssh_reboot", + reboot_requested=True, + rebooted=False, + waited_after_reboot=wait_after_reboot, + ) + if wait_after_reboot: + try: + request_reboot_and_wait( + target.connection, + strategy=FLASH_RESTORE_REBOOT_STRATEGY, + callbacks=context.to_operation_callbacks(), + down_timeout_seconds=60, + up_timeout_seconds=240, + reboot_no_down_message=FLASH_RESTORE_REBOOT_NO_DOWN_MESSAGE, + reboot_up_timeout_message=FLASH_RESTORE_REBOOT_UP_TIMEOUT_MESSAGE, + ) + except RebootFlowError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + record_post_write_action( + bundle=bundle, + post_write_action="ssh_reboot", + reboot_requested=True, + rebooted=True, + waited_after_reboot=True, + ) + return + try: + request_reboot( + target.connection, + strategy=FLASH_RESTORE_REBOOT_STRATEGY, + callbacks=context.to_operation_callbacks(), + raise_on_request_error=True, + ) + except RebootFlowError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + + +def _write_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + plan_operation = _write_operation_param(params) + reboot_after_write, wait_after_reboot = _write_reboot_policy(params, plan_operation) + force = bool_param(params, "force") + backup_dir = required_path_param(params, "backup_dir") + firmware_template = _optional_path_param(params, "firmware_template") + firmware_version = _firmware_version_param(params) + context.update_fields( + flash_mode=plan_operation, + force=force, + backup_dir=str(backup_dir), + firmware_template=str(firmware_template) if firmware_template is not None else None, + firmware_version=firmware_version, + reboot_after_write=reboot_after_write, + wait_after_reboot=wait_after_reboot, + ) + + try: + context.stage("inspect_backup") + context.stage("plan_flash") + bundle, plan = plan_flash_from_backup( + backup_dir=backup_dir, + operation=plan_operation, + force=force, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + if plan is None: + raise AppOperationError("Flash write has no plan", code="validation_failed") + if plan.already_satisfied: + record_write_outcome( + bundle=bundle, + plan=plan, + status="not_needed", + write_validated=False, + write_may_have_modified_device=False, + ) + return OperationResult(True, flash_write_payload(bundle.manifest)) + + config = load_request_config(params, context) + target = _resolve_flash_target(config, context) + bank = None if plan.target_bank is None else plan.target_bank.name + context.update_fields(target_bank=bank) + context.stage("confirm_write") + require_confirmation( + params, + build_confirmation( + operation="flash", + params=params, + title="Confirm firmware flash write", + message=_confirmation_message( + target, + plan_operation, + bank, + reboot_after_write=reboot_after_write, + ), + action_title="Write Firmware", + risk="destructive", + summary=f"Flash {plan_operation} firmware write", + context={ + "host": target.acp_host, + "backup_dir": str(bundle.backup_dir), + "mode": plan_operation, + "target_bank": bank, + "target_sha256": None if plan.target_bank is None else plan.target_bank.sha256, + "reboot_after_write": reboot_after_write, + "wait_after_reboot": wait_after_reboot, + }, + presentation_id=f"flash.{plan_operation}_write", + presentation_values={ + "host": target.acp_host, + "backup_dir": str(bundle.backup_dir), + "mode": plan_operation, + "target_bank": bank, + "reboot_after_write": reboot_after_write, + "wait_after_reboot": wait_after_reboot, + }, + ), + ) + + try: + context.stage("pre_write_validation") + validate_live_target_matches_backup( + connection=target.connection, + plan=plan, + log=context.log, + ) + context.stage("write_primary_bank" if plan_operation == "patch" else "write_active_bank") + context.log("Sending ACP flash command...") + write_flash_plan( + target=target, + bundle=bundle, + plan=plan, + log=context.log, + ) + context.stage("post_write_validation") + except FlashAnalysisError as exc: + raise AppOperationError(str(exc), code="operation_failed") from exc + except TransportError as exc: + raise AppOperationError(f"SSH post-write validation failed: {exc}", code="remote_error") from exc + _finish_validated_write( + context=context, + target=target, + bundle=bundle, + plan_operation=plan_operation, + reboot_after_write=reboot_after_write, + wait_after_reboot=wait_after_reboot, + ) + return OperationResult(True, flash_write_payload(bundle.manifest)) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py new file mode 100644 index 00000000..28cfaa18 --- /dev/null +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import shlex +import sys + +from timecapsulesmb.app.context import AppOperationContext +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.services.credentials import overlay_request_credentials +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation +from timecapsulesmb.app.ops.common import ( + load_request_config, + resolve_request_connection, + resolve_request_target, +) +from timecapsulesmb.app.ops.deploy import verify_runtime +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME +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_runtime_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.storage import UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, + optional_int_param, + required_path_param, + string_param, +) +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.reboot import RebootFlowError, observe_reboot_cycle, request_reboot, request_reboot_and_wait +from timecapsulesmb.services.activation import decide_manual_activation +from timecapsulesmb.services.maintenance import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + FSCK_REBOOT_NO_DOWN_MESSAGE, + UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + 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.deploy import require_supported_payload +from timecapsulesmb.services import repair_xattrs as repair_xattrs_service +from timecapsulesmb.services import storage as storage_service +from timecapsulesmb.services.runtime import ( + load_env_config, + load_optional_env_config, + resolve_env_connection, +) +from timecapsulesmb.transport.ssh import run_ssh + + +REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." + + +def activate_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + operation = "activate" + dry_run = bool_param(params, "dry_run") + context.stage("build_activation_plan") + plan = build_runtime_activation_plan() + if dry_run: + return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) + + config = load_request_config(params, context) + confirmation_connection = resolve_request_connection(config, context, 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, + }, + presentation_id="activate.netbsd4", + presentation_values={"netbsd4": True}, + ), + ) + + target = resolve_request_target(config, context, 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 + context.stage("probe_runtime") + decision = decide_manual_activation(connection) + context.add_debug_fields( + activation_decision=decision.reason, + manual_activation_required=decision.run_actions, + ) + context.log(decision.detail) + if not decision.run_actions: + return OperationResult(True, activation_result_payload(already_active=True)) + + context.stage("run_activation") + run_remote_actions(connection, plan.actions) + verify_runtime(context, connection, stage="verify_runtime_activation", timeout_seconds=200) + return OperationResult(True, activation_result_payload( + already_active=False, + message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + )) + + +def uninstall_operation(params: dict[str, object], context: AppOperationContext) -> 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) + config = load_request_config(params, context) + connection = resolve_request_connection(config, context, allow_empty_password=True) + if not dry_run: + presentation_id = "uninstall.no_reboot" if no_reboot else "uninstall.reboot" + presentation_values = { + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + } + 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, + }, + presentation_id=presentation_id, + presentation_values=presentation_values, + ), + ) + 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: + mounted_volumes = storage_service.mount_mast_volumes_with_diagnostics( + connection, + callbacks=context.to_operation_callbacks(), + 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] + context.stage("build_uninstall_plan") + plan = build_uninstall_plan( + connection.host, + volume_roots, + payload_dirs, + reboot_after_uninstall=not no_reboot, + wait_after_reboot=not no_wait, + ) + if dry_run: + return OperationResult(True, uninstall_plan_payload(uninstall_plan_to_jsonable(plan))) + context.stage("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: + try: + request_reboot( + connection, + strategy="acp_then_ssh", + callbacks=context.to_operation_callbacks(), + raise_on_request_error=True, + ) + except RebootFlowError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=True, + waited=False, + )) + try: + request_reboot_and_wait( + connection, + strategy="acp_then_ssh", + callbacks=context.to_operation_callbacks(), + down_timeout_seconds=60, + up_timeout_seconds=240, + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + reboot_up_timeout_message=REBOOT_UP_TIMEOUT_MESSAGE, + ) + except RebootFlowError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + context.stage("verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + context.log(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], context: AppOperationContext) -> 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: + presentation_id = "fsck.no_reboot" if no_reboot else "fsck.reboot" + volume = string_param(params, "volume") + 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": volume, + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + presentation_id=presentation_id, + presentation_values={ + "volume": volume, + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + ) + context.stage("load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + context.config = config + context.stage("resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + context.connection = connection + mounted_volumes = storage_service.mount_mast_volumes_with_diagnostics( + connection, + callbacks=context.to_operation_callbacks(), + wait_seconds=mount_wait, + mount_stage="mount_hfs_volumes", + ) + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) + if list_volumes: + context.stage("list_fsck_volumes") + context.log(format_fsck_targets(targets)) + return OperationResult(True, fsck_volume_list_payload({ + "targets": [fsck_target_to_jsonable(target) for target in targets], + })) + + context.stage("select_fsck_volume") + try: + target = select_fsck_target( + targets, + string_param(params, "volume") or None, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + context.update_fields(fsck_device=target.device, fsck_mountpoint=target.mountpoint) + if dry_run: + context.log(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, + ))) + + context.stage("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(): + context.log(line) + context.update_fields(returncode=proc.returncode) + if proc.returncode != 0: + context.set_error(f"Disk repair exited with fsck status {proc.returncode}") + 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, + )) + try: + observe_reboot_cycle( + connection, + callbacks=context.to_operation_callbacks(), + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + reboot_up_timeout_message=REBOOT_UP_TIMEOUT_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + except RebootFlowError as exc: + raise AppOperationError(str(exc), code="remote_error") from exc + return OperationResult(True, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + reboot_requested=True, + waited=True, + verified=True, + )) + + +def repair_xattrs_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + operation = "repair-xattrs" + context.stage("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)}, + presentation_id="repair_xattrs", + presentation_values={"path": str(path)}, + ), + ) + context.stage("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)) + context.config = config + request = repair_xattrs_service.RepairXattrsRequest( + path=path, + dry_run=dry_run, + approve_repairs=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, + ) + try: + result = repair_xattrs_service.run_repair( + request, + config, + callbacks=OperationCallbacks( + set_stage=context.stage, + update_fields=context.update_fields, + log=context.log, + ), + ) + except repair_xattrs_service.RepairXattrsServiceError as exc: + raise AppOperationError(str(exc) or "repair-xattrs failed", code="validation_failed") from exc + return OperationResult(result.returncode == 0, repair_xattrs_payload(result.to_payload_fields())) diff --git a/src/timecapsulesmb/app/ops/reachability.py b/src/timecapsulesmb/app/ops/reachability.py new file mode 100644 index 00000000..c427db91 --- /dev/null +++ b/src/timecapsulesmb/app/ops/reachability.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.contracts import reachability_payload +from timecapsulesmb.app.ops.common import load_optional_request_config +from timecapsulesmb.services.app import OperationResult +from timecapsulesmb.services.credentials import request_password +from timecapsulesmb.services.reachability import run_reachability + + +def reachability_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + config = load_optional_request_config(params, context) + + result = run_reachability( + config, + params, + password=request_password(params), + stage=context.stage, + ) + for check in result.checks: + details = {} + if check.host is not None: + details["host"] = check.host + if check.detail is not None: + details["detail"] = check.detail + context.check(status=check.status, message=check.message, details=details) + return OperationResult(True, reachability_payload(result)) diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py new file mode 100644 index 00000000..bdbd131c --- /dev/null +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import hashlib +from urllib.parse import urlparse + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.contracts import ( + capabilities_payload, + install_validation_payload, + telemetry_preference_payload, + version_check_payload, +) +from timecapsulesmb.core.paths import artifact_manifest_resource, resolve_app_paths +from timecapsulesmb.core.release import CLI_VERSION, CLI_VERSION_CODE +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + validate_install, +) +from timecapsulesmb.identity import set_telemetry_enabled +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + string_param, +) +from timecapsulesmb.services.version_check import VERSION_CHECK_URL, check_client_version + + +def capabilities_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + context.stage("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=_public_operation_names(), + distribution_root=str(app_paths.distribution_root), + artifact_manifest_sha256=manifest_hash, + )) + + +def validate_install_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + context.stage("resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + context.stage("validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + context.check( + 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))) + + +def set_telemetry_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + if "enabled" not in params: + raise AppOperationError("missing required parameter: enabled", code="validation_failed") + enabled = bool_param(params, "enabled") + context.stage("resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + context.stage("write_bootstrap") + identity = set_telemetry_enabled(enabled, app_paths.bootstrap_path) + return OperationResult( + True, + telemetry_preference_payload( + install_id=identity.install_id, + telemetry_enabled=identity.telemetry_enabled, + bootstrap_path=str(app_paths.bootstrap_path), + ), + ) + + +def version_check_operation(params: dict[str, object], context: AppOperationContext) -> OperationResult: + url = string_param(params, "url", VERSION_CHECK_URL).strip() or VERSION_CHECK_URL + parsed_url = urlparse(url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise AppOperationError("url must be an HTTP/HTTPS URL", code="validation_failed") + context.stage("resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + context.stage("check_version") + result = check_client_version(url=url, cache_path=app_paths.version_check_cache_path) + return OperationResult(True, version_check_payload(result)) + + +def _public_operation_names() -> list[str]: + from timecapsulesmb.app.ops import public_operation_names + + return public_operation_names() diff --git a/src/timecapsulesmb/app/recovery.py b/src/timecapsulesmb/app/recovery.py new file mode 100644 index 00000000..a1b204b1 --- /dev/null +++ b/src/timecapsulesmb/app/recovery.py @@ -0,0 +1,360 @@ +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 + localization_key: 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 + if self.localization_key: + payload["localization_key"] = self.localization_key + 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 device 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, + ), + "cancelled": RecoveryInfo( + "Operation cancelled", + "The helper was interrupted before the operation completed.", + ("Retry the operation when ready.",), + 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 Apple device."), + retryable=True, + suggested_operation="configure", + action_ids=("replace_password",), + ), + ("configure", "unsupported_device"): RecoveryInfo( + "Unsupported device", + "The SSH probe succeeded, but the detected hardware or OS cannot use a bundled payload.", + ("Review the detected model and OS.", "Use a supported Apple AirPort Time Capsule or AirPort Extreme."), + 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( + "Disk repair confirmation required", + "Disk repair runs fsck, stops file sharing, unmounts the selected HFS disk, and may reboot the device.", + ("Review the selected volume.", "Confirm disk repair 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_identity_probe"): RecoveryInfo( + "AirPort not reachable at this address", + "The helper could not read the AirPort identity through ACP before enabling SSH.", + ( + "Check that the IP address is the Time Capsule or AirPort address.", + "Confirm you are on the same network as the device.", + "Use discovery or enter the current LAN IP address.", + ), + retryable=True, + suggested_operation="configure", + ), + ("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", + ), + ("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", + ), + ("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 device.", "Retry deploy after it is reachable."), + retryable=True, + suggested_operation="doctor", + ), + ("deploy", "remote_error", "wait_for_reboot_up"): RecoveryInfo( + "Reboot did not finish", + ( + "The payload was uploaded and the reboot request succeeded, but the device did not accept SSH " + "again before the 4 minute timeout. It may still be booting, or it may have come back with a " + "different IP address." + ), + ( + "Wait a few more minutes.", + "If the device is reachable at a new IP, update TC_HOST or rerun configure.", + "Make sure you are connected to the same network or Wi-Fi as the device.", + ( + "On NetBSD 4 devices, run tcapsule activate once SSH is reachable; deploy did not get far " + "enough to activate Samba after reboot." + ), + ), + retryable=True, + suggested_operation="doctor", + action_ids=("run_checkup",), + localization_key="deploy.remote_error.wait_for_reboot_up", + ), + ("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", "activate_runtime"): RecoveryInfo( + "Runtime activation failed", + "The deployed Samba runtime could not be started without rebooting.", + ("Retry install/update.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="deploy", + action_ids=("run_checkup",), + ), + ("deploy", "remote_error", "post_reboot_activation"): RecoveryInfo( + "Post-reboot activation failed", + "The device rebooted, but the deployed Samba runtime could not be started after SSH returned.", + ("Retry install/update.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="deploy", + action_ids=("run_checkup",), + ), + ("deploy", "remote_error", "verify_runtime_activation"): RecoveryInfo( + "Activated runtime not ready", + "The deployed Samba runtime was started but did not become healthy.", + ("Retry install/update.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="deploy", + action_ids=("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 00000000..ed844415 --- /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 00000000..d977b116 --- /dev/null +++ b/src/timecapsulesmb/app/service.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import traceback +from collections.abc import Callable + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.events import EventSink, redact +from timecapsulesmb.app.ops import OPERATIONS, TELEMETRY_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.errors import system_exit_message +from timecapsulesmb.core.config import ConfigError +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.app import AppOperationError, OperationResult, config_path +from timecapsulesmb.services.runtime import load_optional_env_config +from timecapsulesmb.telemetry import TelemetryClient +from timecapsulesmb.telemetry.operation import ( + OperationTelemetrySession, + client_from_environment, + confirmation_details, + telemetry_details_from_payload, + telemetry_options_from_params, +) +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], AppOperationContext], 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 + telemetry_session = _api_telemetry_session(operation, params) + if telemetry_session is not None: + telemetry_session.start() + context = AppOperationContext(operation, sink) + try: + result = handler(params, context) + except AppConfirmationRequired as exc: + sink.error( + operation, + str(exc), + code=exc.code, + details=exc.confirmation.to_jsonable(), + recovery=recovery_for(operation, exc.code, stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="confirmation_required", + details=confirmation_details(exc.confirmation), + risk=exc.confirmation.risk, + ) + return 1 + except AppOperationError as exc: + recovery = exc.recovery or recovery_for(operation, exc.code, stage=context.current_stage) + sink.error( + operation, + str(exc), + code=exc.code, + debug=redact(exc.debug) if exc.debug is not None else None, + recovery=recovery, + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(str(exc)) or str(exc), + ) + return 1 + except ConfigError as exc: + sink.error( + operation, + str(exc), + code="config_error", + recovery=recovery_for(operation, "config_error", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(str(exc)) or str(exc), + ) + return 1 + except TransportError as exc: + sink.error( + operation, + str(exc), + code="remote_error", + recovery=recovery_for(operation, "remote_error", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(str(exc)) or str(exc), + ) + return 1 + except KeyboardInterrupt: + message = "Operation cancelled." + sink.error( + operation, + message, + code="cancelled", + recovery=recovery_for(operation, "cancelled", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="cancelled", + error=context.diagnostic_error("Cancelled by user") or "Cancelled by user", + ) + return 130 + except SystemExit as exc: + message = system_exit_message(exc) + result = "success" if message in {"0", "None", ""} else "failure" + if result == "success": + context.emit_result(ok=True, payload={"summary": "Operation exited."}) + _finish_api_telemetry( + telemetry_session, + context, + result="success", + details={"summary": "Operation exited."}, + ) + return 0 + error = message or "Operation exited before completion" + sink.error( + operation, + error, + code="operation_failed", + recovery=recovery_for(operation, "operation_failed", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(error) or error, + ) + return 1 + except Exception as exc: + message = f"{type(exc).__name__}: {exc}" + sink.error( + operation, + message, + code="operation_failed", + debug={"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))}, + recovery=recovery_for(operation, "operation_failed", stage=context.current_stage), + ) + _finish_api_telemetry( + telemetry_session, + context, + result="failure", + error=context.diagnostic_error(message) or message, + ) + return 1 + context.emit_result(ok=result.ok, payload=result.payload) + payload_error = _payload_error(result.payload) if not result.ok else None + _finish_api_telemetry( + telemetry_session, + context, + result="success" if result.ok else "failure", + error=(result.diagnostic_error or context.diagnostic_error(payload_error) or payload_error) if not result.ok else None, + details=telemetry_details_from_payload(operation, params, result.payload), + ) + return 0 if result.ok else 1 + + +def _api_telemetry_session(operation: str, params: dict[str, object]) -> OperationTelemetrySession | None: + if operation not in TELEMETRY_OPERATIONS: + return None + try: + requested_config_path = config_path(params) + app_paths = resolve_app_paths(config_path=requested_config_path) + ensure_install_id(app_paths.bootstrap_path) + config = load_optional_env_config(env_path=requested_config_path) + telemetry = TelemetryClient.from_config( + config, + bootstrap_path=app_paths.bootstrap_path, + nbns_enabled=_nbns_enabled_for_telemetry(operation, params), + ) + return OperationTelemetrySession( + telemetry, + operation, + entrypoint="api", + client=client_from_environment(entrypoint="api"), + options=telemetry_options_from_params(params), + ) + except Exception: + return None + + +def _finish_api_telemetry( + session: OperationTelemetrySession | None, + context: AppOperationContext, + *, + result: str, + error: object | None = None, + details: dict[str, object] | None = None, + risk: str | None = None, +) -> None: + if session is None: + return + session.finish( + result=result, + error=error, + stage=context.current_stage, + risk=risk or context.current_risk, + details=details, + **context.finish_fields, + ) + + +def _payload_error(payload: object | None) -> object | None: + if not isinstance(payload, dict): + return "operation returned an unsuccessful result" + return payload.get("error") or payload.get("summary") or "operation returned an unsuccessful result" + + +def _nbns_enabled_for_telemetry(operation: str, params: dict[str, object]) -> bool | None: + if operation != "deploy": + return None + value = params.get("nbns_enabled", True) + 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 + return None diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py new file mode 100644 index 00000000..98468c97 --- /dev/null +++ b/src/timecapsulesmb/app/stage_policy.py @@ -0,0 +1,143 @@ +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."), + ("reachability", "load_config"): StagePolicy(LOCAL_READ, True, "Read selected device reachability configuration."), + ("reachability", "build_candidates"): StagePolicy(LOCAL_READ, True, "Build selected device host candidates."), + ("reachability", "check_dns"): StagePolicy(LOCAL_READ, True, "Resolve selected device host candidates."), + ("reachability", "check_ping"): StagePolicy(REMOTE_READ, True, "Ping selected device host candidates."), + ("reachability", "check_ssh_port"): StagePolicy(REMOTE_READ, True, "Check selected device SSH port reachability."), + ("reachability", "check_ssh_auth"): StagePolicy(REMOTE_READ, True, "Check selected device SSH authentication."), + ("reachability", "check_smb_port"): StagePolicy(REMOTE_READ, True, "Check selected device SMB port reachability."), + ("set-telemetry", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve local app state paths."), + ("set-telemetry", "write_bootstrap"): StagePolicy(LOCAL_WRITE, False, "Update local telemetry preference."), + ("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."), + ("version-check", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve version check cache path."), + ("version-check", "check_version"): StagePolicy(LOCAL_READ, True, "Fetch or read version metadata."), + ("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", "confirm_enable_ssh"): StagePolicy(REBOOT, True, "Confirm SSH enablement and reboot through AirPort ACP."), + ("configure", "acp_identity_probe"): StagePolicy(REMOTE_READ, True, "Read AirPort identity through ACP before enabling SSH."), + ("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 device 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", "upload_smbd"): StagePolicy(REMOTE_WRITE, False, "Upload smbd."), + ("deploy", "upload_mdns_advertiser"): StagePolicy(REMOTE_WRITE, False, "Upload mdns-advertiser."), + ("deploy", "upload_nbns_advertiser"): StagePolicy(REMOTE_WRITE, False, "Upload nbns-advertiser."), + ("deploy", "upload_boot_files"): StagePolicy(REMOTE_WRITE, False, "Upload boot files."), + ("deploy", "upload_runtime_config"): StagePolicy(REMOTE_WRITE, False, "Upload runtime config."), + ("deploy", "upload_samba_accounts"): StagePolicy(REMOTE_WRITE, False, "Upload Samba account 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", "probe_runtime"): StagePolicy(REMOTE_READ, True, "Checking whether the device will start TimeCapsuleSMB automatically."), + ("deploy", "activate_runtime"): StagePolicy(REMOTE_WRITE, False, "Start the deployed runtime without reboot."), + ("deploy", "post_reboot_activation"): StagePolicy(REMOTE_WRITE, False, "Start the deployed runtime after reboot."), + ("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, "Checking whether TimeCapsuleSMB is already running before activating it."), + ("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", "list_fsck_volumes"): StagePolicy(REMOTE_READ, True, "List mounted HFS volumes available for 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."), + ("flash", "load_config"): StagePolicy(LOCAL_READ, True, "Read flash configuration."), + ("flash", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("flash", "check_compatibility"): StagePolicy(REMOTE_READ, True, "Check NetBSD4 flash compatibility."), + ("flash", "read_flash"): StagePolicy(REMOTE_READ, True, "Read both firmware banks from the device."), + ("flash", "save_raw_backup"): StagePolicy(LOCAL_WRITE, False, "Save raw firmware bank backups locally."), + ("flash", "inspect_backup"): StagePolicy(LOCAL_READ, True, "Read and inspect the saved flash backup."), + ("flash", "analyze_flash"): StagePolicy(LOCAL_READ, True, "Analyze firmware bank safety metadata."), + ("flash", "plan_flash"): StagePolicy(LOCAL_WRITE, True, "Build and save the firmware flash plan."), + ("flash", "save_backup"): StagePolicy(LOCAL_WRITE, False, "Write flash backup manifest."), + ("flash", "confirm_write"): StagePolicy(DESTRUCTIVE, True, "Confirm firmware flash write."), + ("flash", "pre_write_validation"): StagePolicy(REMOTE_READ, True, "Verify the live target bank still matches the saved backup."), + ("flash", "write_primary_bank"): StagePolicy(DESTRUCTIVE, False, "Write the primary firmware bank."), + ("flash", "write_active_bank"): StagePolicy(DESTRUCTIVE, False, "Write the active firmware bank."), + ("flash", "post_write_validation"): StagePolicy(REMOTE_READ, True, "Read back and validate the written firmware bank."), +} + + +def stage_policy(operation: str, stage: str) -> StagePolicy | None: + return _POLICIES.get((operation, stage)) diff --git a/src/timecapsulesmb/apple_firmware.py b/src/timecapsulesmb/apple_firmware.py index 36e41e3c..255ff13d 100644 --- a/src/timecapsulesmb/apple_firmware.py +++ b/src/timecapsulesmb/apple_firmware.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from urllib.request import urlopen -from timecapsulesmb.core.paths import default_user_data_dir +from timecapsulesmb.core.paths import default_user_data_dir, safe_path_part from timecapsulesmb.flash import FlashAnalysisError, sha256_hex @@ -33,11 +33,6 @@ class FirmwareTemplateCandidate: from_cache: bool = False -def _safe_path_part(value: str) -> str: - safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) - return safe.strip("-.") or "device" - - def default_firmware_template_cache_root() -> Path: return default_user_data_dir() / "firmware-templates" @@ -110,8 +105,8 @@ def firmware_template_cache_path(*, cache_dir: Path, product_id: str, version: s parsed_name = Path(urlparse(url).path).name or "firmware.basebinary" if not parsed_name.endswith(".basebinary"): parsed_name = f"{parsed_name}.basebinary" - filename = f"{_safe_path_part(version)}-{suffix}-{_safe_path_part(parsed_name)}" - return cache_dir / _safe_path_part(product_id) / filename + filename = f"{safe_path_part(version)}-{suffix}-{safe_path_part(parsed_name)}" + return cache_dir / safe_path_part(product_id) / filename def read_cached_or_download_template(entry: dict[str, object], *, cache_dir: Path) -> FirmwareTemplateCandidate: diff --git a/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh b/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh index 82addd75..00911948 100644 --- a/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh +++ b/src/timecapsulesmb/assets/boot/samba4/common.d/50-runtime-staging.sh @@ -163,7 +163,7 @@ tc_smbd_fruit_model() { tc_generate_smb_conf_from_share_rows() { payload_dir=$1 runtime_share_rows=$2 - tc_ensure_runtime_identity + tc_ensure_runtime_identity || return 1 if [ -z "$TC_SMB_BIND_INTERFACES" ]; then tc_log "smb.conf generation failed: TC_SMB_BIND_INTERFACES is empty" return 1 diff --git a/src/timecapsulesmb/assets/boot/samba4/common.d/60-advertisers.sh b/src/timecapsulesmb/assets/boot/samba4/common.d/60-advertisers.sh index bf71909f..da9b26f7 100644 --- a/src/timecapsulesmb/assets/boot/samba4/common.d/60-advertisers.sh +++ b/src/timecapsulesmb/assets/boot/samba4/common.d/60-advertisers.sh @@ -395,10 +395,6 @@ tc_prepare_mdns_identity() { return 0 } -tc_mdns_debug_logging_enabled() { - [ "${MDNS_DEBUG_LOGGING:-0}" = "1" ] -} - tc_mdns_auto_ip_available() { tc_probe_mdns_socket_families >/dev/null 2>&1 } diff --git a/src/timecapsulesmb/checks/bonjour.py b/src/timecapsulesmb/checks/bonjour.py index 47ca5e6a..79246f18 100644 --- a/src/timecapsulesmb/checks/bonjour.py +++ b/src/timecapsulesmb/checks/bonjour.py @@ -6,7 +6,7 @@ from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.config import AppConfig -from timecapsulesmb.core.net import extract_host, ipv4_literal, ipv6_literal, resolve_host_ips +from timecapsulesmb.core.net import endpoint_host, ipv4_literal, ipv6_literal, resolve_host_ips from timecapsulesmb.discovery.bonjour import ( BonjourIPFamily, BonjourDiscoverySnapshot, @@ -67,7 +67,7 @@ def build_bonjour_expected_identity( runtime_naming_identity: RuntimeNamingIdentityProbeResult | None = None, ) -> BonjourExpectedIdentity: target_ip = None - candidate_ip = extract_host(config.get("TC_HOST")).strip() + candidate_ip = endpoint_host(config.get("TC_HOST")).strip() if candidate_ip: target_ip = ipv4_literal(candidate_ip) or ipv6_literal(candidate_ip) return BonjourExpectedIdentity( diff --git a/src/timecapsulesmb/checks/doctor.py b/src/timecapsulesmb/checks/doctor.py index 27d875ca..6f2d5944 100644 --- a/src/timecapsulesmb/checks/doctor.py +++ b/src/timecapsulesmb/checks/doctor.py @@ -5,18 +5,19 @@ from typing import Optional from timecapsulesmb.checks.doctor_debug import ( - _doctor_add_bonjour_debug_fields, + _add_bonjour_debug_fields, _doctor_add_fatal_runtime_log_tails, _doctor_add_mast_probe_on_disk_failure, ) from timecapsulesmb.checks.doctor_state import DoctorBonjourResult, DoctorInputs, DoctorOptions, DoctorSink from timecapsulesmb.checks.doctor_steps import ( + _add_active_smb_conf_results, + _add_bonjour_results, _build_doctor_target, - _doctor_add_active_smb_conf_info, _doctor_add_bonjour_naming_info, _doctor_check_active_smb_conf, _doctor_check_authenticated_smb, - _doctor_check_bonjour, + _doctor_check_deployed_config, _doctor_check_deployed_version, _doctor_check_device_compatibility, _doctor_check_direct_smb_port, @@ -73,6 +74,8 @@ def run_doctor_checks( target = _build_doctor_target(inputs) remote = _doctor_check_ssh_login(target, options, sink) + if _doctor_check_deployed_config(target, remote, sink).stop: + return sink.results, sink.fatal() if _doctor_check_deployed_version(target, remote, sink).stop: return sink.results, sink.fatal() if _doctor_check_runtime_ram_root(target, remote, sink).stop: @@ -85,10 +88,22 @@ def run_doctor_checks( smb_config = _doctor_check_active_smb_conf(target, remote, sink) network_plan = _doctor_check_network_plan(target, remote, smb_config, sink) _doctor_check_direct_smb_port(target, remote, network_plan, sink) - bonjour_result = _doctor_check_bonjour(inputs, target, naming, network_plan, sink) - _doctor_add_bonjour_debug_fields(bonjour_result, sink) + bonjour_result = _add_bonjour_results( + inputs.config, + naming.identity, + proxied_ssh=target.proxied_ssh, + skip_bonjour=inputs.options.skip_bonjour, + network_plan=network_plan.plan, + add_result=sink.add, + ) + _add_bonjour_debug_fields( + sink.debug_fields, + bonjour_debug_needed=bonjour_result.debug_needed, + bonjour_expected_debug=bonjour_result.expected_debug, + bonjour_zeroconf_debug=bonjour_result.zeroconf_debug, + ) _doctor_add_bonjour_naming_info(bonjour_result, sink) - _doctor_add_active_smb_conf_info(smb_config, sink) + _add_active_smb_conf_results(smb_config.text, smb_config.reason, sink.add) _doctor_check_nbns(target, remote, smb_config, naming, network_plan, sink) _doctor_check_authenticated_smb(inputs, target, smb_config, naming, bonjour_result, network_plan, sink) _doctor_add_mast_probe_on_disk_failure(target, remote, sink) diff --git a/src/timecapsulesmb/checks/doctor_debug.py b/src/timecapsulesmb/checks/doctor_debug.py index 01d046bc..551e9905 100644 --- a/src/timecapsulesmb/checks/doctor_debug.py +++ b/src/timecapsulesmb/checks/doctor_debug.py @@ -3,7 +3,7 @@ from collections.abc import Iterable from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.checks.doctor_state import DoctorBonjourResult, DoctorSink, DoctorTarget, RemoteAccess +from timecapsulesmb.checks.doctor_state import DoctorSink, DoctorTarget, RemoteAccess from timecapsulesmb.device.probe import read_remote_service_socket_diagnostics_conn, read_runtime_log_tails_conn from timecapsulesmb.device.storage import mast_probe_debug_summary, probe_mast_diagnostics_conn from timecapsulesmb.discovery.native_dns_sd import browse_native_dns_sd @@ -55,15 +55,6 @@ def _add_bonjour_debug_fields( debug_fields["bonjour_native_dns_sd"] = native_dns_sd -def _doctor_add_bonjour_debug_fields(bonjour_result: DoctorBonjourResult, sink: DoctorSink) -> None: - _add_bonjour_debug_fields( - sink.debug_fields, - bonjour_debug_needed=bonjour_result.debug_needed, - bonjour_expected_debug=bonjour_result.expected_debug, - bonjour_zeroconf_debug=bonjour_result.zeroconf_debug, - ) - - def _add_remote_service_socket_debug(target: DoctorTarget, remote: RemoteAccess, sink: DoctorSink) -> None: if sink.debug_fields is None or not remote.remote_checks_enabled: return diff --git a/src/timecapsulesmb/checks/doctor_steps.py b/src/timecapsulesmb/checks/doctor_steps.py index cc88c164..5d3bd1b3 100644 --- a/src/timecapsulesmb/checks/doctor_steps.py +++ b/src/timecapsulesmb/checks/doctor_steps.py @@ -1,8 +1,10 @@ from __future__ import annotations import ipaddress +import time from collections.abc import Callable, Iterable from pathlib import Path +from typing import TypeVar from timecapsulesmb.checks.bonjour import ( BonjourServiceTarget, @@ -43,6 +45,9 @@ from timecapsulesmb.checks.smb import ( SmbClientTarget, SmbClientTargetInput, + authenticated_smb_listing_attempts, + authenticated_smb_listing_retryable, + authenticated_smb_listing_with_attempts, check_authenticated_smb_listing, check_authenticated_smb_file_ops_detailed, ) @@ -55,14 +60,17 @@ from timecapsulesmb.checks.smb_targets import doctor_smb_servers from timecapsulesmb.core.config import AppConfig, DEFAULT_SAMBA_AUTH_USER, validate_app_config from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG -from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.net import endpoint_host from timecapsulesmb.device.compat import is_netbsd4_payload_family, is_netbsd6_payload_family, render_compatibility_message from timecapsulesmb.device.probe import ( + FLASH_RUNTIME_CONFIG, ProbedDeviceState, + ReadinessProbeResult, RemoteInterfaceProbeResult, RUNTIME_RAM_ROOT, RUNTIME_SMB_CONF, RuntimeNamingIdentityProbeResult, + flash_runtime_config_present_conn, nbns_flash_config_enabled_conn, probe_connection_state, probe_managed_mdns_takeover_conn, @@ -79,6 +87,90 @@ from timecapsulesmb.transport.ssh import SshConnection, ssh_local_forward +T = TypeVar("T") + + +DOCTOR_TRANSIENT_RETRY_DELAYS = (10, 15) +TRANSIENT_SMBD_READINESS_FAILURES = { + "managed smbd parent process is not running", + "smbd is not bound to required TCP 445 sockets", +} +TRANSIENT_MDNS_READINESS_FAILURES = { + "mdns-advertiser process is not running", + "mdns-advertiser is not bound to required UDP 5353 listener", +} +DOCTOR_CODE_RUNTIME_NOT_INSTALLED = "runtime_not_installed" + + +def _run_doctor_retryable_check( + run_attempt: Callable[[], T], + should_retry: Callable[[T], bool], + *, + retry_delays: tuple[int, ...] = DOCTOR_TRANSIENT_RETRY_DELAYS, + before_retry: Callable[[T, int], None] | None = None, +) -> T: + result = run_attempt() + for retry_delay in retry_delays: + if not should_retry(result): + break + if before_retry is not None: + before_retry(result, retry_delay) + time.sleep(retry_delay) + result = run_attempt() + return result + + +def _readiness_failure_details(probe: ReadinessProbeResult) -> list[str]: + details: list[str] = [] + steps = getattr(probe, "steps", ()) + if isinstance(steps, (list, tuple)): + for step in steps: + if getattr(step, "status", None) in {"fail", "timeout"}: + detail = getattr(step, "detail", None) + if isinstance(detail, str) and detail: + details.append(detail) + if details: + return details + + lines = getattr(probe, "lines", ()) + if not isinstance(lines, (list, tuple)): + return [] + return [line.removeprefix("FAIL:") for line in lines if isinstance(line, str) and line.startswith("FAIL:")] + + +def _readiness_probe_retryable(probe: ReadinessProbeResult, retryable_failures: set[str]) -> bool: + if probe.ready: + return False + failure_details = _readiness_failure_details(probe) + return bool(failure_details) and all(detail in retryable_failures for detail in failure_details) + + +def _authenticated_smb_listing_with_doctor_retries( + username: str, + password: str, + server: SmbClientTargetInput | list[SmbClientTargetInput], + *, + port: int | None = None, +) -> CheckResult: + attempts: list[dict[str, object]] = [] + + def run_attempt() -> CheckResult: + result = check_authenticated_smb_listing(username, password, server, port=port) + attempts.extend(authenticated_smb_listing_attempts(result)) + return authenticated_smb_listing_with_attempts(result, attempts) + + def mark_retry_delay(result: CheckResult, retry_delay: int) -> None: + for attempt in authenticated_smb_listing_attempts(result): + if "next_retry_delay_sec" not in attempt: + attempt["next_retry_delay_sec"] = retry_delay + + return _run_doctor_retryable_check( + run_attempt, + authenticated_smb_listing_retryable, + before_retry=mark_retry_delay, + ) + + def _add_probe_line_results( add_result: Callable[[CheckResult], None], lines: Iterable[str], @@ -511,11 +603,46 @@ def add_attempt_result(result: CheckResult) -> None: ) -def _doctor_share_name(active_smb_conf: str | None) -> str: - active_share_names = parse_active_share_names(active_smb_conf or "") +def _listing_disk_shares(listing_result: CheckResult) -> list[str]: + value = listing_result.details.get("disk_shares") + if not isinstance(value, list): + return [] + shares: list[str] = [] + for item in value: + if isinstance(item, str) and item and item not in shares: + shares.append(item) + return shares + + +def _select_smb_file_ops_share( + listing_result: CheckResult, + active_share_names: list[str], + active_smb_conf_reason: str, + add_result: Callable[[CheckResult], None], +) -> str | None: + disk_shares = _listing_disk_shares(listing_result) if active_share_names: - return active_share_names[0] - raise RuntimeError("could not determine active Samba share name") + for active_share_name in active_share_names: + if active_share_name in disk_shares: + add_result(CheckResult("PASS", f"authenticated SMB listing includes active share {active_share_name!r}")) + return active_share_name + expected = ", ".join(active_share_names) + listed = ", ".join(disk_shares) if disk_shares else "none" + add_result( + CheckResult( + "FAIL", + f"authenticated SMB listing did not include any active Samba share; expected one of: {expected}; listed disk shares: {listed}", + ) + ) + return None + + if not disk_shares: + add_result(CheckResult("FAIL", "authenticated SMB listing worked, but no disk shares were advertised")) + return None + + reason = active_smb_conf_reason or "active smb.conf did not list share names" + add_result(CheckResult("INFO", f"active Samba share comparison skipped; {reason}")) + return disk_shares[0] def _nbns_family_targets(network_plan: NetworkCheckPlan | None) -> tuple[NetworkFamilyPlan, ...]: @@ -656,7 +783,8 @@ def _add_tunneled_authenticated_smb_results( *, host: str, smb_password: str, - share_name: str, + active_share_names: list[str], + active_smb_conf_reason: str, remote_port: int, debug_prefix: str, debug_fields: dict[str, object] | None, @@ -665,7 +793,7 @@ def _add_tunneled_authenticated_smb_results( local_port = find_free_local_port() if debug_fields is not None: debug_fields[f"{debug_prefix}_listing_servers"] = ["127.0.0.1"] - debug_fields[f"{debug_prefix}_listing_expected_share"] = share_name + debug_fields[f"{debug_prefix}_listing_active_shares"] = active_share_names try: with ssh_local_forward( connection, @@ -673,11 +801,10 @@ def _add_tunneled_authenticated_smb_results( remote_host=host, remote_port=remote_port, ): - listing_result = check_authenticated_smb_listing( + listing_result = _authenticated_smb_listing_with_doctor_retries( DEFAULT_SAMBA_AUTH_USER, smb_password, "127.0.0.1", - expected_share_name=share_name, port=local_port, ) if debug_fields is not None and listing_result.details.get("attempts"): @@ -685,6 +812,14 @@ def _add_tunneled_authenticated_smb_results( add_result(listing_result) if listing_result.status != "PASS": return False + share_name = _select_smb_file_ops_share( + listing_result, + active_share_names, + active_smb_conf_reason, + add_result, + ) + if share_name is None: + return False file_ops_ok = True for result in check_authenticated_smb_file_ops_detailed( @@ -713,21 +848,19 @@ def _add_authenticated_smb_results( smb_password: str, proxied_ssh: bool, active_smb_conf: str | None, + active_smb_conf_reason: str, network_plan: NetworkCheckPlan | None, debug_fields: dict[str, object] | None, add_result: Callable[[CheckResult], None], ) -> None: - try: - share_name = _doctor_share_name(active_smb_conf) - except RuntimeError as exc: - add_result(CheckResult("FAIL", str(exc))) - return + active_share_names = parse_active_share_names(active_smb_conf or "") if proxied_ssh: _add_tunneled_authenticated_smb_results( connection, host=host, smb_password=smb_password, - share_name=share_name, + active_share_names=active_share_names, + active_smb_conf_reason=active_smb_conf_reason, remote_port=445, debug_prefix="authenticated_smb", debug_fields=debug_fields, @@ -738,12 +871,11 @@ def _add_authenticated_smb_results( smb_servers = _doctor_smb_client_targets(config, bonjour_target, runtime_naming_identity, network_plan) if debug_fields is not None: debug_fields["authenticated_smb_listing_servers"] = [_smb_client_target_debug(target) for target in smb_servers] - debug_fields["authenticated_smb_listing_expected_share"] = share_name - listing_result = check_authenticated_smb_listing( + debug_fields["authenticated_smb_listing_active_shares"] = active_share_names + listing_result = _authenticated_smb_listing_with_doctor_retries( DEFAULT_SAMBA_AUTH_USER, smb_password, smb_servers, - expected_share_name=share_name, port=445, ) if debug_fields is not None and listing_result.details.get("attempts"): @@ -761,7 +893,8 @@ def _add_authenticated_smb_results( connection, host=host, smb_password=smb_password, - share_name=share_name, + active_share_names=active_share_names, + active_smb_conf_reason=active_smb_conf_reason, remote_port=445, debug_prefix="authenticated_smb_tunnel", debug_fields=debug_fields, @@ -771,6 +904,14 @@ def _add_authenticated_smb_results( add_result(listing_result) return add_result(listing_result) + share_name = _select_smb_file_ops_share( + listing_result, + active_share_names, + active_smb_conf_reason, + add_result, + ) + if share_name is None: + return smb_server = listing_result.details.get("server") if not isinstance(smb_server, str) or not smb_server: @@ -812,7 +953,7 @@ def _build_doctor_target(inputs: DoctorInputs) -> DoctorTarget: ) return DoctorTarget( connection=connection, - host=extract_host(connection.host), + host=endpoint_host(connection.host), smb_password=inputs.config.require("TC_PASSWORD"), proxied_ssh=ssh_opts_use_proxy(connection.ssh_opts), ) @@ -838,6 +979,33 @@ def _doctor_check_ssh_login(target: DoctorTarget, options: DoctorOptions, sink: ) +def _doctor_check_deployed_config(target: DoctorTarget, remote: RemoteAccess, sink: DoctorSink) -> StepDecision: + if not remote.remote_checks_enabled: + return StepDecision() + + try: + config_present = flash_runtime_config_present_conn(target.connection) + except Exception as e: + sink.add(CheckResult("FAIL", f"deployed payload config probe failed; reboot the device and rerun doctor: {e}")) + return StepDecision(stop=True) + + if sink.debug_fields is not None: + sink.debug_fields["deployed_config_present"] = config_present + + if not config_present: + sink.add( + CheckResult( + "FAIL", + "deployed payload config not found; please run deploy to install on your device", + details={"code": DOCTOR_CODE_RUNTIME_NOT_INSTALLED}, + ) + ) + return StepDecision(stop=True) + + sink.add(CheckResult("PASS", f"deployed payload config {FLASH_RUNTIME_CONFIG} exists")) + return StepDecision() + + def _doctor_check_deployed_version(target: DoctorTarget, remote: RemoteAccess, sink: DoctorSink) -> StepDecision: if not remote.remote_checks_enabled: return StepDecision() @@ -951,7 +1119,10 @@ def _doctor_check_managed_smbd(target: DoctorTarget, remote: RemoteAccess, sink: if not remote.remote_checks_enabled: return - smbd_probe = probe_managed_smbd_conn(target.connection) + smbd_probe = _run_doctor_retryable_check( + lambda: probe_managed_smbd_conn(target.connection), + lambda probe: _readiness_probe_retryable(probe, TRANSIENT_SMBD_READINESS_FAILURES), + ) smbd_probe_lines = getattr(smbd_probe, "lines", ()) if not isinstance(smbd_probe_lines, (list, tuple)): smbd_probe_lines = () @@ -968,7 +1139,10 @@ def _doctor_check_managed_mdns(target: DoctorTarget, remote: RemoteAccess, sink: if not remote.remote_checks_enabled: return - mdns_probe = probe_managed_mdns_takeover_conn(target.connection) + mdns_probe = _run_doctor_retryable_check( + lambda: probe_managed_mdns_takeover_conn(target.connection), + lambda probe: _readiness_probe_retryable(probe, TRANSIENT_MDNS_READINESS_FAILURES), + ) mdns_probe_lines = getattr(mdns_probe, "lines", ()) if not isinstance(mdns_probe_lines, (list, tuple)): mdns_probe_lines = () @@ -1083,23 +1257,6 @@ def _doctor_check_direct_smb_port(target: DoctorTarget, remote: RemoteAccess, ne _add_remote_service_socket_debug(target, remote, sink) -def _doctor_check_bonjour( - inputs: DoctorInputs, - target: DoctorTarget, - naming: RuntimeNamingState, - network_plan: NetworkPlanState, - sink: DoctorSink, -) -> DoctorBonjourResult: - return _add_bonjour_results( - inputs.config, - naming.identity, - proxied_ssh=target.proxied_ssh, - skip_bonjour=inputs.options.skip_bonjour, - network_plan=network_plan.plan, - add_result=sink.add, - ) - - def _doctor_add_bonjour_naming_info(bonjour_result: DoctorBonjourResult, sink: DoctorSink) -> None: if bonjour_result.instance is not None: sink.add(CheckResult("INFO", f"advertised Bonjour instance: {bonjour_result.instance}")) @@ -1113,10 +1270,6 @@ def _doctor_add_bonjour_naming_info(bonjour_result: DoctorBonjourResult, sink: D sink.add(CheckResult("INFO", f"advertised Bonjour host label: unavailable ({bonjour_result.reason})")) -def _doctor_add_active_smb_conf_info(smb_config: SmbConfigState, sink: DoctorSink) -> None: - _add_active_smb_conf_results(smb_config.text, smb_config.reason, sink.add) - - def _doctor_check_nbns( target: DoctorTarget, remote: RemoteAccess, @@ -1162,6 +1315,7 @@ def _doctor_check_authenticated_smb( smb_password=target.smb_password, proxied_ssh=target.proxied_ssh, active_smb_conf=smb_config.text, + active_smb_conf_reason=smb_config.reason, network_plan=network_plan.plan, debug_fields=sink.debug_fields, add_result=sink.add, diff --git a/src/timecapsulesmb/checks/smb.py b/src/timecapsulesmb/checks/smb.py index 0cb04c88..36ca2f3e 100644 --- a/src/timecapsulesmb/checks/smb.py +++ b/src/timecapsulesmb/checks/smb.py @@ -32,6 +32,25 @@ def display(self) -> str: SmbClientTargetInput = Union[str, SmbClientTarget] +def parse_smbclient_disk_shares(stdout: str) -> list[str]: + shares: list[str] = [] + for raw_line in stdout.splitlines(): + line = raw_line.strip() + if not line: + continue + if "|" in line: + parts = line.split("|", 2) + if len(parts) >= 2 and parts[0] == "Disk": + share_name = parts[1].strip() + else: + continue + else: + share_name = line + if share_name and share_name != "IPC$" and share_name not in shares: + shares.append(share_name) + return shares + + def _normalize_smb_client_target(target: SmbClientTargetInput) -> SmbClientTarget: if isinstance(target, SmbClientTarget): return target @@ -113,6 +132,53 @@ def _smbclient_attempts_failure_summary(attempts: list[dict[str, object]]) -> st return "; ".join(_smbclient_attempt_failure_summary(index, attempt) for index, attempt in enumerate(attempts, start=1)) +def _smbclient_attempt_retryable(attempt: dict[str, object]) -> bool: + if attempt.get("outcome") == "timeout": + return True + if attempt.get("outcome") != "error": + return False + + retryable_fragments = ( + "NT_STATUS_IO_TIMEOUT", + "NT_STATUS_CONNECTION_REFUSED", + "NT_STATUS_CONNECTION_DISCONNECTED", + "NT_STATUS_NETWORK_UNREACHABLE", + "Connection refused", + "Connection reset by peer", + "Operation timed out", + ) + for key in ("failure", "stderr_tail", "stdout_tail"): + value = attempt.get(key) + if isinstance(value, str) and any(fragment in value for fragment in retryable_fragments): + return True + return False + + +def authenticated_smb_listing_attempts(result: CheckResult) -> list[dict[str, object]]: + attempts = result.details.get("attempts") + if not isinstance(attempts, list): + return [] + return [attempt for attempt in attempts if isinstance(attempt, dict)] + + +def authenticated_smb_listing_retryable(result: CheckResult) -> bool: + if result.status == "PASS": + return False + attempts = authenticated_smb_listing_attempts(result) + return bool(attempts) and all(_smbclient_attempt_retryable(attempt) for attempt in attempts) + + +def authenticated_smb_listing_with_attempts(result: CheckResult, attempts: list[dict[str, object]]) -> CheckResult: + if not attempts: + return result + details = dict(result.details) + details["attempts"] = attempts + if result.status == "PASS": + return CheckResult(result.status, result.message, details) + failure_msg = _smbclient_attempts_failure_summary(attempts) + return CheckResult(result.status, f"authenticated SMB listing failed after {len(attempts)} attempt(s): {failure_msg}", details) + + def _redacted_smbclient_command(args: list[str]) -> str: redacted: list[str] = [] redact_next = False @@ -184,10 +250,11 @@ def check_authenticated_smb_listing( if not command_exists("smbclient"): return CheckResult("FAIL", "missing local tool smbclient") if isinstance(server, list): + servers = server if isinstance(server, list) else [server] return try_authenticated_smb_listing( username, password, - server, + servers, expected_share_name=expected_share_name, port=port, timeout=timeout, @@ -221,13 +288,15 @@ def check_authenticated_smb_listing( if stderr_tail is not None: attempt["stderr_tail"] = stderr_tail if proc.returncode == 0: + disk_shares = parse_smbclient_disk_shares(proc.stdout) + attempt["disk_shares"] = disk_shares if expected_share_name is not None and expected_share_name not in proc.stdout: attempt["outcome"] = "missing_expected_share" attempt["expected_share"] = expected_share_name return CheckResult( "FAIL", f"authenticated SMB listing did not include expected share {expected_share_name!r} on {target.display}", - {"attempts": [attempt]}, + {"attempts": [attempt], "disk_shares": disk_shares}, ) attempt["outcome"] = "pass" if expected_share_name is not None: @@ -239,6 +308,7 @@ def check_authenticated_smb_listing( { "server": target.server, "ip_address": target.ip_address, + "disk_shares": disk_shares, "attempts": [attempt], }, ) @@ -265,8 +335,8 @@ def try_authenticated_smb_listing( return CheckResult("WARN", "SMB listing verification skipped: smbclient not found") attempts: list[dict[str, object]] = [] - for server_input in servers: - target = _normalize_smb_client_target(server_input) + targets = [_normalize_smb_client_target(server_input) for server_input in servers] + for target in targets: command = _redacted_smbclient_command(_smbclient_listing_args(target, username, password, port=port)) try: start = time.monotonic() @@ -291,6 +361,8 @@ def try_authenticated_smb_listing( if stderr_tail is not None: attempt["stderr_tail"] = stderr_tail if proc.returncode == 0: + disk_shares = parse_smbclient_disk_shares(proc.stdout) + attempt["disk_shares"] = disk_shares if expected_share_name is not None and expected_share_name not in proc.stdout: attempt["outcome"] = "missing_expected_share" attempt["expected_share"] = expected_share_name @@ -307,6 +379,7 @@ def try_authenticated_smb_listing( { "server": target.server, "ip_address": target.ip_address, + "disk_shares": disk_shares, "attempts": attempts, }, ) @@ -314,6 +387,7 @@ def try_authenticated_smb_listing( raw_failure = _smbclient_failure_line(proc) attempt["failure"] = raw_failure attempts.append(attempt) + failure_msg = _smbclient_attempts_failure_summary(attempts) return CheckResult("FAIL", f"authenticated SMB listing failed after {len(attempts)} attempt(s): {failure_msg}", {"attempts": attempts}) diff --git a/src/timecapsulesmb/checks/smb_targets.py b/src/timecapsulesmb/checks/smb_targets.py index a185d1fa..0997add7 100644 --- a/src/timecapsulesmb/checks/smb_targets.py +++ b/src/timecapsulesmb/checks/smb_targets.py @@ -2,7 +2,7 @@ from timecapsulesmb.checks.bonjour import BonjourServiceTarget from timecapsulesmb.core.config import AppConfig -from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.net import endpoint_host from timecapsulesmb.device.probe import RuntimeNamingIdentityProbeResult @@ -20,5 +20,5 @@ def add(value: str | None) -> None: if runtime_naming_identity is not None and runtime_naming_identity.mdns_host_label: add(f"{runtime_naming_identity.mdns_host_label}.local") add(bonjour_target.hostname if bonjour_target is not None else None) - add(extract_host(config.require("TC_HOST"))) + add(endpoint_host(config.require("TC_HOST"))) return ordered diff --git a/src/timecapsulesmb/cli/activate.py b/src/timecapsulesmb/cli/activate.py index bb84f4cc..127145de 100644 --- a/src/timecapsulesmb/cli/activate.py +++ b/src/timecapsulesmb/cli/activate.py @@ -4,17 +4,21 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import activate_deployed_runtime_flow +from timecapsulesmb.cli.flows import verify_managed_runtime_flow from timecapsulesmb.cli.runtime import ( add_config_argument, - load_env_config, + add_no_input_argument, + no_input_enabled, + 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_runtime_activation_plan +from timecapsulesmb.services.activation import decide_manual_activation +from timecapsulesmb.services.runtime import load_env_config from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.cli.util import color_red from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP, NETBSD4_REBOOT_GUIDANCE @@ -35,14 +39,25 @@ def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Manually activate an already-deployed NetBSD4 AirPort storage device payload.") add_config_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before restarting the deployed Samba services") + add_no_input_argument(parser) 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) with CommandContext(telemetry, "activate", "activate_started", "activate_finished", config=config, args=args) as command_context: command_context.update_fields(dry_run=args.dry_run, yes=args.yes, runtime_already_ready=False) + if no_input_enabled(args) and not args.yes and not args.dry_run: + command_context.set_stage("noninteractive_confirmation") + message = "Running `activate` in non-interactive mode requires `--yes` to approve activation." + print(message) + command_context.fail_with_error(message) + return 1 command_context.set_stage("resolve_managed_target") target = command_context.resolve_validated_managed_target(profile="activate", include_probe=True) connection = target.connection @@ -50,6 +65,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.", ) @@ -59,7 +75,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 @@ -71,6 +90,7 @@ def main(argv: Optional[list[str]] = None) -> int: "Continue with NetBSD4 activation?", default=False, noninteractive_message="Running `activate` requires confirmation when stdin is not interactive. Use `activate --yes` in a non-interactive environment.", + allow_prompt=not no_input_enabled(args), ) if proceed is None: return 1 @@ -79,19 +99,29 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.cancel_with_error("Cancelled by user at NetBSD4 activation confirmation prompt.") return 0 - if not activate_deployed_runtime_flow( + command_context.set_stage("probe_runtime") + decision = decide_manual_activation(connection) + command_context.add_debug_fields( + activation_decision=decision.reason, + manual_activation_required=decision.run_actions, + ) + print(decision.detail) + if not decision.run_actions: + print("NetBSD4 payload already active; skipping rc.local.") + command_context.update_fields(runtime_already_ready=True) + print(f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}") + command_context.succeed() + return 0 + + command_context.set_stage("run_activation") + print("Activating NetBSD4 payload without file transfer.") + run_remote_actions(connection, plan.actions) + if not verify_managed_runtime_flow( connection, command_context, - plan.actions, - run_actions=run_remote_actions, - skip_if_ready=True, - already_active_message="NetBSD4 payload already active; skipping rc.local.", - startup_in_progress_message="NetBSD4 payload startup is already in progress; waiting for it to finish.", - activation_message="Activating NetBSD4 payload without file transfer.", - activation_stage="run_activation", - verification_stage="verify_runtime_activation", - verification_timeout_seconds=180, - verification_heading="Waiting for NetBSD 4 device activation, this can take a few minutes for Samba to start up...", + stage="verify_runtime_activation", + timeout_seconds=200, + heading="Waiting for NetBSD 4 device activation, this can take a few minutes for Samba to start up...", failure_message="NetBSD4 activation failed.", ): return 1 diff --git a/src/timecapsulesmb/cli/api.py b/src/timecapsulesmb/cli/api.py new file mode 100644 index 00000000..097d6659 --- /dev/null +++ b/src/timecapsulesmb/cli/api.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Optional + + +def main(argv: Optional[list[str]] = None) -> int: + from timecapsulesmb.app import helper + + return helper.main(argv) diff --git a/src/timecapsulesmb/cli/bootstrap.py b/src/timecapsulesmb/cli/bootstrap.py index 27eb600a..bfc19875 100644 --- a/src/timecapsulesmb/cli/bootstrap.py +++ b/src/timecapsulesmb/cli/bootstrap.py @@ -7,8 +7,9 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import load_optional_env_config +from timecapsulesmb.cli.util import color_red from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.runtime import load_optional_env_config from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.local import find_command @@ -16,8 +17,6 @@ REPO_ROOT = Path(__file__).resolve().parents[3] VENVDIR = REPO_ROOT / ".venv" REQUIREMENTS = REPO_ROOT / "requirements.txt" -ANSI_RED = "\033[31m" -ANSI_RESET = "\033[0m" HOMEBREW_INSTALL_COMMAND = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' MACOS_SSHPASS_FORMULA = "sshpass" REQUIRED_HOST_TOOLS = ("sshpass", "smbclient") @@ -32,14 +31,64 @@ "zypper": {"sshpass": "sshpass", "smbclient": "samba-client"}, "pacman": {"sshpass": "sshpass", "smbclient": "smbclient"}, } +COMMAND_OUTPUT_ERROR_LIMIT = 8192 class BootstrapError(Exception): pass +class BootstrapCommandError(Exception): + def __init__(self, cmd: list[str], returncode: int, stdout: str, stderr: str) -> None: + self.cmd = cmd + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + super().__init__(f"Command failed with exit code {returncode}: {cmd}") + + def run(cmd: list[str], *, cwd: Optional[Path] = None) -> None: - subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=True) + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + ) + if proc.stdout: + sys.stdout.write(proc.stdout) + sys.stdout.flush() + if proc.stderr: + sys.stderr.write(proc.stderr) + sys.stderr.flush() + if proc.returncode != 0: + raise BootstrapCommandError(cmd, proc.returncode, proc.stdout or "", proc.stderr or "") + + +def _truncate_command_output(text: str, limit: int = COMMAND_OUTPUT_ERROR_LIMIT) -> str: + if len(text) <= limit: + return text.rstrip() + omitted = len(text) - limit + return f"{text[:limit].rstrip()}\n..." + + +def _format_command_output(label: str, text: str) -> str | None: + formatted = _truncate_command_output(text) + if not formatted: + return None + return f"{label}:\n{formatted}" + + +def _format_command_error(exc: BootstrapCommandError) -> str: + message = f"Command failed with exit code {exc.returncode}: {exc.cmd}" + output = _format_command_output("stderr", exc.stderr) + if output is None: + output = _format_command_output("stdout", exc.stdout) + if output is not None: + message = f"{message}\n\n{output}" + return message def current_platform_label() -> str: @@ -50,10 +99,6 @@ def current_platform_label() -> str: return sys.platform -def red(text: str) -> str: - return f"{ANSI_RED}{text}{ANSI_RESET}" - - def ensure_venv(python: str) -> Path: if not VENVDIR.exists(): print(f"Creating virtualenv at {VENVDIR}", flush=True) @@ -101,6 +146,11 @@ def _macos_manual_install_command(missing_tools: list[str]) -> str: return f"brew install {' '.join(packages)}" +def _format_macos_host_tool_packages(missing_tools: list[str]) -> str: + packages = [MACOS_HOST_TOOL_PACKAGES[tool] for tool in missing_tools] + return ", ".join(packages) + + def _linux_install_plan(missing_tools: list[str]) -> tuple[list[list[str]], str] | None: for manager, packages_by_tool in LINUX_HOST_TOOL_PACKAGES.items(): executable = find_command(manager) @@ -128,9 +178,9 @@ def _linux_install_plan(missing_tools: list[str]) -> tuple[list[list[str]], str] def _raise_host_tool_install_error(message: str, manual_command: str | None = None) -> None: - print(red(message), flush=True) + print(color_red(message), flush=True) if manual_command: - print(red("Install the missing tools manually, then rerun './tcapsule bootstrap':"), flush=True) + print(color_red("Install the missing tools manually, then rerun './tcapsule bootstrap':"), flush=True) print(manual_command, flush=True) raise BootstrapError(message) @@ -138,10 +188,21 @@ def _raise_host_tool_install_error(message: str, manual_command: str | None = No def _install_macos_host_tools(missing_tools: list[str]) -> None: brew = find_command("brew") if brew is None: - print(red(f"Homebrew is required to install missing host tools: {_format_tools(missing_tools)}"), flush=True) - print(red("Install Homebrew, then rerun './tcapsule bootstrap':"), flush=True) + print( + color_red( + "Install Homebrew so bootstrap can install missing host tools automatically, " + f"or install these macOS packages manually: {_format_macos_host_tool_packages(missing_tools)}. " + "Then rerun './tcapsule bootstrap'." + ), + flush=True, + ) + print(color_red(f"Missing host tools: {_format_tools(missing_tools)}"), flush=True) + print(color_red("Homebrew install command:"), flush=True) print(HOMEBREW_INSTALL_COMMAND, flush=True) - raise BootstrapError("Homebrew is required to install missing host tools on macOS.") + raise BootstrapError( + "Install Homebrew or manually install the missing macOS host tool packages: " + f"{_format_macos_host_tool_packages(missing_tools)}" + ) packages = [MACOS_HOST_TOOL_PACKAGES[tool] for tool in missing_tools] print(f"Installing missing host tools via Homebrew: {_format_tools(missing_tools)}", flush=True) @@ -177,11 +238,18 @@ def install_required_host_tools() -> None: f"Automatic host tool installation is not implemented for {platform_label}.", f"Install {_format_tools(missing_tools)} with your OS package manager.", ) + except BootstrapCommandError as exc: + message = f"Failed to install missing host tools automatically: {_format_tools(missing_tools)} (exit code {exc.returncode})" + print(color_red(message), flush=True) + if manual_command: + print(color_red("Install the missing tools manually, then rerun './tcapsule bootstrap':"), flush=True) + print(manual_command, flush=True) + raise BootstrapError(f"{message}\n\n{_format_command_error(exc)}") from exc except subprocess.CalledProcessError as exc: message = f"Failed to install missing host tools automatically: {_format_tools(missing_tools)} (exit code {exc.returncode})" - print(red(message), flush=True) + print(color_red(message), flush=True) if manual_command: - print(red("Install the missing tools manually, then rerun './tcapsule bootstrap':"), flush=True) + print(color_red("Install the missing tools manually, then rerun './tcapsule bootstrap':"), flush=True) print(manual_command, flush=True) raise BootstrapError(message) from exc @@ -237,6 +305,11 @@ def main(argv: Optional[list[str]] = None) -> int: install_python_requirements(venv_python) command_context.set_stage("install_host_tools") install_required_host_tools() + except BootstrapCommandError as e: + message = _format_command_error(e) + print(message, file=sys.stderr) + command_context.fail_with_error(message) + return e.returncode or 1 except subprocess.CalledProcessError as e: message = f"Command failed with exit code {e.returncode}: {e.cmd}" print(message, file=sys.stderr) diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index 3df2ba84..ae2ca387 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -2,36 +2,39 @@ import argparse import getpass +import sys import uuid from collections.abc import Callable, Sequence from typing import Optional from timecapsulesmb.configure_defaults import ( ConfigureValueChoice, - valid_existing_config_value, validated_value_or_empty, ) from timecapsulesmb.core.config import ( AppConfig, CONFIG_VALIDATORS, + ConfigError, DEFAULTS, infer_mdns_device_model_from_airport_syap, parse_env_file, - parse_bool, - write_env_file, ) from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import wait_for_tcp_port_state from timecapsulesmb.cli.runtime import ( add_config_argument, + add_no_input_argument, + add_password_source_arguments, confirm as confirm_prompt, - ssh_target_link_local_resolution_error, + no_input_enabled, + print_json, + read_password_source_args, ) from timecapsulesmb.core.errors import missing_dependency_message, missing_required_python_module -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 +from timecapsulesmb.services import configure as configure_service +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.configure import build_configure_env_values, write_configure_env_file from timecapsulesmb.device.probe import ( ProbedDeviceState, probe_connection_state, @@ -47,7 +50,7 @@ ) from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import SshConnection -from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError from timecapsulesmb.cli.util import color_cyan, color_red REQUIRED_PYTHON_MODULES = ("zeroconf", "pexpect") @@ -59,10 +62,6 @@ def non_negative_integer_arg(value: str) -> str: return str(int(value)) -def existing_config_value_or_default(existing: dict[str, str], key: str, label: str) -> str: - return valid_existing_config_value(existing, key, label) or DEFAULTS[key] - - def prompt(label: str, default: str, secret: bool) -> str: suffix = f" [{color_cyan(default)}]" if default and not secret else "" text = f"{label}{suffix}: " @@ -126,7 +125,11 @@ def discover_default_record( list_devices(records) selected = choose_device(records) if selected is None: - existing_target = valid_existing_config_value(existing, "TC_HOST", "Device SSH target") or DEFAULTS["TC_HOST"] + existing_target = validated_value_or_empty( + "TC_HOST", + existing.get("TC_HOST", ""), + "Device SSH target", + ) or DEFAULTS["TC_HOST"] print(f"Discovery skipped. Falling back to {existing_target}.\n", flush=True) return None @@ -154,17 +157,17 @@ def prompt_ssh_target_value( discovered_host: Optional[str], ssh_opts: str, ) -> str: - host_default = values.get("TC_HOST") or discovered_host or valid_existing_config_value( - existing, + host_default = values.get("TC_HOST") or discovered_host or validated_value_or_empty( "TC_HOST", + existing.get("TC_HOST", ""), "Device SSH target", ) or DEFAULTS["TC_HOST"] while True: candidate = prompt_valid_config_value("TC_HOST", "Device SSH target", host_default) - resolution_error = ssh_target_link_local_resolution_error(candidate, ssh_opts) - if resolution_error is None: - return candidate - print(resolution_error) + try: + return configure_service.configure_ssh_target(candidate, ssh_opts) + except ValueError as exc: + print(str(exc)) host_default = candidate @@ -179,6 +182,69 @@ def prompt_host_and_password( values["TC_PASSWORD"] = prompt("Device root password", password_default, True) +def _validate_config_value(key: str, label: str, value: str) -> str | None: + validator = CONFIG_VALIDATORS.get(key) + if validator is None: + return None + return validator(value, label) + + +def _scripted_ssh_target_value( + existing: dict[str, str], + *, + host_arg: str | None, + ssh_opts: str, +) -> tuple[str | None, str | None]: + candidate = host_arg or validated_value_or_empty( + "TC_HOST", + existing.get("TC_HOST", ""), + "Device SSH target", + ) + if not candidate: + return None, "configure --no-input requires --host or an existing valid TC_HOST in the config file." + validation_error = _validate_config_value("TC_HOST", "Device SSH target", candidate) + if validation_error is not None: + return None, validation_error + try: + return configure_service.configure_ssh_target(candidate, ssh_opts), None + except ValueError as exc: + return None, str(exc) + + +def _scripted_password_value(existing: dict[str, str], args: argparse.Namespace) -> tuple[str | None, str | None]: + try: + password = read_password_source_args(args) + except ConfigError as exc: + return None, str(exc) + if password is None: + password = existing.get("TC_PASSWORD", "") + if not password: + return None, ( + "configure --no-input requires a device password from --password-env, " + "--password-file, --password-stdin, or an existing TC_PASSWORD." + ) + return password, None + + +def populate_scripted_host_and_password( + existing: dict[str, str], + values: dict[str, str], + args: argparse.Namespace, + ssh_opts: str, +) -> str | None: + host, host_error = _scripted_ssh_target_value(existing, host_arg=args.host, ssh_opts=ssh_opts) + if host_error is not None: + return host_error + assert host is not None + password, password_error = _scripted_password_value(existing, args) + if password_error is not None: + return password_error + assert password is not None + values["TC_HOST"] = host + values["TC_PASSWORD"] = password + return None + + def prompt_valid_config_value(key: str, label: str, current: str, secret: bool = False) -> str: validator = CONFIG_VALIDATORS.get(key) while True: @@ -202,56 +268,25 @@ def print_automatic_value_choice(key: str, choice: ConfigureValueChoice) -> None print(f"Using {key} derived from TC_AIRPORT_SYAP: {choice.value}") -def enable_ssh_and_reprobe_for_configure( - connection: SshConnection, - command_context: CommandContext, - *, - timeout_seconds: int = 180, -) -> ProbedDeviceState | None: - host = extract_host(connection.host) - command_context.add_debug_fields( - configure_acp_enable_attempted=True, - ssh_initially_reachable=False, - ) - print("\nSSH is not reachable. Attempting to enable SSH on the device...") - command_context.set_stage("acp_enable_ssh") - try: - enable_ssh(host, connection.password, reboot_device=True, log=print) - except ACPAuthError: - command_context.add_debug_fields( - configure_acp_enable_succeeded=False, - configure_retry_reason="acp_authentication_failed", - ) - raise - except ACPError: - command_context.add_debug_fields(configure_acp_enable_succeeded=False) - raise - - command_context.add_debug_fields(configure_acp_enable_succeeded=True) - command_context.set_stage("wait_for_ssh_after_acp") - if not wait_for_tcp_port_state( - host, - 22, - expected_state=True, - timeout_seconds=timeout_seconds, - service_name="SSH port", - ): - command_context.update_fields(ssh_final_reachable=False) - return None - - command_context.update_fields(ssh_final_reachable=True) - command_context.set_stage("ssh_probe_after_acp") - return probe_connection_state(connection) - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Create or update the local TimeCapsuleSMB .env configuration.") add_config_argument(parser) + add_no_input_argument(parser) + add_password_source_arguments(parser) + parser.add_argument("--host", help="Device SSH target, for example root@192.168.1.10") + parser.add_argument("--skip-discovery", action="store_true", help="Skip Bonjour discovery and use the supplied or saved SSH target") + parser.add_argument("--yes", action="store_true", help="Approve enabling SSH via ACP when SSH is closed") + ssh_group = parser.add_mutually_exclusive_group() + ssh_group.add_argument("--enable-ssh", action="store_true", help="Enable SSH via ACP if SSH is closed") + ssh_group.add_argument("--no-enable-ssh", action="store_true", help="Fail instead of enabling SSH via ACP if SSH is closed") + parser.add_argument("--json", action="store_true", help="Output a machine-readable configure result") 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("--ata-idle-seconds", type=non_negative_integer_arg, metavar="SECONDS", help=argparse.SUPPRESS) parser.add_argument("--ata-standby", type=non_negative_integer_arg, metavar="SECONDS", help=argparse.SUPPRESS) args = parser.parse_args(argv) + if args.json and not no_input_enabled(args): + parser.error("--json requires --no-input") ensure_install_id() env_path = resolve_app_paths(config_path=args.config).config_path @@ -270,7 +305,6 @@ def main(argv: Optional[list[str]] = None) -> int: ) values: dict[str, str] = {} discovered_airport_syap: Optional[str] = None - probed_device: DeviceCompatibility | None = None with CommandContext( telemetry, "configure", @@ -281,71 +315,85 @@ def main(argv: Optional[list[str]] = None) -> int: configure_id=configure_id, ) as command_context: command_context.update_fields(configure_id=configure_id) + + def fail_configure(message: str) -> int: + if args.json: + print_json({ + "ok": False, + "configure_id": configure_id, + "path": str(env_path), + "error": message, + }) + else: + print(message) + command_context.fail_with_error(message) + return 1 + + def progress(message: str = "") -> None: + print(message, file=sys.stderr if args.json else sys.stdout, flush=True) + command_context.set_stage("dependency_check") missing_module = missing_required_python_module(REQUIRED_PYTHON_MODULES) if missing_module is not None: module_name, error = missing_module message = missing_dependency_message(module_name, error) - print(message) - command_context.set_error(message) - command_context.fail() - return 1 + return fail_configure(message) command_context.set_stage("startup") - print("This writes a local .env configuration file in this folder. The other tcapsule commands use that file.") - print(f"Writing {env_path}") - print(f"Press Enter to accept the [{color_cyan('saved/suggested/default')}] value.") - print("Most users can just keep the suggested values.\n") + if not args.json: + print("This writes a local .env configuration file in this folder. The other tcapsule commands use that file.") + print(f"Writing {env_path}") + print(f"Press Enter to accept the [{color_cyan('saved/suggested/default')}] value.") + print("Most users can just keep the suggested values.\n") ssh_opts = existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]) - values["TC_SSH_OPTS"] = ssh_opts - existing_internal_share_use_disk_root = parse_bool( - existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) - ) - values["TC_INTERNAL_SHARE_USE_DISK_ROOT"] = ( - "true" if args.internal_share_use_disk_root or existing_internal_share_use_disk_root else "false" - ) - existing_any_protocol = parse_bool( - existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) - ) - values["TC_ANY_PROTOCOL"] = ( - "true" if args.any_protocol or existing_any_protocol else "false" - ) - existing_ata_idle_seconds = existing_config_value_or_default( - existing, - "TC_ATA_IDLE_SECONDS", - "ATA idle seconds", - ) - values["TC_ATA_IDLE_SECONDS"] = ( - args.ata_idle_seconds if args.ata_idle_seconds is not None else existing_ata_idle_seconds - ) - existing_ata_standby = existing_config_value_or_default( - existing, - "TC_ATA_STANDBY", - "ATA standby timer", - ) - values["TC_ATA_STANDBY"] = args.ata_standby if args.ata_standby is not None else existing_ata_standby - command_context.set_stage("bonjour_discovery") - try: - discovered_record = discover_default_record( + values.update( + build_configure_env_values( existing, - on_diagnostics=lambda diagnostics: command_context.add_debug_fields( - bonjour_discovery=diagnostics, - ), - ) - except Exception as exc: - error_text = exception_summary(exc) - print(f"Warning: mDNS discovery failed: {error_text}") - print("This only affects automatic device discovery. Configure will continue with manual SSH target entry.") - print("Falling back to manual SSH target entry.\n") - command_context.update_fields( - bonjour_discovery_failed=True, - bonjour_discovery_fallback=True, - bonjour_discovery_fallback_reason="discovery_exception", - bonjour_discovery_error_type=type(exc).__name__, - bonjour_discovery_error=error_text, + host="", + password="", + ssh_opts=ssh_opts, + configure_id=configure_id, ) + ) + values.pop("TC_HOST", None) + values.pop("TC_PASSWORD", None) + if args.host: + values["TC_HOST"] = args.host + if not no_input_enabled(args): + try: + password_arg = read_password_source_args(args) + except ConfigError as exc: + return fail_configure(str(exc)) + if password_arg is not None: + values["TC_PASSWORD"] = password_arg + command_context.set_stage("bonjour_discovery") + if args.skip_discovery or args.host or no_input_enabled(args): discovered_record = None + command_context.add_debug_fields(bonjour_discovery_skipped=True) + if args.skip_discovery and not args.json: + print("Skipping mDNS discovery.\n") + else: + try: + discovered_record = discover_default_record( + existing, + on_diagnostics=lambda diagnostics: command_context.add_debug_fields( + bonjour_discovery=diagnostics, + ), + ) + except Exception as exc: + error_text = exception_summary(exc) + print(f"Warning: mDNS discovery failed: {error_text}") + print("This only affects automatic device discovery. Configure will continue with manual SSH target entry.") + print("Falling back to manual SSH target entry.\n") + command_context.update_fields( + bonjour_discovery_failed=True, + bonjour_discovery_fallback=True, + bonjour_discovery_fallback_reason="discovery_exception", + bonjour_discovery_error_type=type(exc).__name__, + bonjour_discovery_error=error_text, + ) + discovered_record = None command_context.add_debug_fields(selected_bonjour_record=discovered_record) discovered_host = discovered_record_root_host(discovered_record) if discovered_record else None command_context.add_debug_fields(discovered_host=discovered_host) @@ -353,103 +401,156 @@ def main(argv: Optional[list[str]] = None) -> int: discovered_airport_syap = discovered_record.properties.get("syAP") or None command_context.add_debug_fields(discovered_airport_syap=discovered_airport_syap) command_context.set_stage("prompt_host_password") - prompt_host_and_password(existing, values, discovered_host, ssh_opts) - while True: - command_context.set_stage("ssh_probe") - print("Checking login information...") - connection = SshConnection(values["TC_HOST"], values["TC_PASSWORD"], ssh_opts) + if no_input_enabled(args): + scripted_error = populate_scripted_host_and_password(existing, values, args, ssh_opts) + if scripted_error is not None: + return fail_configure(scripted_error) + else: + prompt_host_and_password(existing, values, discovered_host, ssh_opts) + + class ConfigureRetry(Exception): + pass + + def apply_probe_to_context(connection: SshConnection, probed_state: ProbedDeviceState) -> None: command_context.connection = connection - probed_state = probe_connection_state(connection) command_context.probe_state = probed_state - probe_result = probed_state.probe_result - if not probe_result.ssh_port_reachable: - try: - probed_state = enable_ssh_and_reprobe_for_configure(connection, command_context) - except ACPAuthError as exc: - print("\nThe AirPort admin password did not work.") - print(str(exc)) - print("Please enter the SSH target and password again.\n") - command_context.set_stage("prompt_host_password") - prompt_host_and_password(existing, values, discovered_host, ssh_opts) - continue - except ACPError as exc: - message = f"Failed to enable SSH via ACP: {exc}" - print(color_red("Failed to enable SSH via ACP:")) - print(str(exc)) - command_context.fail_with_error(message) - return 1 - if probed_state is None: - message = "SSH did not open after enabling via ACP. Reboot the device, wait 5 minutes, and try configure again." - print(message) - command_context.fail_with_error(message) - return 1 - command_context.probe_state = probed_state - probe_result = probed_state.probe_result - if not probe_result.ssh_port_reachable: - message = "SSH did not become reachable after enabling via ACP." - print(message) - command_context.fail_with_error(message) - return 1 - if probe_result.ssh_authenticated: - command_context.add_debug_fields(ssh_final_reachable=True) - command_context.update_fields(ssh_final_reachable=True) - probed_device = probed_state.compatibility - command_context.compatibility = probed_device - if probed_device is not None and not probed_device.supported: - command_context.add_debug_fields(configure_failure_reason="unsupported_device") - raise SystemExit(render_compatibility_message(probed_device)) - break + command_context.compatibility = probed_state.compatibility + + def probe_for_context(connection: SshConnection) -> ProbedDeviceState: + probed_state = probe_connection_state(connection) + apply_probe_to_context(connection, probed_state) + return probed_state + + def before_enable_ssh(_connection: SshConnection, _probed_state: ProbedDeviceState) -> None: + if args.no_enable_ssh: + raise configure_service.ConfigureFlowError( + "SSH is not reachable and --no-enable-ssh was provided.", + code="ssh_unreachable", + ) + if no_input_enabled(args) and not args.enable_ssh: + raise configure_service.ConfigureFlowError( + "SSH is not reachable. In non-interactive mode, use --enable-ssh --yes to enable SSH via ACP.", + code="ssh_unreachable", + ) + if no_input_enabled(args) and not args.yes: + raise configure_service.ConfigureFlowError( + "configure --enable-ssh in non-interactive mode requires --yes.", + code="ssh_unreachable", + ) + + def save_without_authentication(probed_state: ProbedDeviceState) -> bool: + if no_input_enabled(args): + return False print("\nThe provided AirPort SSH target and password did not work.") - if probe_result.ssh_port_reachable: + if probed_state.probe_result.ssh_port_reachable: command_context.update_fields(ssh_final_reachable=True) if confirm("Save this information still?", True): command_context.add_debug_fields(configure_saved_without_ssh_authentication=True) - break + return True print("Please enter the SSH target and password again.\n") command_context.add_debug_fields(configure_retry_reason="ssh_authentication_failed") command_context.set_stage("prompt_host_password") prompt_host_and_password(existing, values, discovered_host, ssh_opts) - continue + raise ConfigureRetry() - observed_syap_source = "probed" - observed_syap = None if probed_device is None else probed_device.exact_syap - if observed_syap is None: - observed_syap = validated_value_or_empty( - "TC_AIRPORT_SYAP", - discovered_airport_syap or "", - "Airport Utility syAP code", - ) or None - observed_syap_source = "discovered" - observed_model_source = "probed" - observed_model = None if probed_device is None else probed_device.exact_model - if observed_model is None and observed_syap is not None: - observed_model = infer_mdns_device_model_from_airport_syap(observed_syap) - observed_model_source = "derived" - if observed_syap is not None: - values["TC_AIRPORT_SYAP"] = observed_syap - print_automatic_value_choice( - "TC_AIRPORT_SYAP", - ConfigureValueChoice(value=observed_syap, source=observed_syap_source), - ) - if observed_model is not None: - values["TC_MDNS_DEVICE_MODEL"] = observed_model - print_automatic_value_choice( - "TC_MDNS_DEVICE_MODEL", - ConfigureValueChoice(value=observed_model, source=observed_model_source), - ) - - command_context.set_stage("write_env") - values["TC_CONFIGURE_ID"] = configure_id - write_env_file(env_path, values) - command_context.update_fields( - configure_id=configure_id, - device_syap=observed_syap, - device_model=observed_model, - ) - print(f"\nReview the .env file configuration: wrote {env_path}") - print("Next steps:") - print("- Deploy this configuration to your Time Capsule/Airport Extreme device, run:") - print(" .venv/bin/tcapsule deploy") + while True: + if not args.json: + print("Checking login information...") + try: + result = configure_service.run_configure_flow( + configure_service.ConfigureFlowRequest( + existing=existing, + env_path=env_path, + host=values["TC_HOST"], + password=values["TC_PASSWORD"], + ssh_opts=ssh_opts, + configure_id=configure_id, + persist_password=True, + discovered_airport_syap=discovered_airport_syap, + enable_ssh=True, + verbose_wait=not args.json, + internal_share_use_disk_root=True if args.internal_share_use_disk_root else None, + any_protocol=True if args.any_protocol else None, + ata_idle_seconds=args.ata_idle_seconds, + ata_standby=args.ata_standby, + probe=probe_for_context, + write_env=lambda path, output: write_configure_env_file(path, output, persist_password=True), + infer_model_from_syap=infer_mdns_device_model_from_airport_syap, + ), + callbacks=OperationCallbacks( + set_stage=command_context.set_stage, + add_debug_fields=command_context.add_debug_fields, + update_fields=command_context.update_fields, + log=progress, + ), + hooks=configure_service.ConfigureFlowHooks( + after_probe=apply_probe_to_context, + before_enable_ssh=before_enable_ssh, + save_without_authentication=save_without_authentication, + ), + ) + except ConfigureRetry: + continue + except ACPAuthError as exc: + if no_input_enabled(args): + message = f"Failed to enable SSH via ACP: {exc}" + return fail_configure(message) + print("\nThe AirPort admin password did not work.") + print(str(exc)) + print("Please enter the SSH target and password again.\n") + command_context.set_stage("prompt_host_password") + prompt_host_and_password(existing, values, discovered_host, ssh_opts) + continue + except ACPError as exc: + if command_context.debug_stage == "acp_identity_probe": + label = "Failed to read AirPort identity via ACP" + else: + label = "Failed to enable SSH via ACP" + message = f"{label}: {exc}" + if not args.json: + print(color_red(f"{label}:")) + print(str(exc)) + return fail_configure(message) + except configure_service.ConfigureFlowError as exc: + if exc.code == "auth_failed": + return fail_configure("The provided AirPort SSH target and password did not work.") + if exc.code == "unsupported_device": + raise SystemExit(str(exc)) + if exc.code == "ssh_enable_timeout": + return fail_configure( + "SSH did not open after enabling via ACP. Reboot the device, wait 5 minutes, and try configure again." + ) + return fail_configure(str(exc)) + values.clear() + values.update(result.values) + command_context.connection = result.connection + command_context.probe_state = result.probe_state + command_context.compatibility = result.compatibility + if result.identity.syap is not None and not args.json: + print_automatic_value_choice( + "TC_AIRPORT_SYAP", + ConfigureValueChoice(value=result.identity.syap, source=result.identity.syap_source or "probed"), + ) + if result.identity.model is not None and not args.json: + print_automatic_value_choice( + "TC_MDNS_DEVICE_MODEL", + ConfigureValueChoice(value=result.identity.model, source=result.identity.model_source or "probed"), + ) + break + if args.json: + print_json({ + "ok": True, + "configure_id": configure_id, + "path": str(env_path), + "host": values.get("TC_HOST"), + "device_syap": result.identity.syap, + "device_model": result.identity.model, + }) + else: + print(f"\nReview the .env file configuration: wrote {env_path}") + print("Next steps:") + print("- Deploy this configuration to your Time Capsule/Airport Extreme device, run:") + print(" .venv/bin/tcapsule deploy") command_context.succeed() return 0 return 1 diff --git a/src/timecapsulesmb/cli/context.py b/src/timecapsulesmb/cli/context.py index ef5ee994..df169cd1 100644 --- a/src/timecapsulesmb/cli/context.py +++ b/src/timecapsulesmb/cli/context.py @@ -1,124 +1,44 @@ from __future__ import annotations -import time import threading import uuid from collections.abc import Mapping from typing import TYPE_CHECKING -from timecapsulesmb.cli import runtime +from timecapsulesmb.cli import runtime as cli_runtime from timecapsulesmb.core.config import ConfigError, airport_exact_display_name_from_identity from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.device.compat import require_compatibility as require_device_compatibility from timecapsulesmb.device.errors import DeviceError from timecapsulesmb.device.probe import probe_connection_state, probe_remote_airport_identity_conn -from timecapsulesmb.device.storage import ( - mast_volumes_debug_summary, - mounted_mast_volumes_conn, - payload_candidate_checks_debug_summary, - read_mast_volumes_conn, - select_payload_home_with_diagnostics_conn, - wait_for_mast_volumes_conn, +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.context import ( + COMMAND_FIELD_BLACKLIST, + COMMAND_VALUE_BLACKLIST, + OperationContext, ) +from timecapsulesmb.services import runtime as service_runtime from timecapsulesmb.telemetry import build_device_os_version -from timecapsulesmb.telemetry.debug import debug_summary, render_debug_mapping +from timecapsulesmb.telemetry.operation import ( + OperationTelemetrySession, + client_from_environment, + telemetry_details_from_payload, + telemetry_options_from_args, +) from timecapsulesmb.transport.errors import TransportError if TYPE_CHECKING: - from timecapsulesmb.cli.runtime import ManagedTargetState from timecapsulesmb.core.config import AppConfig from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbedDeviceState, RemoteInterfaceProbeResult - from timecapsulesmb.device.storage import MaStDiscoveryResult, MaStVolume, PayloadHomeSelection + from timecapsulesmb.services.runtime import ManagedTargetState from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import SshConnection -COMMAND_VALUE_BLACKLIST = { - "TC_PASSWORD", - # Removed naming keys may still exist in old .env files. They are - # intentionally ignored and should not appear as command inputs. - "TC_SAMBA_USER", - "TC_PAYLOAD_DIR_NAME", - "TC_MDNS_HOST_LABEL", - "TC_MDNS_INSTANCE_NAME", - "TC_NETBIOS_NAME", - # These are already first-class telemetry fields. - "TC_CONFIGURE_ID", - "TC_MDNS_DEVICE_MODEL", - "TC_AIRPORT_SYAP", -} -COMMAND_FIELD_BLACKLIST = { - # These are already first-class telemetry fields. - "configure_id", - "device_model", - "device_syap", - "device_os_version", - "device_family", - "nbns_enabled", - "reboot_was_attempted", - "device_came_back_after_reboot", -} -MAST_ACP_OUTPUT_DEBUG_LIMIT = 8192 OPTIONAL_IDENTITY_PROBE_FINISH_TIMEOUT_SECONDS = 0.1 -def _mast_acp_output_debug_text(raw_output: str) -> str: - if not raw_output: - return "" - if len(raw_output) <= MAST_ACP_OUTPUT_DEBUG_LIMIT: - return raw_output - omitted = len(raw_output) - MAST_ACP_OUTPUT_DEBUG_LIMIT - return f"{raw_output[:MAST_ACP_OUTPUT_DEBUG_LIMIT]}..." - - -def _render_connection_debug_lines(connection: SshConnection | None, values: Mapping[str, str] | None) -> list[str]: - host = None - ssh_opts = None - if connection is not None: - host = connection.host - ssh_opts = connection.ssh_opts - elif values is not None: - host = values.get("TC_HOST") or None - ssh_opts = values.get("TC_SSH_OPTS") or None - lines: list[str] = [] - if host: - lines.append(f"host={host}") - if ssh_opts: - lines.append(f"ssh_opts={ssh_opts}") - return lines - - -def render_command_debug_lines( - *, - command_name: str, - stage: str | None, - connection: SshConnection | None, - values: Mapping[str, str] | None, - preflight_error: str | None, - finish_fields: Mapping[str, object], - probe_state: ProbedDeviceState | None, - debug_fields: Mapping[str, object], - config: AppConfig | None = None, -) -> list[str]: - debug_values = config.values if config is not None else values - lines = ["Debug context:", f"command={command_name}"] - if stage: - lines.append(f"stage={stage}") - if config is not None: - lines.append(f"env_path={config.path}") - lines.extend(_render_connection_debug_lines(connection, debug_values)) - if debug_values is not None: - lines.extend(render_debug_mapping(debug_values, blacklist=COMMAND_VALUE_BLACKLIST)) - if preflight_error: - lines.append(f"preflight_error={preflight_error}") - lines.extend(render_debug_mapping(finish_fields, blacklist=COMMAND_FIELD_BLACKLIST)) - if probe_state is not None: - lines.extend(render_debug_mapping(debug_summary(probe_state), blacklist=COMMAND_FIELD_BLACKLIST)) - lines.extend(render_debug_mapping(debug_fields, blacklist=COMMAND_FIELD_BLACKLIST)) - return lines - - class CommandContext: def __init__( self, @@ -134,30 +54,79 @@ def __init__( ) -> None: self.telemetry = telemetry self.command_name = command_name - self.values = values - self.config = config + self.operation_context = OperationContext(command_name, values=values, config=config) self.args = args self.finished_event = finished_event - self.start_time = time.monotonic() self.finished = False self.command_id = str(uuid.uuid4()) self.result = "failure" - self.finish_fields: dict[str, object] = {} - self.error_lines: list[str] = [] - self.preflight_error: str | None = None - self.debug_stage: str | None = None - self.debug_fields: dict[str, object] = {} - self.connection: SshConnection | None = None self.interface_probe: RemoteInterfaceProbeResult | None = None - self.probe_state: ProbedDeviceState | None = None self.compatibility: DeviceCompatibility | None = None self._optional_airport_identity_thread: threading.Thread | None = None self._optional_airport_identity: tuple[str | None, str | None] | None = None - self._emit_telemetry(started_event, command_id=self.command_id, **fields) + self.telemetry_session = OperationTelemetrySession( + telemetry, + command_name, + entrypoint="cli", + client=client_from_environment(entrypoint="cli"), + started_event=started_event, + finished_event=finished_event, + operation_id=self.command_id, + options=telemetry_options_from_args(args), + ) + self.telemetry_session.start(**fields) def __enter__(self) -> "CommandContext": return self + @property + def values(self) -> Mapping[str, str] | None: + return self.operation_context.values + + @property + def config(self) -> AppConfig | None: + return self.operation_context.config + + @property + def finish_fields(self) -> dict[str, object]: + return self.operation_context.finish_fields + + @property + def error_lines(self) -> list[str]: + return self.operation_context.error_lines + + @property + def preflight_error(self) -> str | None: + return self.operation_context.preflight_error + + @preflight_error.setter + def preflight_error(self, value: str | None) -> None: + self.operation_context.preflight_error = value + + @property + def debug_stage(self) -> str | None: + return self.operation_context.debug_stage + + @property + def debug_fields(self) -> dict[str, object]: + return self.operation_context.debug_fields + + @property + def connection(self) -> SshConnection | None: + return self.operation_context.connection + + @connection.setter + def connection(self, value: SshConnection | None) -> None: + self.operation_context.connection = value + + @property + def probe_state(self) -> ProbedDeviceState | None: + return self.operation_context.probe_state + + @probe_state.setter + def probe_state(self, value: ProbedDeviceState | None) -> None: + self.operation_context.probe_state = value + def __exit__(self, exc_type: object, exc: object, _tb: object) -> bool: if exc_type is KeyboardInterrupt and self.result != "cancelled": self.result = "cancelled" @@ -199,10 +168,16 @@ def fail_with_error(self, message: str) -> None: self.result = "failure" self.set_error(message) + def to_operation_callbacks(self) -> OperationCallbacks: + return OperationCallbacks( + set_stage=self.set_stage, + log=print, + add_debug_fields=self.add_debug_fields, + update_fields=self.update_fields, + ) + def update_fields(self, **fields: object) -> None: - for key, value in fields.items(): - if value is not None: - self.finish_fields[key] = value + self.operation_context.update_fields(**fields) def _update_device_identity_fields(self, *, model: str | None, syap: str | None) -> None: self.update_fields(device_model=model, device_syap=syap) @@ -257,40 +232,16 @@ def optional_airport_display_name(self, *, timeout_seconds: float = 0.0) -> str: ) def set_stage(self, stage: str) -> None: - self.debug_stage = stage + self.operation_context.set_stage(stage) def add_debug_fields(self, **fields: object) -> None: - for key, value in fields.items(): - if value is not None: - self.debug_fields[key] = debug_summary(value) + self.operation_context.add_debug_fields(**fields) def set_error(self, message: str) -> None: - self.error_lines = [line.rstrip() for line in message.splitlines() if line.strip()] + self.operation_context.set_error(message) def build_error(self) -> str | None: - if not self.error_lines: - return None - return "\n".join([ - *self.error_lines, - "", - *render_command_debug_lines( - command_name=self.command_name, - stage=self.debug_stage, - connection=self.connection, - values=self.values, - preflight_error=self.preflight_error, - finish_fields=self.finish_fields, - probe_state=self.probe_state, - debug_fields=self.debug_fields, - config=self.config, - ), - ]) - - def _emit_telemetry(self, event: str, **fields: object) -> None: - try: - self.telemetry.emit(event, **fields) - except Exception: - pass + return self.operation_context.build_error() def confirm_or_fail( self, @@ -300,112 +251,26 @@ def confirm_or_fail( noninteractive_message: str, eof_default: bool | None = None, interrupt_default: bool | None = None, + allow_prompt: bool = True, ) -> bool | None: + if not allow_prompt: + print(noninteractive_message) + self.fail_with_error(noninteractive_message) + return None try: - return runtime.confirm( + return cli_runtime.confirm( prompt_text, default=default, eof_default=eof_default, interrupt_default=interrupt_default, noninteractive_message=noninteractive_message, ) - except runtime.NonInteractivePromptError as exc: + except cli_runtime.NonInteractivePromptError as exc: message = str(exc) print(message) self.fail_with_error(message) return None - def _storage_connection(self, connection: SshConnection | None) -> SshConnection: - if connection is not None: - return connection - if self.connection is None: - raise RuntimeError("CommandContext connection is not set.") - return self.connection - - def read_mast_volumes( - self, - connection: SshConnection | None = None, - *, - stage: str = "read_mast", - ) -> tuple[MaStVolume, ...]: - connection = self._storage_connection(connection) - self.set_stage(stage) - volumes = read_mast_volumes_conn(connection) - self.add_debug_fields( - mast_volume_count=len(volumes), - mast_candidates=mast_volumes_debug_summary(volumes), - ) - return volumes - - def mount_mast_volumes( - self, - connection: SshConnection | None = None, - *, - wait_seconds: int, - read_stage: str = "read_mast", - mount_stage: str = "mount_mast_volumes", - ) -> tuple[MaStVolume, ...]: - connection = self._storage_connection(connection) - mast_volumes = self.read_mast_volumes(connection, stage=read_stage) - self.set_stage(mount_stage) - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=wait_seconds, - ) - self.add_debug_fields( - mast_mounted_volume_count=len(mounted_volumes), - mast_mounted_candidates=mast_volumes_debug_summary(mounted_volumes), - ) - return mounted_volumes - - def wait_for_mast_volumes( - self, - connection: SshConnection | None = None, - *, - attempts: int, - delay_seconds: int, - stage: str = "read_mast", - ) -> MaStDiscoveryResult: - connection = self._storage_connection(connection) - self.set_stage(stage) - mast_discovery = wait_for_mast_volumes_conn( - connection, - attempts=attempts, - delay_seconds=delay_seconds, - ) - mast_volumes = mast_discovery.volumes - fields: dict[str, object] = { - "mast_read_attempts": mast_discovery.attempts, - "mast_volume_count": len(mast_volumes), - "mast_candidates": mast_volumes_debug_summary(mast_volumes), - } - if not mast_volumes: - fields["mast_acp_output_chars"] = len(mast_discovery.raw_output) - fields["mast_acp_output"] = _mast_acp_output_debug_text(mast_discovery.raw_output) - self.add_debug_fields(**fields) - return mast_discovery - - def select_payload_home( - self, - connection: SshConnection | None, - mast_volumes: tuple[MaStVolume, ...], - payload_dir_name: str, - *, - wait_seconds: int, - stage: str = "select_payload_home", - ) -> PayloadHomeSelection: - connection = self._storage_connection(connection) - self.set_stage(stage) - selection = select_payload_home_with_diagnostics_conn( - connection, - mast_volumes, - payload_dir_name, - wait_seconds=wait_seconds, - ) - self.add_debug_fields(mast_candidate_checks=payload_candidate_checks_debug_summary(selection.checks)) - return selection - def resolve_env_connection( self, *, @@ -414,10 +279,12 @@ def resolve_env_connection( ) -> SshConnection: if self.config is None: raise RuntimeError("CommandContext config is not set.") - self.connection = runtime.resolve_env_connection( + self.connection = service_runtime.resolve_env_connection( self.config, required_keys=required_keys, allow_empty_password=allow_empty_password, + allow_password_prompt=not cli_runtime.no_input_enabled(self.args), + password_provider=cli_runtime.prompt_device_password, ) return self.connection @@ -451,17 +318,19 @@ def _apply_managed_target_state(self, target: ManagedTargetState) -> ManagedTarg def inspect_managed_connection(self, *, iface: str, include_probe: bool = False) -> ManagedTargetState: connection = self.connection if self.connection is not None else self.resolve_env_connection() - target = runtime.inspect_managed_connection(connection, iface, include_probe=include_probe) + target = service_runtime.inspect_managed_connection(connection, iface, include_probe=include_probe) return self._apply_managed_target_state(target) def resolve_validated_managed_target(self, *, profile: str, include_probe: bool = False) -> ManagedTargetState: if self.config is None: raise RuntimeError("CommandContext config is not set.") - target = runtime.resolve_validated_managed_target( + target = service_runtime.resolve_validated_managed_target( self.config, command_name=self.command_name, profile=profile, include_probe=include_probe, + allow_password_prompt=not cli_runtime.no_input_enabled(self.args), + password_provider=cli_runtime.prompt_device_password, ) return self._apply_managed_target_state(target) @@ -490,19 +359,26 @@ def finish(self, *, result: str, **fields: object) -> None: self.harvest_optional_airport_identity_probe(timeout_seconds=OPTIONAL_IDENTITY_PROBE_FINISH_TIMEOUT_SECONDS) emit_fields = dict(self.finish_fields) emit_fields.update(fields) - duration_sec = round(time.monotonic() - self.start_time, 3) try: error = None if result == "success" else self.build_error() except Exception as exc: error = f"{self.command_name} failed, and debug context rendering also failed: {type(exc).__name__}: {exc}" if result != "success" and error is None: error = f"{self.command_name} failed without additional details." - self._emit_telemetry( - self.finished_event, - synchronous=True, - command_id=self.command_id, + if self.args is None: + params: Mapping[str, object] = {} + elif isinstance(self.args, Mapping): + params = self.args + else: + try: + params = vars(self.args) + except TypeError: + params = {} + details = telemetry_details_from_payload(self.command_name, params, emit_fields) + self.telemetry_session.finish( result=result, - duration_sec=duration_sec, error=error, + stage=self.debug_stage, + details=details, **emit_fields, ) diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index cbd0a002..5e139c66 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -1,158 +1,42 @@ from __future__ import annotations import argparse -from contextlib import ExitStack -import tempfile -from pathlib import Path from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import activate_deployed_runtime_flow, request_deploy_reboot_and_wait, verify_managed_runtime_flow from timecapsulesmb.cli.runtime import ( add_config_argument, - load_env_config, + add_no_input_argument, + no_input_enabled, 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 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 -from timecapsulesmb.deploy.auth import render_smbpasswd -from timecapsulesmb.deploy.commands import RemoteAction, StopProcessAction -from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, format_deployment_plan -from timecapsulesmb.deploy.executor import flush_remote_filesystem_writes, 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, - DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, - DEPLOY_STARTUP_ACTIVATE_NOW, - DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, - DEPLOY_STARTUP_REBOOT_THEN_VERIFY, - DeploymentStartupMode, - FileTransfer, - GENERATED_FLASH_CONFIG_SOURCE, - GENERATED_SMBPASSWD_SOURCE, - GENERATED_USERNAME_MAP_SOURCE, - PACKAGED_BOOT_SOURCE, - PACKAGED_COMMON_SH_SOURCE, - PACKAGED_DFREE_SH_SOURCE, - PACKAGED_MANAGER_SOURCE, - PACKAGED_RC_LOCAL_SOURCE, - build_deployment_plan, -) -from timecapsulesmb.deploy.boot_assets import ( - boot_asset_path, -) -from timecapsulesmb.device.compat import is_netbsd4_payload_family, payload_family_description -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.device.errors import DeviceError from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.cli.util import color_green - - -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." +from timecapsulesmb.services.deploy import ( + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, + DeployArtifactValidationError, + DeployCompletionMessages, + DeployOptions, + DeployRuntimeConfig, + complete_deployment_after_upload, + deployment_plan_to_jsonable, + format_deployment_plan, + payload_family_description, + pre_upload_action_message, + prepare_deploy_preflight, + prepare_deployment_plan, + uploaded_file_message, + upload_and_verify_deployment_payload, ) - - -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 _runtime_unsigned_config_value(config: AppConfig, key: str, default: str) -> str: - raw_value = config.get(key, default).strip() - if raw_value == "": - raw_value = default - if raw_value == "": - return "" - if not raw_value.isdigit(): - raise ValueError(f"{key} must be a non-negative integer") - return str(int(raw_value)) - - -def _runtime_unsigned_override_value(value: str | int) -> str | int: - if isinstance(value, int): - if value < 0: - raise ValueError("runtime setting override must be a non-negative integer") - return value - raw_value = value.strip() - if raw_value == "": - return "" - if not raw_value.isdigit(): - raise ValueError("runtime setting override must be a non-negative integer") - return str(int(raw_value)) - - -def render_flash_runtime_config( - config: AppConfig, - payload_home: PayloadHome, - *, - nbns_enabled: bool, - debug_logging: bool, - ata_idle_seconds: str | int | None = None, - ata_standby: str | int | None = None, - 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"]) - runtime_ata_idle_seconds = ( - _runtime_unsigned_config_value(config, "TC_ATA_IDLE_SECONDS", DEFAULTS["TC_ATA_IDLE_SECONDS"]) - if ata_idle_seconds is None - else _runtime_unsigned_override_value(ata_idle_seconds) - ) - runtime_ata_standby = ( - _runtime_unsigned_config_value(config, "TC_ATA_STANDBY", DEFAULTS["TC_ATA_STANDBY"]) - if ata_standby is None - else _runtime_unsigned_override_value(ata_standby) - ) - - 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", runtime_ata_idle_seconds), - ("ATA_STANDBY", runtime_ata_standby), - ("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" +from timecapsulesmb.services.reboot import RebootFlowError +from timecapsulesmb.services.runtime import load_env_config def _target_family_display_name(target) -> str: @@ -163,24 +47,6 @@ 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 _startup_mode_for_deploy(*, no_reboot: bool, is_netbsd4: bool) -> DeploymentStartupMode: - if no_reboot: - return DEPLOY_STARTUP_ACTIVATE_NOW - if is_netbsd4: - return DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE - return DEPLOY_STARTUP_REBOOT_THEN_VERIFY - - -def _activation_complete_message(*, is_netbsd4: bool) -> str: - if is_netbsd4: - return f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}" - return "Runtime activation complete." - - def _non_negative_int(value: str) -> int: try: parsed = int(value) @@ -195,7 +61,13 @@ 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) parser.add_argument("--no-reboot", action="store_true", help="Do not reboot; activate the deployed runtime in place") + parser.add_argument( + "--no-wait", + action="store_true", + help="Request reboot and return without waiting for SSH or runtime verification", + ) parser.add_argument("--yes", action="store_true", help="Do not prompt before reboot") + add_no_input_argument(parser) 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") @@ -214,6 +86,14 @@ def main(argv: Optional[list[str]] = None) -> int: parser.error("--json currently requires --dry-run") nbns_enabled = not args.no_nbns + deploy_options = DeployOptions( + dry_run=args.dry_run, + no_reboot=args.no_reboot, + no_wait=args.no_wait, + mount_wait_seconds=args.mount_wait, + allow_unsupported=args.allow_unsupported, + ) + no_wait = deploy_options.effective_no_wait ensure_install_id() app_paths = resolve_app_paths(config_path=args.config) config = load_env_config(env_path=args.config) @@ -224,90 +104,62 @@ def main(argv: Optional[list[str]] = None) -> int: reboot_was_attempted=False, device_came_back_after_reboot=False, ) + if no_input_enabled(args) and not args.yes and not args.no_reboot and not args.dry_run: + command_context.set_stage("noninteractive_confirmation") + message = ( + "Running `deploy` with reboot in non-interactive mode requires `--yes` " + "to approve the reboot or `--no-reboot` to avoid it." + ) + print(message) + command_context.fail_with_error(message) + return 1 command_context.set_stage("resolve_managed_target") if not args.json: print("Resolving deployment target...", flush=True) target = command_context.resolve_validated_managed_target(profile="deploy", include_probe=True) connection = target.connection - host = connection.host - smb_password = connection.password - command_context.set_stage("validate_artifacts") if not args.json: print("Validating local artifacts...", flush=True) - artifact_results = validate_artifacts(app_paths.distribution_root) - failures = [message for _, ok, message in artifact_results if not ok] - if failures: - raise SystemExit("; ".join(failures)) - command_context.set_stage("check_compatibility") if not args.json: print("Checking device compatibility...", flush=True) - compatibility, compatibility_message = require_supported_device_compatibility( - command_context, - allow_unsupported=args.allow_unsupported, - json_output=args.json, - ) - if not compatibility.payload_family: - raise SystemExit(f"{compatibility_message}\nNo deployable payload is available for this detected device.") - payload_family = compatibility.payload_family - is_netbsd4 = is_netbsd4_payload_family(payload_family) - if is_netbsd4: - # Apple NetBSD 4 firmware can expose /usr/bin/scp but hang after - # writing the file. Use the SSH pipe upload fallback consistently. - connection.remote_has_scp = False - startup_mode = _startup_mode_for_deploy(no_reboot=args.no_reboot, is_netbsd4=is_netbsd4) - command_context.update_fields(deploy_startup_mode=startup_mode) - if not args.json: - print(f"Using {payload_family_description(payload_family)} payload.", flush=True) - apple_mount_wait_seconds = args.mount_wait - resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) - smbd_path = resolved_artifacts["smbd"].absolute_path - mdns_path = resolved_artifacts["mdns-advertiser"].absolute_path - nbns_path = resolved_artifacts["nbns-advertiser"].absolute_path - if args.dry_run: - payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) - else: - if not args.json: - print("Finding payload volume...", flush=True) - mast_discovery = command_context.wait_for_mast_volumes( + try: + preflight = prepare_deploy_preflight( connection, - attempts=MAST_DISCOVERY_ATTEMPTS, - delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + target, + app_paths.distribution_root, + deploy_options, + callbacks=command_context.to_operation_callbacks(), ) - mast_volumes = mast_discovery.volumes - if not mast_volumes: - raise SystemExit( - _no_mast_volumes_message( - attempts=MAST_DISCOVERY_ATTEMPTS, - delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, - ) - ) - selection = command_context.select_payload_home( + except DeployArtifactValidationError as exc: + raise SystemExit(str(exc)) from exc + except DeviceError as exc: + raise SystemExit(str(exc)) from exc + payload_context = preflight.payload_context + payload_family = preflight.payload_family + startup_mode = preflight.startup_mode + if not args.json: + print(f"Using {payload_family_description(payload_family)} payload.", flush=True) + if not args.dry_run and not args.json: + print("Finding payload volume...", flush=True) + try: + prepared_plan = prepare_deployment_plan( connection, - mast_volumes, - MANAGED_PAYLOAD_DIR_NAME, - wait_seconds=apple_mount_wait_seconds, + app_paths.distribution_root, + payload_context, + dry_run=args.dry_run, + payload_dir_name=deploy_options.payload_dir_name, + mount_wait_seconds=deploy_options.mount_wait_seconds, + callbacks=command_context.to_operation_callbacks(), + artifacts=preflight.artifacts, + wait_after_reboot=not no_wait, ) - if selection.payload_home is None: - raise SystemExit(_no_writable_mast_volumes_message(len(mast_volumes))) - payload_home = selection.payload_home - if not args.json: - print(f"Using payload directory {payload_home.payload_dir}.", flush=True) - command_context.set_stage("build_deployment_plan") - plan = build_deployment_plan( - host, - payload_home, - smbd_path, - mdns_path, - nbns_path, - startup_mode=startup_mode, - apple_mount_wait_seconds=apple_mount_wait_seconds, - ) - command_context.add_debug_fields( - payload_volume_root=plan.volume_root, - payload_device_path=plan.device_path, - payload_dir=plan.payload_dir, - ) + except DeviceError as exc: + raise SystemExit(str(exc)) from exc + payload_home = prepared_plan.payload_home + plan = prepared_plan.plan + if not args.dry_run and not args.json: + print(f"Using payload directory {payload_home.payload_dir}.", flush=True) if args.dry_run: if args.json: @@ -320,139 +172,60 @@ def main(argv: Optional[list[str]] = None) -> int: print("Deleting old deployed files...", flush=True) print("Stopping existing runtime...", flush=True) - def report_pre_upload_action(action: RemoteAction, _index: int, _total: int) -> None: - if isinstance(action, StopProcessAction) and action.name == "nbns-advertiser": - print("Cleaning up previous deployment files...", flush=True) - - command_context.set_stage("pre_upload_actions") - run_remote_actions(connection, plan.pre_upload_actions, on_action_done=report_pre_upload_action) - command_context.set_stage("prepare_deployment_files") - flash_config_text = render_flash_runtime_config( - config, - payload_home, - nbns_enabled=nbns_enabled, - debug_logging=args.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(smb_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_BOOT_SOURCE: boot_assets.enter_context(boot_asset_path("boot.sh")), - PACKAGED_MANAGER_SOURCE: boot_assets.enter_context(boot_asset_path("manager.sh")), - PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), - } + def report_pre_upload_action(action, _index: int, _total: int) -> None: + message = pre_upload_action_message(action) + if message is not None: + print(message, flush=True) - def report_uploaded_file(transfer: FileTransfer) -> None: - message = None - if transfer.source_id == BINARY_SMBD_SOURCE: - message = "Uploaded smbd." - elif transfer.source_id == BINARY_MDNS_SOURCE and transfer.mode == "flash_atomic": - message = "Uploaded mdns-advertiser." - elif transfer.source_id == BINARY_NBNS_SOURCE: - message = "Uploaded nbns-advertiser." - elif transfer.source_id == PACKAGED_DFREE_SH_SOURCE: - message = "Uploaded boot files." - elif transfer.source_id == GENERATED_FLASH_CONFIG_SOURCE: - message = "Uploaded runtime config." - elif transfer.source_id == GENERATED_USERNAME_MAP_SOURCE: - message = "Uploaded Samba account files." - if message is not None: - print(message, flush=True) + def report_uploaded_file(transfer) -> None: + message = uploaded_file_message(transfer) + if message is not None: + print(message, flush=True) - command_context.set_stage("upload_payload") - print("Uploading deployment payload...", flush=True) - upload_deployment_payload( - plan, + try: + upload_and_verify_deployment_payload( + config, connection=connection, - source_resolver=upload_sources, + prepared_plan=prepared_plan, + runtime_config=DeployRuntimeConfig( + nbns_enabled=nbns_enabled, + debug_logging=args.debug_logging, + ), + callbacks=command_context.to_operation_callbacks(), + on_pre_upload_action_done=report_pre_upload_action, + on_before_upload=lambda: print("Uploading deployment payload...", flush=True), + on_after_upload=lambda: print("Upload phase complete.", flush=True), on_uploaded=report_uploaded_file, + on_before_post_upload_actions=lambda: print("Applying file permissions...", flush=True), + on_before_verify=lambda post_sync: None if post_sync else print("Verifying uploaded payload...", flush=True), + on_before_flush=lambda: print("Flushing payload to disk...", flush=True), ) - print("Upload phase complete.", flush=True) - - command_context.set_stage("post_upload_actions") - print("Applying file permissions...", flush=True) - run_remote_actions(connection, plan.post_upload_actions) - - command_context.set_stage("verify_payload_upload") - print("Verifying uploaded payload...", flush=True) - payload_verification = verify_payload_home_conn( - connection, - payload_home, - wait_seconds=apple_mount_wait_seconds, - ) - 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)) - - command_context.set_stage("flush_payload_upload") - if not args.json: - print("Flushing payload to disk...", flush=True) - flush_remote_filesystem_writes(connection) - - # The immediate verification above can succeed from cache. Flush and - # verify again before any reboot so dirty HFS metadata cannot disappear - # under an ACP-triggered restart. - command_context.set_stage("verify_payload_upload_after_sync") - payload_verification = verify_payload_home_conn( - connection, - payload_home, - wait_seconds=apple_mount_wait_seconds, - ) - 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)) + except DeviceError as exc: + raise SystemExit(str(exc)) from exc print("Verified uploaded payload.", flush=True) print(f"Deployed Samba payload to {plan.payload_dir}", flush=True) print("Updated /mnt/Flash boot files.", flush=True) - if startup_mode == DEPLOY_STARTUP_ACTIVATE_NOW: - if not activate_deployed_runtime_flow( - connection, - command_context, - plan.activation_actions, - run_actions=run_remote_actions, - skip_if_ready=False, - already_active_message="Managed runtime already active; skipping rc.local.", - startup_in_progress_message="Managed runtime startup is already in progress; waiting for it to finish.", - activation_message="Starting deployed runtime without reboot.", - activation_stage="activate_runtime", - verification_stage="verify_runtime_activation", - verification_timeout_seconds=180, - verification_heading="Waiting for managed runtime to finish starting...", - failure_message="Managed runtime activation failed.", - ): - return 1 - print(_activation_complete_message(is_netbsd4=is_netbsd4)) - print(color_green("Deploy Finished.")) - command_context.succeed() - return 0 - - if not args.yes: + if plan.reboot_required and not args.yes: device_name = _target_family_display_name(target) if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: - prompt = f"This will reboot the {device_name}, then activate Samba after SSH returns. Continue?" + if no_wait: + prompt = ( + f"This will request a reboot of the {device_name} and return without " + "post-reboot Samba activation or verification. Continue?" + ) + else: + prompt = f"This will reboot the {device_name}, then activate Samba after SSH returns. Continue?" + elif no_wait: + prompt = f"This will request a reboot of the {device_name} and return without waiting for verification. Continue?" else: prompt = f"This will reboot the {device_name} now. Continue?" proceed = command_context.confirm_or_fail( prompt, default=True, noninteractive_message="Running `deploy` with reboot requires confirmation when stdin is not interactive. Use `deploy --yes` to skip the prompt or `deploy --no-reboot`.", + allow_prompt=not no_input_enabled(args), ) if proceed is None: return 1 @@ -461,48 +234,34 @@ def report_uploaded_file(transfer: FileTransfer) -> None: command_context.cancel_with_error("Cancelled by user at reboot confirmation prompt.") return 0 - print("Requesting reboot...", flush=True) - if not request_deploy_reboot_and_wait( - connection, - command_context, - reboot_no_down_message=REBOOT_NO_DOWN_MESSAGE, - ): - return 1 - - if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: - if not activate_deployed_runtime_flow( + try: + completion = complete_deployment_after_upload( connection, - command_context, - plan.activation_actions, - run_actions=run_remote_actions, - skip_if_ready=True, - already_active_message="Managed runtime already active after reboot; skipping rc.local.", - startup_in_progress_message="Managed runtime startup is already in progress after reboot; waiting for it to finish.", - activation_message="Activating deployed runtime after reboot.", - activation_stage="post_reboot_activation", - verification_stage="verify_runtime_activation", - verification_timeout_seconds=180, - verification_heading="Waiting for NetBSD 4 device activation, this can take a few minutes for Samba to start up...", - failure_message="NetBSD4 activation failed.", - ): - return 1 - print(_activation_complete_message(is_netbsd4=is_netbsd4)) - print(color_green("Deploy Finished.")) - command_context.succeed() - return 0 - - print("Waiting for managed runtime to finish starting...", flush=True) - if verify_managed_runtime_flow( - connection, - command_context, - stage="verify_runtime_reboot", - timeout_seconds=240, - heading="Wait for device to finish loading; it can take a few minutes for Samba to start up...", - failure_message="Managed runtime did not become ready after reboot.", - ): - print(color_green("Deploy Finished.")) - command_context.succeed() - return 0 + prepared_plan, + no_wait=no_wait, + callbacks=command_context.to_operation_callbacks(), + messages=DeployCompletionMessages( + netbsd4_heading="Waiting for NetBSD 4 device activation, this can take a few minutes for Samba to start up...", + reboot_request_message="Requesting reboot...", + reboot_runtime_wait_message="Waiting for managed runtime to finish starting...", + reboot_heading="Wait for device to finish loading; it can take a few minutes for Samba to start up...", + ), + ) + except RebootFlowError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) + return 1 + except DeviceError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) + return 1 - return 1 + if completion.message: + print(completion.message) + if completion.reboot_requested and not completion.waited: + print("Reboot requested; not waiting for the device to go down or come back.") + print("Post-reboot runtime verification skipped.") + print(color_green("Deploy Finished.")) + command_context.succeed() + return 0 return 1 diff --git a/src/timecapsulesmb/cli/discover.py b/src/timecapsulesmb/cli/discover.py index 0a0382f4..9fbe129c 100644 --- a/src/timecapsulesmb/cli/discover.py +++ b/src/timecapsulesmb/cli/discover.py @@ -4,7 +4,7 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import add_config_argument, load_optional_env_config, print_json +from timecapsulesmb.cli.runtime import add_config_argument, print_json from timecapsulesmb.discovery.bonjour import ( DEFAULT_BROWSE_TIMEOUT_SEC, BonjourResolvedService, @@ -14,6 +14,7 @@ service_instance_to_jsonable, ) from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.runtime import load_optional_env_config from timecapsulesmb.telemetry import TelemetryClient diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index befa02e3..48263a7c 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -1,23 +1,20 @@ from __future__ import annotations import argparse -import re -from collections.abc import Mapping from typing import Optional 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_config_argument, print_json from timecapsulesmb.cli.util import color_green, color_red from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.doctor import build_doctor_error, doctor_status_counts +from timecapsulesmb.services.runtime import load_env_config from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.core.paths import resolve_app_paths -BONJOUR_INSTANCE_FAILURE_PREFIX = "no discovered _smb._tcp instance matched" - - def print_result(result: CheckResult) -> None: status = result.status if status == "PASS": @@ -27,321 +24,6 @@ def print_result(result: CheckResult) -> None: print(f"{status} {result.message}") -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 _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 _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 _native_dns_sd_smb_names(native_dns_sd: object) -> list[str]: - 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 [] - - lines: list[str] = [] - expected_summary, expected_instance = _bonjour_expected_summary(results, debug_fields) - if expected_summary: - lines.append(f"INFO expected Bonjour identity: {expected_summary}") - zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") - zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) - if zeroconf_instance_count == 0: - lines.append( - "INFO Python zeroconf discovered 0 Bonjour instances during doctor; " - "mDNS advertiser/discovery path needs investigation" - ) - elif zeroconf_instance_count is not None: - lines.append( - f"INFO Python zeroconf discovered {zeroconf_instance_count} Bonjour instance(s), " - "but no matching _smb._tcp instance" - ) - if _authenticated_smb_listing_passed(debug_fields): - lines.append("INFO SMB works over unicast, but Bonjour discovered no matching _smb._tcp records") - zeroconf_summary = _zeroconf_debug_summary(zeroconf) - if zeroconf_summary: - lines.append(f"INFO Python zeroconf diagnostics: {zeroconf_summary}") - lines.extend(_mdns_transport_context_from_debug(debug_fields)) - lines.extend(_mdns_counter_context_from_debug(debug_fields)) - lines.extend(_native_dns_sd_context_from_debug(debug_fields, expected_instance=expected_instance)) - return lines - - -def _authenticated_smb_listing_passed(debug_fields: Mapping[str, object]) -> bool: - for attempt in _as_sequence(_mapping_value(debug_fields, "authenticated_smb_listing_attempts")): - outcome = _mapping_value(attempt, "outcome") - expected_share_found = _mapping_value(attempt, "expected_share_found") - if outcome == "pass" and expected_share_found is True: - return True - return False - - -def _debug_scalar_text(value: object) -> str | None: - if value is None: - return None - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (str, int, float)): - return str(value) - return None - - -def _debug_summary_fields(value: object, keys: tuple[str, ...]) -> str: - parts: list[str] = [] - for key in keys: - text = _debug_scalar_text(_mapping_value(value, key)) - if text is not None: - parts.append(f"{key}={text}") - return " ".join(parts) - - -def _bonjour_expected_summary( - results: list[CheckResult], - debug_fields: Mapping[str, object], -) -> tuple[str, str | None]: - expected = _mapping_value(debug_fields, "bonjour_expected") - instance = _mapping_value(expected, "instance_name") - if not isinstance(instance, str) or not instance: - instance = _expected_bonjour_instance_from_results(results) - host_label = _mapping_value(expected, "host_label") - target_ip = _mapping_value(expected, "target_ip") - parts: list[str] = [] - if isinstance(instance, str) and instance: - parts.append(f"instance_name={instance!r}") - if isinstance(host_label, str) and host_label: - parts.append(f"host_label={host_label!r}") - if isinstance(target_ip, str) and target_ip: - parts.append(f"target_ip={target_ip!r}") - return " ".join(parts), instance if isinstance(instance, str) and instance else None - - -def _zeroconf_debug_summary(zeroconf: object) -> str: - return _debug_summary_fields( - zeroconf, - ( - "ip_version", - "zeroconf_interfaces", - "instance_count", - "resolved_count", - "service_event_count", - "ptr_record_count", - "resolve_attempt_count", - "resolve_success_count", - "resolve_error_count", - ), - ) - - -def _native_dns_sd_context_from_debug( - debug_fields: Mapping[str, object], - *, - expected_instance: str | None, -) -> list[str]: - lines: list[str] = [] - native_error = _mapping_value(debug_fields, "bonjour_native_dns_sd_error") - if isinstance(native_error, str) and native_error: - lines.append(f"INFO native dns-sd diagnostic error: {native_error}") - - native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") - summary = _debug_summary_fields(native_dns_sd, ("status", "timeout_sec", "elapsed_sec")) - if summary: - lines.append(f"INFO native dns-sd diagnostics: {summary}") - names = _native_dns_sd_smb_names(native_dns_sd) - if names: - names_text = ", ".join(repr(name) for name in names) - lines.append(f"INFO native dns-sd observed _smb._tcp instances: {names_text}") - else: - lines.append("INFO native dns-sd observed 0 _smb._tcp Add events") - if expected_instance is not None: - matched = "yes" if expected_instance in names else "no" - lines.append(f"INFO native dns-sd observed expected _smb._tcp instance: {matched}") - return lines - - -def _mdns_transport_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: - mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") - if not isinstance(mdns_log, str): - return [] - transport = _last_regex_group(r"mdns transport active: ([^\n]+)", mdns_log) - if not transport: - return [] - return [f"INFO mdns-advertiser transport state: {transport}"] - - -def _mdns_counter_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: - mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") - if not isinstance(mdns_log, str): - return [] - counters = _last_regex_group(r"mdns counters: ([^\n]+)", mdns_log) - if not counters: - return [] - return [f"INFO mdns-advertiser counters: {counters}"] - - -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 - - def print_followup_help() -> None: print("") print("Some troubleshooting tips:") @@ -393,7 +75,7 @@ def main(argv: Optional[list[str]] = None) -> int: 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 bf2b843e..0df79a69 100644 --- a/src/timecapsulesmb/cli/flash.py +++ b/src/timecapsulesmb/cli/flash.py @@ -2,43 +2,31 @@ import argparse import base64 -from dataclasses import dataclass -from datetime import datetime, timezone -from functools import partial -import re from pathlib import Path from typing import Optional from timecapsulesmb.apple_firmware import ( APPLE_FIRMWARE_CATALOG_URL, FirmwareTemplateCandidate, - normalize_syap, ) from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import observe_reboot_cycle, request_ssh_reboot from timecapsulesmb.cli.runtime import ( LogCallback, add_config_argument, + add_no_input_argument, + add_no_wait_argument, emit_progress, - load_env_config, + no_input_enabled, prefixed_logger, print_json, require_netbsd4_device_compatibility, - write_json_file, ) from timecapsulesmb.cli.util import color_green, color_red from timecapsulesmb.core.config import AIRPORT_IDENTITIES_BY_SYAP -from timecapsulesmb.core.net import extract_host -from timecapsulesmb.core.paths import default_user_data_dir -from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.flash import ( - FlashAnalysis, FlashAnalysisError, - FlashInspection, STOCK_LOGIN_NETBSD4_DUMMY, analyze_flash_banks, - inspection_error_message, - inspection_to_jsonable, inspect_flash_banks, require_zopfli_gzip_available, sha256_hex, @@ -46,178 +34,42 @@ from timecapsulesmb.flash_payloads import build_patch_payload_for_active_bank as build_acp_flash_payload_for_active_bank from timecapsulesmb.flash_workflow import ( FlashPlan, - plan_check_apple, - plan_download_only, - plan_patch_primary, - plan_restore_apple, require_patch_ready as require_write_ready, - write_and_validate_plan, ) from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.integrations.acp import ACPError, flash_firmware_bank, get_property_int +from timecapsulesmb.services import flash as flash_service +from timecapsulesmb.services.flash import ( + FlashAnalysisBundle, + FlashInputs, + FlashTarget, + FLASH_UNSUPPORTED_DEVICE_MESSAGE, + apply_flash_plan_to_manifest, + build_flash_backup_dir, + default_flash_backup_root, + manifest_from_inspection, + plan_from_operation, + record_write_outcome, + require_netbsd4_flash_target, + save_acp_flash_payload, + save_flash_banks, + save_flash_manifest, + save_primary_patched_bank_if_ready, + write_flash_plan, +) +from timecapsulesmb.services.reboot import RebootFlowError, observe_reboot_cycle, request_reboot +from timecapsulesmb.services.runtime import load_env_config from timecapsulesmb.telemetry import TelemetryClient -from timecapsulesmb.transport.ssh import SshConnection, SshError, run_ssh_capture_bytes +from timecapsulesmb.transport.ssh import SshError -FLASH_READ_TIMEOUT_SECONDS = 180 -FLASH_WRITE_TIMEOUT_SECONDS = 300 MAX_LOGIN_ERROR_UPLOAD_BYTES = 8192 WRITE_OPERATIONS = {"patch", "restore"} POWERCYCLE_REQUIRED_MESSAGE = ( - "POWER-CYCLE REQUIRED: unplug the Time Capsule, wait 10 seconds, then plug it back in." + "POWER-CYCLE REQUIRED: unplug the device, wait 10 seconds, then plug it back in." ) ProgressLogger = LogCallback -@dataclass(frozen=True) -class FlashTarget: - connection: SshConnection - acp_host: str - compatibility: DeviceCompatibility - - -@dataclass(frozen=True) -class FlashInputs: - primary: bytes - secondary: bytes - cks1: int | None - cks2: int | None - syap: str - live_login: bytes - - -@dataclass(frozen=True) -class FlashAnalysisBundle: - inspection: FlashInspection - analysis: FlashAnalysis | None - backup_dir: Path - manifest: dict[str, object] - - -def _safe_path_part(value: str) -> str: - safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) - return safe.strip("-.") or "device" - - -def default_flash_backup_root() -> Path: - return default_user_data_dir() / "flash-backups" - - -def build_flash_backup_dir(*, base_dir: Path | None, host: str, syap: str) -> Path: - if base_dir is not None: - return base_dir.expanduser().resolve() - root = default_flash_backup_root() - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%fZ") - return root / f"{timestamp}-{_safe_path_part(host)}-syAP{_safe_path_part(syap)}" - - -def dump_remote_bank(connection: SshConnection, device: str, *, log: ProgressLogger = None) -> bytes: - emit_progress(log, f"SSH: /bin/dd if={device} bs=65536 2>/dev/null") - return run_ssh_capture_bytes( - connection, - f"/bin/dd if={device} bs=65536 2>/dev/null", - timeout=FLASH_READ_TIMEOUT_SECONDS, - ) - - -def read_live_login(connection: SshConnection, *, log: ProgressLogger = None) -> bytes: - emit_progress(log, "SSH: /bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null") - return run_ssh_capture_bytes(connection, "/bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null", timeout=30) - - -def read_acp_property_int(acp_host: str, password: str, name: str) -> int: - try: - return get_property_int(acp_host, password, name) - except ACPError as exc: - raise FlashAnalysisError(f"ACP property {name} read failed: {exc}") from exc - - -def read_flash_inputs( - connection: SshConnection, - *, - acp_host: str, - password: str, - log: ProgressLogger = None, -) -> tuple[bytes, bytes, int | None, int | None, int | None, bytes]: - emit_progress(log, "Reading primary firmware bank from /dev/rflash0.raw...") - primary = dump_remote_bank(connection, "/dev/rflash0.raw", log=log) - emit_progress(log, "Reading secondary firmware bank from /dev/rflash1.raw...") - secondary = dump_remote_bank(connection, "/dev/rflash1.raw", log=log) - emit_progress(log, "Reading ACP checksum properties cks1 and cks2...") - cks1 = read_acp_property_int(acp_host, password, "cks1") - cks2 = read_acp_property_int(acp_host, password, "cks2") - emit_progress(log, "Reading ACP product property syAP...") - syap = read_acp_property_int(acp_host, password, "syAP") - emit_progress(log, "Reading live /etc/rc.d/LOGIN...") - login = read_live_login(connection, log=log) - return primary, secondary, cks1, cks2, syap, login - - -def dump_remote_bank_for_validation( - connection: SshConnection, - device: str, - *, - log: ProgressLogger = None, -) -> bytes: - emit_progress(log, f"Reading back written firmware bank from {device}...") - return dump_remote_bank(connection, device) - - -def get_property_int_for_validation( - host: str, - password: str, - name: str, - *, - log: ProgressLogger = None, - **kwargs: object, -) -> int: - emit_progress(log, f"Reading ACP checksum property {name} after write...") - return get_property_int(host, password, name, **kwargs) - - -def _manifest( - *, - operation: str, - inspection: FlashInspection, - host: str, - syap: str, - live_login: bytes, - backup_dir: Path, - os_release: str, -) -> dict[str, object]: - payload = inspection_to_jsonable( - inspection, - write_policy="primary_bank_patch" if operation == "patch" else "active_bank_only", - ) - if operation != "patch": - _mark_manifest_no_write(payload, "backup only; no patch candidate built") - files: dict[str, str] = { - "primary": str(backup_dir / "primary.raw"), - "secondary": str(backup_dir / "secondary.raw"), - "manifest": str(backup_dir / "manifest.json"), - } - payload.update({ - "operation": operation, - "host": host, - "syap": syap, - "os_release": os_release, - "backup_dir": str(backup_dir), - "files": files, - "live_login": { - "size": len(live_login), - "sha256": sha256_hex(live_login), - }, - }) - return payload - - -def _mark_manifest_no_write(manifest: dict[str, object], decision: str) -> None: - for bank in manifest["banks"]: - assert isinstance(bank, dict) - bank["would_write"] = False - bank["write_decision"] = decision - - def _manifest_banks(manifest: dict[str, object]) -> list[dict[str, object]]: banks = manifest.get("banks") assert isinstance(banks, list) @@ -228,65 +80,6 @@ def _manifest_banks(manifest: dict[str, object]) -> list[dict[str, object]]: return typed_banks -def _apply_flash_plan_to_manifest(manifest: dict[str, object], plan: FlashPlan) -> None: - target_name = None if plan.target_bank is None else plan.target_bank.name - for bank in _manifest_banks(manifest): - if bank.get("name") != target_name: - bank["would_write"] = False - if target_name is not None and plan.mode == "patch": - bank["write_decision"] = "secondary backup left unmodified" - elif target_name is not None: - bank["write_decision"] = "inactive bank left unmodified" - continue - - if plan.mode == "patch": - bank["would_write"] = plan.write_requested - if plan.already_satisfied: - bank["write_decision"] = "primary bank already patched; no write needed" - elif plan.write_requested: - bank["write_decision"] = "primary bank patch planned" - elif plan.mode == "restore": - bank["would_write"] = plan.write_requested - if plan.write_requested: - bank["write_decision"] = "active bank restore from Apple firmware planned" - else: - bank["write_decision"] = "active bank already matches requested Apple stock firmware; no write needed" - elif plan.mode == "check_apple": - bank["would_write"] = False - bank["write_decision"] = "check only; no firmware write planned" - elif plan.mode == "download_only": - bank["would_write"] = False - bank["write_decision"] = "download only; no firmware write planned" - - -def save_flash_banks(*, backup_dir: Path, primary: bytes, secondary: bytes) -> None: - backup_dir.mkdir(parents=True, exist_ok=True) - (backup_dir / "primary.raw").write_bytes(primary) - (backup_dir / "secondary.raw").write_bytes(secondary) - - -def save_flash_manifest(*, backup_dir: Path, manifest: dict[str, object]) -> None: - write_json_file(backup_dir / "manifest.json", manifest) - - -def save_primary_patched_bank_if_ready(*, backup_dir: Path, inspection: FlashInspection) -> Path | None: - primary = inspection.primary.analysis - if primary is None or primary.patch is None: - return None - path = backup_dir / "primary.patched.raw" - path.write_bytes(primary.patch.target_bank) - return path - - -def save_acp_flash_payload(*, backup_dir: Path, plan: FlashPlan) -> Path | None: - if plan.target_bank is None or plan.payload is None: - return None - suffix = "patched" if plan.mode == "patch" else plan.mode - path = backup_dir / f"{plan.target_bank.name}.{suffix}.basebinary" - path.write_bytes(plan.payload.data) - return path - - def live_login_mismatch_error_lines(live_login: bytes) -> list[str]: if live_login == STOCK_LOGIN_NETBSD4_DUMMY: return [] @@ -403,50 +196,6 @@ def _operation_from_args(args: argparse.Namespace) -> str: return "read_only" -def _plan_from_operation( - *, - operation: str, - inspection: FlashInspection, - analysis: FlashAnalysis | None, - force: bool, - syap: str, - firmware_template: Path | None, - firmware_version: str | None, -) -> FlashPlan | None: - if operation == "patch": - return plan_patch_primary( - inspection, - force=force, - syap=syap, - firmware_template=firmware_template, - firmware_version=firmware_version, - ) - if analysis is None: - raise FlashAnalysisError(inspection_error_message(inspection)) - if operation == "restore": - return plan_restore_apple( - analysis, - syap=syap, - firmware_template=firmware_template, - firmware_version=firmware_version, - ) - if operation == "check_apple": - return plan_check_apple( - analysis, - syap=syap, - firmware_template=firmware_template, - firmware_version=firmware_version, - ) - if operation == "download_only": - return plan_download_only( - analysis, - syap=syap, - firmware_template=firmware_template, - firmware_version=firmware_version, - ) - return None - - def _confirmation_prompt(plan: FlashPlan) -> str: assert plan.target_bank is not None if plan.mode == "restore": @@ -480,64 +229,6 @@ def _update_context_with_plan(command_context: CommandContext, plan: FlashPlan, command_context.update_fields(**fields) -def _write_outcome_payload( - *, - plan: FlashPlan, - status: str, - write_validated: bool, - write_may_have_modified_device: bool, - stage: str | None = None, - message: str | None = None, -) -> dict[str, object]: - outcome: dict[str, object] = { - "status": status, - "mode": plan.mode, - "write_validated": write_validated, - "write_may_have_modified_device": write_may_have_modified_device, - } - if plan.target_bank is not None: - outcome.update({ - "bank": plan.target_bank.name, - "device": plan.target_bank.device, - }) - if plan.payload is not None: - outcome.update({ - "firmware_payload_sha256": plan.payload.payload_sha256, - "firmware_payload_size": len(plan.payload.data), - "expected_prefix_sha256": plan.payload.expected_prefix_sha256, - "expected_prefix_size": len(plan.payload.expected_prefix), - }) - if stage is not None: - outcome["stage"] = stage - if message is not None: - outcome["message"] = message - return outcome - - -def _record_write_outcome( - *, - bundle: FlashAnalysisBundle, - plan: FlashPlan, - status: str, - write_validated: bool, - write_may_have_modified_device: bool, - stage: str | None = None, - message: str | None = None, - write_result: dict[str, object] | None = None, -) -> None: - bundle.manifest["write_outcome"] = _write_outcome_payload( - plan=plan, - status=status, - write_validated=write_validated, - write_may_have_modified_device=write_may_have_modified_device, - stage=stage, - message=message, - ) - if write_result is not None: - bundle.manifest["write_result"] = write_result - save_flash_manifest(backup_dir=bundle.backup_dir, manifest=bundle.manifest) - - def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Analyze, patch, or restore the NetBSD4 firmware boot hook.") add_config_argument(parser) @@ -548,7 +239,9 @@ def _build_parser() -> argparse.ArgumentParser: mode_group.add_argument("--check-apple", action="store_true", help="Check whether the active bank matches Apple stock firmware") 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") + add_no_input_argument(parser) 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 +273,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: @@ -605,8 +300,6 @@ def _resolve_flash_target( emit_progress(log, "Resolving SSH target...") target = command_context.resolve_validated_managed_target(profile="flash", include_probe=False) connection = target.connection - acp_host = extract_host(connection.host) - emit_progress(log, f"Using ACP host {acp_host}.") command_context.set_stage("check_compatibility") emit_progress(log, "Checking NetBSD4 device compatibility...") @@ -614,9 +307,15 @@ def _resolve_flash_target( command_context, command_name="flash", json_output=args.json, - unsupported_message="flash is only supported for NetBSD4 AirPort storage devices.", + unsupported_message=FLASH_UNSUPPORTED_DEVICE_MESSAGE, ) - return FlashTarget(connection=connection, acp_host=acp_host, compatibility=compatibility) + flash_target = require_netbsd4_flash_target( + connection, + compatibility, + update_fields=command_context.update_fields, + ) + emit_progress(log, f"Using ACP host {flash_target.acp_host}.") + return flash_target def _read_flash( @@ -627,7 +326,7 @@ def _read_flash( ) -> FlashInputs | None: command_context.set_stage("read_flash") try: - primary, secondary, cks1, cks2, acp_syap, live_login = read_flash_inputs( + inputs = flash_service.read_flash_inputs( target.connection, acp_host=target.acp_host, password=target.connection.password, @@ -646,28 +345,12 @@ def _read_flash( command_context.fail() return None - try: - syap = normalize_syap(acp_syap) - except FlashAnalysisError as exc: - message = str(exc) - record_flash_error(command_context, message, stage="read_flash", live_login=live_login) - print(message) - command_context.fail() - return None - - identity = AIRPORT_IDENTITIES_BY_SYAP.get(syap) + identity = AIRPORT_IDENTITIES_BY_SYAP.get(inputs.syap) command_context.update_fields( - device_syap=syap, + device_syap=inputs.syap, device_model=None if identity is None else identity.mdns_model, ) - return FlashInputs( - primary=primary, - secondary=secondary, - cks1=cks1, - cks2=cks2, - syap=syap, - live_login=live_login, - ) + return inputs def _analyze_flash( @@ -712,14 +395,12 @@ def _analyze_flash( primary_login=None if primary_analysis is None else primary_analysis.login.classification, secondary_login=None if secondary_analysis is None else secondary_analysis.login.classification, ) - manifest = _manifest( + manifest = manifest_from_inspection( operation=operation, inspection=inspection, - host=target.acp_host, - syap=inputs.syap, - live_login=inputs.live_login, + target=target, + inputs=inputs, backup_dir=backup_dir, - os_release=target.compatibility.os_release, ) return FlashAnalysisBundle(inspection=inspection, analysis=analysis, backup_dir=backup_dir, manifest=manifest) @@ -737,7 +418,7 @@ def _plan_flash( command_context.set_stage("plan_flash") try: - plan = _plan_from_operation( + plan = plan_from_operation( operation=operation, inspection=bundle.inspection, analysis=bundle.analysis, @@ -785,7 +466,7 @@ def _plan_flash( if isinstance(files, dict) and payload_path is not None and plan.target_bank is not None: files[f"{plan.target_bank.name}_{plan.mode}_basebinary_payload"] = str(payload_path) bundle.manifest["flash_plan"] = plan.to_jsonable() - _apply_flash_plan_to_manifest(bundle.manifest, plan) + apply_flash_plan_to_manifest(bundle.manifest, plan) _update_context_with_plan(command_context, plan, payload_path) return True, plan @@ -830,7 +511,7 @@ def _prepare_write( print("Primary firmware bank is already patched; no write needed.") else: print("Active firmware bank already matches the requested Apple stock firmware; no write needed.") - _record_write_outcome( + record_write_outcome( bundle=bundle, plan=plan, status="not_needed", @@ -848,13 +529,14 @@ def _prepare_write( noninteractive_message=( f"Running `flash --{operation}` requires confirmation when stdin is not interactive. " f"Use `flash --{operation} --yes` to skip the prompt." - ) + ), + allow_prompt=not no_input_enabled(args), ) if proceed is None: return False, 1 if not proceed: print("Flash write cancelled.", flush=True) - _record_write_outcome( + record_write_outcome( bundle=bundle, plan=plan, status="cancelled", @@ -882,28 +564,11 @@ def _write_flash( target_text = "primary" if plan.mode == "patch" else f"active {plan.target_bank.name}" command_context.set_stage(stage) emit_progress(log, f"Sending ACP flash command for {target_text} bank...") - _record_write_outcome( - bundle=bundle, - plan=plan, - status="attempting", - write_validated=False, - write_may_have_modified_device=True, - stage=stage, - ) try: - write_result = write_and_validate_plan( - connection=target.connection, - acp_host=target.acp_host, - plan=plan, - os_release=target.compatibility.os_release, - flash_firmware_bank_func=flash_firmware_bank, - dump_remote_bank_func=partial(dump_remote_bank_for_validation, log=log), - get_property_int_func=partial(get_property_int_for_validation, log=log), - timeout=FLASH_WRITE_TIMEOUT_SECONDS, - ) + write_result = write_flash_plan(target=target, bundle=bundle, plan=plan, log=log) except FlashAnalysisError as exc: message = str(exc) - _record_write_outcome( + record_write_outcome( bundle=bundle, plan=plan, status="failed", @@ -918,7 +583,7 @@ def _write_flash( return None except SshError as exc: message = f"SSH post-write validation failed: {exc}" - _record_write_outcome( + record_write_outcome( bundle=bundle, plan=plan, status="failed", @@ -932,7 +597,7 @@ def _write_flash( command_context.fail() return None - _record_write_outcome( + record_write_outcome( bundle=bundle, plan=plan, status="validated", @@ -972,14 +637,34 @@ def _finish_write( command_context.succeed() return 0 - request_ssh_reboot(target.connection, command_context, log=log) - if not observe_reboot_cycle( - target.connection, - command_context, - reboot_no_down_message="Firmware write validated, but the device did not go down after reboot request.", - down_timeout_seconds=60, - up_timeout_seconds=240, - ): + try: + request_reboot( + target.connection, + strategy="ssh", + callbacks=command_context.to_operation_callbacks(), + progress_log=log, + raise_on_request_error=args.no_wait, + ) + except RebootFlowError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) + return 1 + 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 + try: + observe_reboot_cycle( + target.connection, + callbacks=command_context.to_operation_callbacks(), + reboot_no_down_message="Firmware write validated, but the device did not go down after reboot request.", + reboot_up_timeout_message="Timed out waiting for SSH after reboot.", + down_timeout_seconds=60, + up_timeout_seconds=240, + ) + except RebootFlowError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) print(color_red(POWERCYCLE_REQUIRED_MESSAGE), flush=True) return 1 print("Device returned after reboot. Run `tcapsule flash --check-apple` to verify Apple stock firmware.", flush=True) @@ -1047,6 +732,12 @@ def _run_flash( def main(argv: Optional[list[str]] = None) -> int: args, operation = _parse_args(argv) + if no_input_enabled(args) and operation in WRITE_OPERATIONS and not args.yes: + print( + f"Running `flash --{operation}` in non-interactive mode requires `--yes` " + "to approve the firmware write." + ) + return 1 _require_operation_dependencies(operation) log = prefixed_logger("flash", enabled=not args.json) diff --git a/src/timecapsulesmb/cli/flows.py b/src/timecapsulesmb/cli/flows.py index 71660eea..2f3596e1 100644 --- a/src/timecapsulesmb/cli/flows.py +++ b/src/timecapsulesmb/cli/flows.py @@ -1,76 +1,13 @@ from __future__ import annotations import time -from typing import Callable, Iterable +from typing import Iterable from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import LogCallback, emit_progress -from timecapsulesmb.core.net import extract_host -from timecapsulesmb.core.errors import system_exit_message -from timecapsulesmb.deploy.commands import RemoteAction -from timecapsulesmb.deploy.executor import remote_request_reboot -from timecapsulesmb.deploy.verify import ( - managed_runtime_ready, - render_managed_runtime_verification, - verify_managed_runtime, -) -from timecapsulesmb.device.probe import ( - RUNTIME_ACTIVATION_STATE_READY, - RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING, - probe_runtime_activation_state_conn, - read_remote_network_diagnostics_conn, - read_runtime_log_tails_conn, - runtime_startup_failure_debug_fields, - wait_for_ssh_state_conn, -) -from timecapsulesmb.integrations.acp import ACPError, reboot as acp_reboot +from timecapsulesmb.device.errors import DeviceError +from timecapsulesmb.services.runtime_verification import verify_managed_runtime_ready from timecapsulesmb.transport.local import tcp_open -from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError - - -REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." -DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE = ( - "Timed out waiting for SSH after reboot.\n\n" - "The payload was uploaded and the reboot request succeeded, but the device did not accept SSH again " - "before the 4 minute timeout. It may still be booting, or it may have come back with a different IP address.\n\n" - "Next steps:\n" - " 1. Wait a few more minutes.\n" - " 2. If the device is reachable at a new IP, update TC_HOST or rerun configure.\n" - " 3. Make sure you are connected to the same network/wifi as the device.\n" - " 4. On NetBSD 4 devices, run `tcapsule activate` once SSH is reachable; " - "deploy did not get far enough to activate Samba after reboot." -) -ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 -SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE = "SSH: /bin/sync; /sbin/shutdown -r now (fallback /sbin/reboot)" - - -def wait_for_tcp_port_state( - host: str, - port: int, - *, - expected_state: bool, - timeout_seconds: int = 120, - interval_seconds: int = 5, - verbose: bool = True, - service_name: str | None = None, -) -> bool: - label = service_name or f"TCP port {port}" - expected_state_string = "open" if expected_state else "closed" - if verbose: - print(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 verbose: - print(f"{label} is {expected_state_string}.") - return True - if time.time() >= deadline: - break - time.sleep(interval_seconds) - if verbose: - print(f"{label} did not become {expected_state_string} within {timeout_seconds}s.") - return False +from timecapsulesmb.transport.ssh import SshConnection def wait_for_device_up( @@ -90,170 +27,6 @@ def wait_for_device_up( return False -def request_reboot_and_wait( - connection: SshConnection, - command_context: CommandContext, - *, - reboot_no_down_message: str, - down_timeout_seconds: int = 60, - up_timeout_seconds: int = 240, - reboot_up_timeout_message: str = REBOOT_UP_TIMEOUT_MESSAGE, -) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_acp_then_ssh(connection, command_context) - - return observe_reboot_cycle( - connection, - command_context, - reboot_no_down_message=reboot_no_down_message, - down_timeout_seconds=down_timeout_seconds, - up_timeout_seconds=up_timeout_seconds, - reboot_up_timeout_message=reboot_up_timeout_message, - ) - - -def request_deploy_reboot_and_wait( - connection: SshConnection, - command_context: CommandContext, - *, - reboot_no_down_message: str, - down_timeout_seconds: int = 60, - up_timeout_seconds: int = 240, - reboot_up_timeout_message: str = DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, -) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_via_ssh_shutdown(connection, command_context) - - return observe_reboot_cycle( - connection, - command_context, - reboot_no_down_message=reboot_no_down_message, - down_timeout_seconds=down_timeout_seconds, - up_timeout_seconds=up_timeout_seconds, - reboot_up_timeout_message=reboot_up_timeout_message, - ) - - -def request_ssh_reboot( - connection: SshConnection, - command_context: CommandContext, - *, - log: LogCallback = None, -) -> 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) - - -def _request_reboot_acp_then_ssh(connection: SshConnection, command_context: CommandContext) -> 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) - - -def _request_reboot_via_acp(connection: SshConnection, command_context: CommandContext) -> bool: - command_context.add_debug_fields(acp_reboot_attempted=True) - try: - acp_reboot( - extract_host(connection.host), - connection.password, - timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS, - ) - except ACPError as exc: - command_context.add_debug_fields( - acp_reboot_succeeded=False, - acp_reboot_error=system_exit_message(exc), - ) - print("ACP reboot request failed; trying SSH reboot request.") - return False - - command_context.add_debug_fields(acp_reboot_succeeded=True) - print("ACP reboot requested.") - return True - - -def _request_reboot_via_ssh_shutdown( - connection: SshConnection, - command_context: CommandContext, - *, - log: LogCallback = None, -) -> None: - command_context.add_debug_fields(reboot_request_strategy="ssh_shutdown_then_reboot") - _request_reboot_via_ssh( - connection, - command_context, - log=log, - request_reboot=remote_request_reboot, - progress_message=SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, - ) - - -def _request_reboot_via_ssh( - connection: SshConnection, - command_context: CommandContext, - *, - log: LogCallback = None, - request_reboot: Callable[[SshConnection], None] | None = None, - progress_message: str = SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, -) -> None: - command_context.add_debug_fields(ssh_reboot_attempted=True) - emit_progress(log, progress_message) - try: - if request_reboot is None: - request_reboot = remote_request_reboot - request_reboot(connection) - except SshCommandTimeout as exc: - command_context.add_debug_fields( - ssh_reboot_succeeded=False, - ssh_reboot_timed_out=True, - ssh_reboot_error=system_exit_message(exc), - ) - print("SSH reboot request timed out; checking whether the device is rebooting...") - return - except SshError as exc: - command_context.add_debug_fields( - ssh_reboot_succeeded=False, - ssh_reboot_error=system_exit_message(exc), - ) - print("SSH reboot request failed; checking whether the device is rebooting anyway...") - return - - command_context.add_debug_fields(ssh_reboot_succeeded=True) - print("SSH reboot requested.") - - -def observe_reboot_cycle( - connection: SshConnection, - command_context: CommandContext, - *, - reboot_no_down_message: str, - down_timeout_seconds: int, - up_timeout_seconds: int, - reboot_up_timeout_message: str = REBOOT_UP_TIMEOUT_MESSAGE, -) -> bool: - print("Waiting for the device to go down...") - command_context.set_stage("wait_for_reboot_down") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - print(reboot_no_down_message) - command_context.fail_with_error(reboot_no_down_message) - return False - - print("Device went down; waiting for it to come back up...") - command_context.set_stage("wait_for_reboot_up") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - print(reboot_up_timeout_message) - command_context.fail_with_error(reboot_up_timeout_message) - return False - - command_context.update_fields(device_came_back_after_reboot=True) - print("Device is back online.") - return True - - def verify_managed_runtime_flow( connection: SshConnection, command_context: CommandContext, @@ -263,81 +36,17 @@ def verify_managed_runtime_flow( heading: str, failure_message: str, ) -> bool: - command_context.set_stage(stage) - verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) - for line in render_managed_runtime_verification(verification, heading=heading): - print(line) - if not managed_runtime_ready(verification): - detail = verification.detail.strip() - runtime_log_fields: dict[str, object] = {} - try: - runtime_log_fields = read_runtime_log_tails_conn(connection) - command_context.add_debug_fields(**runtime_log_fields) - except Exception as exc: - command_context.add_debug_fields(remote_runtime_log_tail_error=system_exit_message(exc)) - startup_failure_fields = runtime_startup_failure_debug_fields( - runtime_log_fields, - verification_detail=detail, + try: + verify_managed_runtime_ready( + connection, + callbacks=command_context.to_operation_callbacks(), + stage=stage, + timeout_seconds=timeout_seconds, + heading=heading, + failure_message=failure_message, ) - if startup_failure_fields: - command_context.add_debug_fields(**startup_failure_fields) - if startup_failure_fields.get("runtime_startup_failure") == "network_auto_ip_unavailable": - try: - command_context.add_debug_fields(**read_remote_network_diagnostics_conn(connection)) - except Exception as exc: - command_context.add_debug_fields(remote_network_diagnostics_error=system_exit_message(exc)) - if detail: - failure_message = f"{failure_message.rstrip()} {detail}" - print(failure_message) - command_context.fail_with_error(failure_message) + except DeviceError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) return False return True - - -def activate_deployed_runtime_flow( - connection: SshConnection, - command_context: CommandContext, - activation_actions: list[RemoteAction], - *, - run_actions: Callable[[SshConnection, list[RemoteAction]], None], - skip_if_ready: bool, - already_active_message: str, - startup_in_progress_message: str, - activation_message: str, - activation_stage: str, - verification_stage: str, - probe_timeout_seconds: int = 20, - verification_timeout_seconds: int = 180, - verification_heading: str = "Waiting for managed runtime to finish starting...", - failure_message: str = "Managed runtime activation failed.", -) -> bool: - if skip_if_ready: - command_context.set_stage("probe_runtime") - preflight = probe_runtime_activation_state_conn(connection, timeout_seconds=probe_timeout_seconds) - if preflight.state == RUNTIME_ACTIVATION_STATE_READY: - print(already_active_message) - command_context.update_fields(runtime_already_ready=True) - return True - if preflight.state == RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING: - print(startup_in_progress_message) - command_context.update_fields(runtime_startup_already_running=True) - return verify_managed_runtime_flow( - connection, - command_context, - stage=verification_stage, - timeout_seconds=verification_timeout_seconds, - heading=verification_heading, - failure_message=failure_message, - ) - - command_context.set_stage(activation_stage) - print(activation_message) - run_actions(connection, activation_actions) - return verify_managed_runtime_flow( - connection, - command_context, - stage=verification_stage, - timeout_seconds=verification_timeout_seconds, - heading=verification_heading, - failure_message=failure_message, - ) diff --git a/src/timecapsulesmb/cli/fsck.py b/src/timecapsulesmb/cli/fsck.py index 974e6a57..2bb7f3ed 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -2,73 +2,30 @@ 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.cli.runtime import add_config_argument, add_no_input_argument, no_input_enabled from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS -from timecapsulesmb.device.processes import ( - render_direct_pkill9_by_ucomm, - render_direct_pkill9_manager, - render_direct_pkill9_watchdog, -) from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.device.storage import MaStVolume +from timecapsulesmb.services import storage as storage_service +from timecapsulesmb.services.maintenance import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + build_remote_fsck_script, + format_fsck_targets, + fsck_target_from_volume, + FsckTarget, + select_fsck_target, +) +from timecapsulesmb.services.reboot import RebootFlowError, observe_reboot_cycle +from timecapsulesmb.services.runtime import load_env_config 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})") +def prompt_fsck_target(targets: tuple[FsckTarget, ...]) -> FsckTarget: + print(format_fsck_targets(targets)) while True: answer = input("Select a volume to fsck by number: ").strip() if answer.isdigit(): @@ -78,33 +35,11 @@ def select_fsck_target(targets: tuple[FsckTarget, ...], selector: str | None, *, print("Please enter a valid volume number.") -def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: - lines = [ - render_direct_pkill9_manager(), - 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) parser.add_argument("--yes", action="store_true", help="Do not prompt before running fsck") + add_no_input_argument(parser) 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") parser.add_argument("--volume", help="HFS volume device to repair, for example dk2 or /dev/dk2") @@ -122,23 +57,30 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.set_stage("validate_config") command_context.require_valid_config(profile="fsck") + if no_input_enabled(args) and not args.yes: + command_context.set_stage("noninteractive_confirmation") + message = "Running `fsck` in non-interactive mode requires `--yes` to approve disk repair." + print(message) + command_context.fail_with_error(message) + return 1 command_context.set_stage("resolve_connection") connection = command_context.resolve_env_connection(allow_empty_password=True) if connection.password: command_context.start_optional_airport_identity_probe(connection) - mounted_volumes = command_context.mount_mast_volumes( + mounted_volumes = storage_service.mount_mast_volumes_with_diagnostics( connection, + callbacks=command_context.to_operation_callbacks(), wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, mount_stage="mount_hfs_volumes", ) command_context.set_stage("select_fsck_volume") + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) try: - target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), - args.volume, - prompt=not args.yes, - ) + if not args.volume and len(targets) > 1 and not args.yes and not no_input_enabled(args): + target = prompt_fsck_target(targets) + else: + target = select_fsck_target(targets, args.volume) except RuntimeError as exc: raise SystemExit(str(exc)) from exc command_context.update_fields(fsck_device=target.device, fsck_mountpoint=target.mountpoint) @@ -152,6 +94,7 @@ def main(argv: Optional[list[str]] = None) -> int: f"This will stop file sharing, unmount the disk, run fsck_hfs, and reboot the {device_name}. Continue?", default=True, noninteractive_message="Running `fsck` requires confirmation when stdin is not interactive. Use `fsck --yes` in a non-interactive environment.", + allow_prompt=not no_input_enabled(args), ) if proceed is None: return 1 @@ -178,13 +121,18 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 - if not observe_reboot_cycle( - connection, - command_context, - reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, - down_timeout_seconds=90, - up_timeout_seconds=420, - ): + try: + observe_reboot_cycle( + connection, + callbacks=command_context.to_operation_callbacks(), + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + reboot_up_timeout_message="Timed out waiting for SSH after reboot.", + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + except RebootFlowError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) return 1 command_context.succeed() diff --git a/src/timecapsulesmb/cli/main.py b/src/timecapsulesmb/cli/main.py index 0dc61e2f..3aa1e5dc 100644 --- a/src/timecapsulesmb/cli/main.py +++ b/src/timecapsulesmb/cli/main.py @@ -4,12 +4,13 @@ import sys from typing import Optional -from . import activate, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, repair_xattrs, uninstall, validate_install +from . import activate, api, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, repair_xattrs, uninstall, validate_install from timecapsulesmb.core.paths import DistributionRootError -from .version_check import check_client_version, render_version_block_message +from timecapsulesmb.services.version_check import check_client_version, render_version_block_message COMMANDS = { + "api": api.main, "bootstrap": bootstrap.main, "activate": activate.main, "configure": configure.main, @@ -36,7 +37,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/paths.py b/src/timecapsulesmb/cli/paths.py index be29ac3c..9c7a5f64 100644 --- a/src/timecapsulesmb/cli/paths.py +++ b/src/timecapsulesmb/cli/paths.py @@ -4,10 +4,11 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import add_config_argument, load_optional_env_config, print_json +from timecapsulesmb.cli.runtime import add_config_argument, print_json from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.install_validation import paths_to_jsonable +from timecapsulesmb.services.runtime import load_optional_env_config from timecapsulesmb.telemetry import TelemetryClient diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index bb00930a..b64f230f 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -5,202 +5,99 @@ from pathlib import Path from typing import 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.cli.runtime import ( + add_config_argument, + add_no_input_argument, + confirm as confirm_prompt, + no_input_enabled, +) from timecapsulesmb.core.config import AppConfig from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.repair_xattrs import ( - ACTION_CLEAR_ARCH_FLAG, - ACTION_FIX_PERMISSIONS, - DEFAULT_REPAIR_REPORT_LIMIT, - MountedSmbShare, - RepairCandidate, - RepairFinding, - RepairSummary, - XattrStatus, - actionable_findings, - build_repair_report, - classify_path, - default_share_path_from_config, - file_flags, - find_findings, - finding_to_candidate, - format_finding_line, - is_time_machine_path, - iter_scan_paths, - mounted_smb_shares, - parse_mounted_smb_shares, - path_exists, - path_has_hidden_component, - repair_candidate, - run_capture, - should_skip_path, - ssh_target_host, - unresolved_findings_after_success, - validate_repair_root_under_volumes, - xattr_status, - xattrs_readable, +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.repair_xattrs import ( + RepairXattrsRequest, + RepairXattrsServiceError, + RepairRunResult, + run_repair as run_repair_service, ) +from timecapsulesmb.services.runtime import load_optional_env_config from timecapsulesmb.telemetry import TelemetryClient -def print_candidates(candidates: list[RepairCandidate], *, dry_run: bool) -> None: - verb = "Would repair" if dry_run else "Repairable" - 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})") - - -def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: - 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}" - print(f"WARN {detail}") - - -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}") - if not dry_run: - print(f" repaired: {summary.repaired}") - print(f" failed: {summary.failed}") - - 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: - command_context.set_stage("resolve_scan_root") - command_context.update_fields( +def repair_request_from_args(args: argparse.Namespace) -> RepairXattrsRequest: + return RepairXattrsRequest( + path=args.path, dry_run=args.dry_run, + approve_repairs=args.yes, 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, + verbose=args.verbose, ) - 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") - print(f"Scanning {root}") + +def run_repair(args: argparse.Namespace, command_context: CommandContext, config: AppConfig) -> int: 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, + result = run_repair_service( + repair_request_from_args(args), + config, + callbacks=OperationCallbacks( + set_stage=command_context.set_stage, + update_fields=command_context.update_fields, + log=print, + ), + confirm=None if no_input_enabled(args) else confirm, ) - except RuntimeError as exc: + except RepairXattrsServiceError 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, - ) + apply_result_to_command_context(result, command_context) + return result.returncode - if not findings: - print("No repairable files found.") - print_summary(summary, dry_run=True) + +def apply_result_to_command_context(result: RepairRunResult, command_context: CommandContext) -> None: + if result.telemetry_result == "success": command_context.succeed() - return 0 - - command_context.set_stage("report_findings") - print_diagnostics(findings, verbose=args.verbose) - if candidates: - print_candidates(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 - - 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 + elif result.error: + command_context.fail_with_error(result.error) + else: + command_context.fail() + + +def _repair_result_payload(result: RepairRunResult) -> dict[str, object]: + fields = result.to_payload_fields() + fields["stats"] = jsonable(result.summary) + return repair_xattrs_payload(fields) - 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 - - command_context.set_stage("repair_findings") - failed_findings: list[RepairFinding] = [] - for finding, candidate in zip(repairs, candidates): - print(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}") - if ACTION_FIX_PERMISSIONS in candidate.actions: - print(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}") - else: - print(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) - if unresolved: - command_context.fail_with_error(build_repair_report(findings, failed=unresolved)) + +def run_repair_json(args: argparse.Namespace, config: AppConfig, sink: EventSink) -> int: + operation = "repair-xattrs" + try: + result = run_repair_service( + repair_request_from_args(args), + config, + callbacks=OperationCallbacks( + set_stage=lambda stage: sink.stage(operation, stage), + log=lambda message: sink.log(operation, message), + ), + ) + except RepairXattrsServiceError as exc: + message = str(exc) or "repair-xattrs failed" + sink.error(operation, message, code="operation_failed") + sink.result(operation, ok=False, payload={"error": message}) return 1 - command_context.succeed() - return 0 + payload = _repair_result_payload(result) + sink.result(operation, ok=result.returncode == 0, payload=payload) + return result.returncode def main(argv: Optional[list[str]] = None) -> int: @@ -209,6 +106,7 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--path", type=Path, default=None, help="Mounted SMB share path or subdirectory to scan. Defaults to the mounted SMB share matching .env.") parser.add_argument("--dry-run", action="store_true", help="Only scan and report files; do not prompt or repair") parser.add_argument("--yes", action="store_true", help="Repair without prompting") + add_no_input_argument(parser) parser.add_argument("--recursive", dest="recursive", action="store_true", default=True, help="Scan recursively (default)") parser.add_argument("--no-recursive", dest="recursive", action="store_false", help="Only scan the top-level directory") parser.add_argument("--max-depth", type=int, default=None, help="Maximum directory depth to scan when recursive") @@ -216,15 +114,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 c5241634..6c8b16ec 100644 --- a/src/timecapsulesmb/cli/runtime.py +++ b/src/timecapsulesmb/cli/runtime.py @@ -1,40 +1,20 @@ from __future__ import annotations import argparse +import getpass import json -from dataclasses import dataclass +import os +import sys from pathlib import Path from typing import Callable, Optional -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, - is_link_local_ipv6, - resolve_host_ipv4s, - resolve_host_ipv6s, -) -from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.core.config import ConfigError from timecapsulesmb.device.compat import ( DeviceCompatibility, is_netbsd4_payload_family, render_compatibility_message, ) -from timecapsulesmb.device.probe import ( - ProbedDeviceState, - RemoteInterfaceProbeResult, - probe_connection_state, - probe_remote_interface_conn, - read_interface_ipv4_addrs_conn, -) -from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy +from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS LogCallback = Optional[Callable[[str], None]] @@ -44,13 +24,6 @@ class NonInteractivePromptError(RuntimeError): """Raised when a required confirmation cannot be read from stdin.""" -@dataclass(frozen=True) -class ManagedTargetState: - connection: SshConnection - interface_probe: RemoteInterfaceProbeResult | None - probe_state: ProbedDeviceState | None - - def add_config_argument(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--config", @@ -60,8 +33,84 @@ def add_config_argument(parser: argparse.ArgumentParser) -> None: ) -def config_path_from_args(args: argparse.Namespace) -> Path | None: - return getattr(args, "config", None) +def add_no_input_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--no-input", + action="store_true", + help="Do not prompt; fail if required input or confirmation is missing", + ) + + +def no_input_enabled(args: argparse.Namespace | object) -> bool: + return bool(getattr(args, "no_input", False)) + + +def prompt_device_password(prompt: str) -> str: + return getpass.getpass(prompt) + + +def add_password_source_arguments(parser: argparse.ArgumentParser) -> None: + password_group = parser.add_mutually_exclusive_group() + password_group.add_argument( + "--password-env", + metavar="NAME", + help="Read the device password from environment variable NAME", + ) + password_group.add_argument( + "--password-file", + type=Path, + metavar="PATH", + help="Read the device password from PATH", + ) + password_group.add_argument( + "--password-stdin", + action="store_true", + help="Read the device password from stdin", + ) + + +def read_password_source_args(args: argparse.Namespace) -> str | None: + env_name = getattr(args, "password_env", None) + if env_name: + if env_name not in os.environ: + raise ConfigError(f"Password environment variable is not set: {env_name}") + return os.environ[env_name] + + password_file = getattr(args, "password_file", None) + if password_file is not None: + try: + return Path(password_file).read_text(encoding="utf-8").rstrip("\r\n") + except OSError as exc: + raise ConfigError(f"Failed to read password file {password_file}: {exc}") from exc + + if getattr(args, "password_stdin", False): + return sys.stdin.read().rstrip("\r\n") + + return 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 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 json_text(data: object) -> str: @@ -125,110 +174,6 @@ def confirm( print("Please answer 'y' or 'n'.") -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) - - -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) - - -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)) - link_local_ipv6s = tuple(ip for ip in resolve_host_ipv6s(host) if is_link_local_ipv6(ip)) - link_local_hosts = link_local_ips + link_local_ipv6s - if not link_local_hosts: - return None - noun = "address" if len(link_local_hosts) == 1 else "addresses" - return ( - f"{field_name} host {host} resolves to link-local {noun} " - f"{', '.join(link_local_hosts)}. Use the device's LAN IP or a hostname that resolves " - "to its LAN IP; link-local addresses are 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_supported_device_compatibility( command_context, *, diff --git a/src/timecapsulesmb/cli/set_ssh.py b/src/timecapsulesmb/cli/set_ssh.py index f6ba21b7..6989021c 100644 --- a/src/timecapsulesmb/cli/set_ssh.py +++ b/src/timecapsulesmb/cli/set_ssh.py @@ -1,17 +1,29 @@ 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.flows import wait_for_device_up +from timecapsulesmb.cli.runtime import ( + LogCallback, + add_config_argument, + add_no_input_argument, + add_no_wait_argument, + confirm, + emit_progress, + no_input_enabled, +) from timecapsulesmb.cli.util import color_red from timecapsulesmb.core.config import ConfigError -from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.net import endpoint_host from timecapsulesmb.deploy.executor import remote_request_reboot from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.integrations.acp import enable_ssh +from timecapsulesmb.services.acp_ssh import enable_ssh_with_identity_preflight +from timecapsulesmb.services import runtime as runtime_service +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.runtime import load_env_config from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, run_ssh from timecapsulesmb.transport.local import tcp_open @@ -28,6 +40,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,115 +95,208 @@ 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_input_argument(parser) + 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 = endpoint_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") - enable_ssh(acp_host, password, reboot_device=True, log=print) + enable_ssh_with_identity_preflight( + acp_host, + password, + reboot_device=True, + callbacks=OperationCallbacks( + set_stage=command_context.set_stage, + log=print, + add_debug_fields=command_context.add_debug_fields, + update_fields=command_context.update_fields, + ), + ) except Exception as e: error_text = str(e) - message = f"Failed to enable SSH via ACP: {error_text}" - print(color_red("Failed to enable SSH via ACP:")) + if command_context.debug_stage == "acp_identity_probe": + label = "Failed to read AirPort identity via ACP" + else: + label = "Failed to enable SSH via ACP" + message = f"{label}: {error_text}" + print(color_red(f"{label}:")) print("\n".join(error_text.splitlines())) 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"): + if not runtime_service.wait_for_tcp_port_state( + acp_host, + 22, + expected_state=True, + log=print, + 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 and no_input_enabled(args): + message = ( + "Running `set-ssh` in non-interactive legacy mode requires `--yes` " + "to disable SSH, or an explicit `--enable`, `--disable`, or `--status` mode." + ) + print(message) + command_context.fail_with_error(message) + return 1 + 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 + 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("SSH is configured. You can connect as 'root' using the AirPort admin password.") + print("Device is starting reboot now, waiting for it to shut down...") + command_context.set_stage("wait_for_ssh_down") + if not runtime_service.wait_for_tcp_port_state( + acp_host, + 22, + expected_state=False, + log=print, + 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 runtime_service.wait_for_tcp_port_state( + acp_host, + 22, + expected_state=False, + timeout_seconds=30, + log=print, + 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 771d5cc7..f8d77fe2 100644 --- a/src/timecapsulesmb/cli/uninstall.py +++ b/src/timecapsulesmb/cli/uninstall.py @@ -4,28 +4,35 @@ 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.runtime import ( + add_config_argument, + add_mount_wait_argument, + add_no_input_argument, + add_no_wait_argument, + no_input_enabled, + 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 import storage as storage_service +from timecapsulesmb.services.maintenance import UNINSTALL_REBOOT_NO_DOWN_MESSAGE as REBOOT_NO_DOWN_MESSAGE +from timecapsulesmb.services.reboot import RebootFlowError, request_reboot, request_reboot_and_wait +from timecapsulesmb.services.runtime import load_env_config 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") + add_no_input_argument(parser) 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") parser.add_argument("--json", action="store_true", help="Output the dry-run uninstall plan as JSON") @@ -48,6 +55,15 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.set_stage("validate_config") command_context.require_valid_config(profile="uninstall") + if no_input_enabled(args) and not args.yes and not args.no_reboot and not args.dry_run: + command_context.set_stage("noninteractive_confirmation") + message = ( + "Running `uninstall` with reboot in non-interactive mode requires `--yes` " + "to approve the reboot or `--no-reboot` to avoid it." + ) + print(message) + command_context.fail_with_error(message) + return 1 command_context.set_stage("resolve_connection") connection = command_context.resolve_env_connection(allow_empty_password=True) if connection.password: @@ -57,15 +73,22 @@ def main(argv: Optional[list[str]] = None) -> int: volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] else: - mounted_volumes = command_context.mount_mast_volumes( + mounted_volumes = storage_service.mount_mast_volumes_with_diagnostics( connection, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + callbacks=command_context.to_operation_callbacks(), + 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] command_context.update_fields(volume_roots=volume_roots, payload_dirs=payload_dirs) command_context.set_stage("build_uninstall_plan") - plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not args.no_reboot) + plan = build_uninstall_plan( + connection.host, + volume_roots, + payload_dirs, + reboot_after_uninstall=not args.no_reboot, + wait_after_reboot=not args.no_wait, + ) if args.dry_run: if args.json: @@ -97,6 +120,7 @@ def main(argv: Optional[list[str]] = None) -> int: f"This will reboot the {device_name} now. Continue?", default=True, noninteractive_message="Running `uninstall` with reboot requires confirmation when stdin is not interactive. Use `uninstall --yes` to skip the prompt or `uninstall --no-reboot`.", + allow_prompt=not no_input_enabled(args), ) if proceed is None: return 1 @@ -105,11 +129,36 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 - if not request_reboot_and_wait( - connection, - command_context, - reboot_no_down_message=REBOOT_NO_DOWN_MESSAGE, - ): + if args.no_wait: + try: + request_reboot( + connection, + strategy="acp_then_ssh", + callbacks=command_context.to_operation_callbacks(), + raise_on_request_error=True, + ) + except RebootFlowError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) + return 1 + print("Reboot requested; not waiting for the device to go down or come back.") + print("Post-uninstall verification skipped.") + command_context.succeed() + return 0 + + try: + request_reboot_and_wait( + connection, + strategy="acp_then_ssh", + callbacks=command_context.to_operation_callbacks(), + down_timeout_seconds=60, + up_timeout_seconds=240, + reboot_no_down_message=REBOOT_NO_DOWN_MESSAGE, + reboot_up_timeout_message="Timed out waiting for SSH after reboot.", + ) + except RebootFlowError as exc: + print(str(exc)) + command_context.fail_with_error(str(exc)) return 1 command_context.set_stage("verify_post_uninstall") diff --git a/src/timecapsulesmb/cli/validate_install.py b/src/timecapsulesmb/cli/validate_install.py index c6a19558..31cc4a69 100644 --- a/src/timecapsulesmb/cli/validate_install.py +++ b/src/timecapsulesmb/cli/validate_install.py @@ -4,10 +4,11 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import add_config_argument, load_optional_env_config, print_json +from timecapsulesmb.cli.runtime import add_config_argument, print_json from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.install_validation import install_checks_to_jsonable, install_ok, validate_install +from timecapsulesmb.services.runtime import load_optional_env_config from timecapsulesmb.telemetry import TelemetryClient diff --git a/src/timecapsulesmb/configure_defaults.py b/src/timecapsulesmb/configure_defaults.py index aef2d9b0..20b4591a 100644 --- a/src/timecapsulesmb/configure_defaults.py +++ b/src/timecapsulesmb/configure_defaults.py @@ -4,6 +4,7 @@ from timecapsulesmb.core.config import ( CONFIG_VALIDATORS, + DEFAULTS, ) @@ -22,5 +23,5 @@ def validated_value_or_empty(key: str, value: str, label: str) -> str: return value -def valid_existing_config_value(existing: dict[str, str], key: str, label: str) -> str: - return validated_value_or_empty(key, existing.get(key, ""), label) +def existing_config_value_or_default(existing: dict[str, str], key: str, label: str) -> str: + return validated_value_or_empty(key, existing.get(key, ""), label) or DEFAULTS[key] diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 3f4ef68d..d6550d31 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -4,9 +4,11 @@ from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional +import os import re +import tempfile -from timecapsulesmb.core.net import extract_host, ipv4_literal, ipv6_literal, is_link_local_ip +from timecapsulesmb.core.net import ipv4_literal, ipv6_literal, is_link_local_ip, parse_endpoint from timecapsulesmb.core.paths import package_project_root, resolve_app_paths REPO_ROOT = package_project_root() @@ -60,6 +62,7 @@ class AirportDeviceIdentity: "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", "TC_ATA_IDLE_SECONDS": "300", "TC_ATA_STANDBY": "", } @@ -70,10 +73,25 @@ class AirportDeviceIdentity: "TC_SSH_OPTS", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", "TC_ATA_IDLE_SECONDS", "TC_ATA_STANDBY", "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_SHARE_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 @@ -321,11 +339,17 @@ def validate_ssh_target(value: str, field_name: str) -> Optional[str]: return f"{field_name} must not contain whitespace." if "@" not in value: return f"{field_name} must include a username, like {DEFAULT_SSH_TARGET_PLACEHOLDER}" - user, host = value.split("@", 1) + endpoint = parse_endpoint(value) + user = endpoint.user + host = endpoint.host if not user: return f"{field_name} must include a username before @." if not host: return f"{field_name} must include a host after @." + if endpoint.invalid_port: + return f"{field_name} port must be numeric." + if endpoint.port not in (None, 22): + return f"{field_name} only supports the default SSH port 22. Set custom SSH ports in TC_SSH_OPTS." if host.lower() == "192.168.x.x": return ( f"{field_name} IP address is invalid. " @@ -365,6 +389,12 @@ def validate_optional_unsigned_integer(value: str, field_name: str) -> Optional[ return None +def validate_unsigned_integer(value: str, field_name: str) -> Optional[str]: + if not value.isdigit(): + return f"{field_name} must be a non-negative integer." + return None + + def validate_airport_syap(value: str, field_name: str) -> Optional[str]: if not value: return f"{field_name} cannot be blank." @@ -395,7 +425,8 @@ 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_ATA_IDLE_SECONDS": validate_optional_unsigned_integer, + "TC_DEBUG_LOGGING": validate_bool, + "TC_ATA_IDLE_SECONDS": validate_unsigned_integer, "TC_ATA_STANDBY": validate_optional_unsigned_integer, } @@ -412,6 +443,7 @@ class ConfigProfile: CONFIGURE_VALIDATED_KEYS = ( "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", "TC_ATA_IDLE_SECONDS", "TC_ATA_STANDBY", ) @@ -419,6 +451,7 @@ class ConfigProfile: "TC_HOST", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", "TC_ATA_IDLE_SECONDS", "TC_ATA_STANDBY", ) @@ -427,7 +460,6 @@ class ConfigProfile: ) FLASH_REQUIRED_FILE_KEYS = ( "TC_HOST", - "TC_PASSWORD", ) FLASH_VALIDATED_KEYS = ( "TC_HOST", @@ -447,7 +479,8 @@ class ConfigProfile: validated_keys=MANAGED_VALIDATED_KEYS, ), "doctor": ConfigProfile( - required_file_values=(*MANAGED_REQUIRED_FILE_KEYS, "TC_PASSWORD"), + required_file_values=MANAGED_REQUIRED_FILE_KEYS, + required_values=("TC_PASSWORD",), validated_keys=MANAGED_VALIDATED_KEYS, ), "uninstall": ConfigProfile( @@ -462,8 +495,13 @@ 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, + required_values=("TC_PASSWORD",), validated_keys=FLASH_VALIDATED_KEYS, ), "repair_xattrs": ConfigProfile( @@ -511,7 +549,8 @@ def validate_app_config(config: AppConfig, *, profile: str) -> list[ConfigIssue] validator = CONFIG_VALIDATORS.get(key) if validator is None: continue - error = validator(config.get(key, ""), key) + value = config.values[key] if key in config.values else DEFAULTS.get(key, "") + error = validator(value, key) if error: errors.append(ConfigIssue( kind="invalid_value", @@ -545,9 +584,40 @@ 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)) + text = render_env_text(values) + tmp_name: str | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp_name = tmp.name + tmp.write(text) + tmp.flush() + os.fsync(tmp.fileno()) + os.replace(tmp_name, path) + finally: + if tmp_name is not None: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass diff --git a/src/timecapsulesmb/core/net.py b/src/timecapsulesmb/core/net.py index 6eff2a64..662c8718 100644 --- a/src/timecapsulesmb/core/net.py +++ b/src/timecapsulesmb/core/net.py @@ -1,11 +1,93 @@ from __future__ import annotations +from dataclasses import dataclass import ipaddress import socket - - -def extract_host(target: str) -> str: - return target.split("@", 1)[1] if "@" in target else target +from urllib.parse import urlparse + + +@dataclass(frozen=True) +class Endpoint: + raw: str + user: str + host: str + port: int | None = None + invalid_port: str | None = None + + +def parse_endpoint(value: str) -> Endpoint: + raw = value.strip() + user = "" + host = raw + port: int | None = None + invalid_port: str | None = None + + parsed = urlparse(raw) + if parsed.scheme and parsed.hostname: + user = parsed.username or "" + host = parsed.hostname + try: + port = parsed.port + except ValueError: + invalid_port = parsed.netloc.rsplit(":", 1)[-1] + return Endpoint(raw=raw, user=user, host=normalize_endpoint_host(host), port=port, invalid_port=invalid_port) + + candidate = raw.split("/", 1)[0] + if "@" in candidate: + user, candidate = candidate.rsplit("@", 1) + + if candidate.startswith("[") and "]" in candidate: + end = candidate.index("]") + host = candidate[1:end] + suffix = candidate[end + 1:] + if suffix.startswith(":"): + port_text = suffix[1:] + if port_text.isdigit(): + port = int(port_text) + elif port_text: + invalid_port = port_text + elif suffix: + invalid_port = suffix + elif candidate.count(":") == 1: + host_part, port_text = candidate.rsplit(":", 1) + if port_text.isdigit(): + host = host_part + port = int(port_text) + elif port_text: + host = candidate + invalid_port = port_text + else: + host = candidate + + return Endpoint(raw=raw, user=user, host=normalize_endpoint_host(host), port=port, invalid_port=invalid_port) + + +def normalize_endpoint_host(value: str) -> str: + candidate = value.strip().strip("[]") + if not candidate: + return "" + literal = ipv4_literal(candidate) or ipv6_literal(candidate) + if literal is not None: + return literal + return candidate.rstrip(".") + + +def endpoint_host(value: str) -> str: + return parse_endpoint(value).host + + +def canonical_ssh_target(value: str, *, default_user: str = "root") -> str: + endpoint = parse_endpoint(value) + if not endpoint.host: + return "" + if endpoint.invalid_port: + raise ValueError(f"invalid SSH target port: {endpoint.invalid_port}") + if endpoint.port not in (None, 22): + raise ValueError( + f"unsupported SSH target port {endpoint.port}; set a custom SSH port in TC_SSH_OPTS instead" + ) + user = endpoint.user or default_user + return f"{user}@{endpoint.host}" def ipv4_literal(value: str) -> str | None: diff --git a/src/timecapsulesmb/core/paths.py b/src/timecapsulesmb/core/paths.py index 93812726..cc9730f6 100644 --- a/src/timecapsulesmb/core/paths.py +++ b/src/timecapsulesmb/core/paths.py @@ -4,6 +4,7 @@ import json import os import platform +import re from importlib import resources from pathlib import Path @@ -43,14 +44,6 @@ class AppPaths: state_dir: Path package_root: Path - @property - def project_root(self) -> Path: - return self.distribution_root - - @property - def env_path(self) -> Path: - return self.config_path - @property def bootstrap_path(self) -> Path: return self.state_dir / ".bootstrap" @@ -89,6 +82,11 @@ def _resolve_user_path(path: Path | str) -> Path: return Path(path).expanduser().resolve() +def safe_path_part(value: str, *, default: str = "device") -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) + return safe.strip("-.") or default + + def _has_source_checkout_markers(path: Path) -> bool: return ( (path / "bin").is_dir() diff --git a/src/timecapsulesmb/core/release.py b/src/timecapsulesmb/core/release.py index 2c3abe24..99174f19 100644 --- a/src/timecapsulesmb/core/release.py +++ b/src/timecapsulesmb/core/release.py @@ -1,7 +1,7 @@ from __future__ import annotations # Update this version info for each release, including beta releases. -CLI_VERSION = "2.1.7" -RELEASE_TAG = "v2.1.7" -CLI_VERSION_CODE = 20128 +CLI_VERSION = "2.2.0-beta3" +RELEASE_TAG = "v2.2.0-beta3" +CLI_VERSION_CODE = 20203 SAMBA_VERSION = "4.24.1" diff --git a/src/timecapsulesmb/deploy/artifact_resolver.py b/src/timecapsulesmb/deploy/artifact_resolver.py index 8cb0a2ce..628186b6 100644 --- a/src/timecapsulesmb/deploy/artifact_resolver.py +++ b/src/timecapsulesmb/deploy/artifact_resolver.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from pathlib import Path -from timecapsulesmb.deploy.artifacts import ArtifactRecord, load_artifact_manifest +from timecapsulesmb.deploy.artifacts import load_artifact_manifest from timecapsulesmb.device.compat import ( PAYLOAD_FAMILY_NETBSD4BE, PAYLOAD_FAMILY_NETBSD4LE, @@ -24,7 +24,12 @@ def resolve_artifact(distribution_root: Path, name: str) -> ResolvedArtifact: record = manifest.get(name) if record is None: raise KeyError(f"Unknown artifact: {name}") - return resolved_artifact_from_record(distribution_root, record) + return ResolvedArtifact( + name=record.name, + repo_relative_path=record.path, + absolute_path=distribution_root / record.path, + sha256=record.sha256, + ) def resolve_payload_artifacts(distribution_root: Path, payload_family: str) -> dict[str, ResolvedArtifact]: @@ -50,12 +55,3 @@ def resolve_payload_artifacts(distribution_root: Path, payload_family: str) -> d raise KeyError(f"Unknown payload family: {payload_family}") return {logical_name: resolve_artifact(distribution_root, artifact_name) for logical_name, artifact_name in names.items()} - - -def resolved_artifact_from_record(distribution_root: Path, record: ArtifactRecord) -> ResolvedArtifact: - return ResolvedArtifact( - name=record.name, - repo_relative_path=record.path, - absolute_path=distribution_root / record.path, - sha256=record.sha256, - ) diff --git a/src/timecapsulesmb/deploy/commands.py b/src/timecapsulesmb/deploy/commands.py index 93a041c3..a574e83b 100644 --- a/src/timecapsulesmb/deploy/commands.py +++ b/src/timecapsulesmb/deploy/commands.py @@ -2,7 +2,7 @@ import shlex from dataclasses import dataclass -from typing import Iterable, Union +from typing import Union from timecapsulesmb.device.processes import ( render_pkill_wait_pkill9_by_ucomm, @@ -79,21 +79,6 @@ class RunScriptAction: ] -def prepare_dirs_action( - directories: Iterable[str], - recreated_symlinks: Iterable[RemoteSymlink] = (), -) -> RemoteAction: - return PrepareDirsAction(tuple(directories), tuple(recreated_symlinks)) - - -def install_permissions_action(permissions: Iterable[RemotePermission]) -> RemoteAction: - return InstallPermissionsAction(tuple(permissions)) - - -def ensure_volume_mounted_action(volume_root: str, device_path: str, wait_seconds: int) -> RemoteAction: - return EnsureVolumeMountedAction(volume_root, device_path, wait_seconds) - - def _render_prepare_dirs_action(action: PrepareDirsAction) -> str: commands: list[str] = [] if action.directories: diff --git a/src/timecapsulesmb/deploy/dry_run.py b/src/timecapsulesmb/deploy/dry_run.py index c9df9558..7891be8d 100644 --- a/src/timecapsulesmb/deploy/dry_run.py +++ b/src/timecapsulesmb/deploy/dry_run.py @@ -12,27 +12,32 @@ DeploymentPlan, UninstallPlan, ) +from timecapsulesmb.device.probe import NETBSD4_LOGIN_PATH, NETBSD4_LOGIN_RC_LOCAL_MARKER DEPLOY_REBOOT_STRATEGY = "ssh_shutdown_then_reboot" UNINSTALL_REBOOT_STRATEGY = "acp_then_ssh" +NETBSD4_AUTOSTART_MARKER = NETBSD4_LOGIN_RC_LOCAL_MARKER.decode("ascii") -def _append_reboot_request(lines: list[str], reboot_required: bool, *, strategy: str) -> None: +def _append_reboot_request(lines: list[str], reboot_required: bool, *, strategy: str, wait_after_reboot: bool = True) -> None: if not reboot_required: return lines.append(" request: attempt device reboot") lines.append(f" strategy: {strategy}") - lines.append(" follow-up: wait for SSH down, then SSH up") + if wait_after_reboot: + lines.append(" follow-up: wait for SSH down, then SSH up") + else: + lines.append(" follow-up: return immediately after reboot request") -def _add_reboot_request_json(data: dict[str, object], reboot_required: bool, *, strategy: str) -> None: +def _add_reboot_request_json(data: dict[str, object], reboot_required: bool, *, strategy: str, wait_after_reboot: bool = True) -> None: if not reboot_required: return data["reboot_request"] = { "mode": "device_reboot", "strategy": strategy, - "follow_up": ["wait_for_ssh_down", "wait_for_ssh_up"], + "follow_up": ["wait_for_ssh_down", "wait_for_ssh_up"] if wait_after_reboot else ["return_after_reboot_request"], } @@ -40,12 +45,49 @@ def _startup_description(plan: DeploymentPlan) -> str: if plan.startup_mode == DEPLOY_STARTUP_ACTIVATE_NOW: return "stop old managers and wcifsfs, run /mnt/Flash/rc.local now, then verify managed runtime" if plan.startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: - return "reboot, wait for SSH, run /mnt/Flash/rc.local unless startup is already in progress, then verify managed runtime" + if not plan.wait_after_reboot: + return "request reboot and return without post-reboot activation or verification" + return ( + f"reboot, wait for SSH, probe {NETBSD4_LOGIN_PATH} for {NETBSD4_AUTOSTART_MARKER}; " + "if present wait for managed runtime, otherwise run /mnt/Flash/rc.local and verify managed runtime" + ) if plan.startup_mode == DEPLOY_STARTUP_REBOOT_THEN_VERIFY: + if not plan.wait_after_reboot: + return "request reboot and return without post-reboot verification" return "reboot, wait for SSH, then verify managed runtime" return plan.startup_mode +def _post_reboot_activation_probe_json() -> dict[str, object]: + return { + "kind": "netbsd4_rc_local_autostart", + "path": NETBSD4_LOGIN_PATH, + "marker": NETBSD4_AUTOSTART_MARKER, + "if_present": ["skip_post_reboot_start_actions", "verify_managed_runtime"], + "if_missing": ["run_post_reboot_start_actions", "verify_managed_runtime"], + } + + +def _runtime_startup_json(plan: DeploymentPlan) -> dict[str, object]: + data: dict[str, object] = { + "mode": plan.startup_mode, + "description": _startup_description(plan), + "reboot_required": plan.reboot_required, + "wait_after_reboot": plan.wait_after_reboot, + } + if plan.startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE and plan.wait_after_reboot: + data["post_reboot_probe"] = _post_reboot_activation_probe_json() + return data + + +def _activation_plan_probe_json() -> dict[str, object]: + return { + "kind": "managed_runtime_ready", + "if_ready": ["skip_activation_actions"], + "if_not_ready": ["run_activation_actions", "verify_managed_runtime"], + } + + def format_deployment_plan(plan: DeploymentPlan) -> str: lines: list[str] = [] lines.append("Dry run: deployment plan") @@ -72,7 +114,10 @@ def format_deployment_plan(plan: DeploymentPlan) -> str: lines.append(f" {command}") lines.append("") if plan.activation_actions: - lines.append("Remote actions (runtime activation):") + if plan.startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: + lines.append("Remote actions (post-reboot runtime start if firmware autostart is missing):") + else: + lines.append("Remote actions (runtime activation):") for command in render_remote_actions(plan.activation_actions): lines.append(f" {command}") lines.append("") @@ -83,10 +128,12 @@ def format_deployment_plan(plan: DeploymentPlan) -> str: lines.append("") lines.append("Reboot:") lines.append(f" {'yes' if plan.reboot_required else 'no'}") - _append_reboot_request(lines, plan.reboot_required, strategy=DEPLOY_REBOOT_STRATEGY) + _append_reboot_request(lines, plan.reboot_required, strategy=DEPLOY_REBOOT_STRATEGY, wait_after_reboot=plan.wait_after_reboot) if plan.activation_actions: if plan.startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: - lines.append(" follow-up: run /mnt/Flash/rc.local after SSH returns") + lines.append(f" follow-up: probe {NETBSD4_LOGIN_PATH} for {NETBSD4_AUTOSTART_MARKER}") + lines.append(" if present: wait for managed runtime") + lines.append(" if missing: run /mnt/Flash/rc.local, then wait for managed runtime") else: lines.append(" follow-up: run /mnt/Flash/rc.local without rebooting") lines.append("") @@ -107,7 +154,15 @@ def deployment_plan_to_jsonable(plan: DeploymentPlan) -> dict[str, object]: data["pre_upload_actions"] = remote_actions_to_jsonable(plan.pre_upload_actions) data["post_upload_actions"] = remote_actions_to_jsonable(plan.post_upload_actions) data["activation_actions"] = remote_actions_to_jsonable(plan.activation_actions) - _add_reboot_request_json(data, plan.reboot_required, strategy=DEPLOY_REBOOT_STRATEGY) + data["runtime_startup"] = _runtime_startup_json(plan) + _add_reboot_request_json(data, plan.reboot_required, strategy=DEPLOY_REBOOT_STRATEGY, wait_after_reboot=plan.wait_after_reboot) + return data + + +def activation_plan_to_jsonable(plan: ActivationPlan) -> dict[str, object]: + data = asdict(plan) + data["actions"] = remote_actions_to_jsonable(plan.actions) + data["pre_activation_probe"] = _activation_plan_probe_json() return data @@ -120,7 +175,7 @@ def format_activation_plan(plan: ActivationPlan, *, device_name: str = "AirPort lines.append(f" {command}") lines.append("") lines.append("Pre-activation shortcut:") - lines.append(" skip rc.local if NetBSD4 payload is already healthy or startup is already in progress") + lines.append(" probe managed runtime readiness; skip rc.local if the NetBSD4 payload is already healthy") lines.append("") lines.append("Post-activation checks:") for check in plan.post_activation_checks: @@ -156,7 +211,7 @@ def format_uninstall_plan(plan: UninstallPlan) -> str: lines.append("") lines.append("Reboot:") lines.append(f" {'yes' if plan.reboot_required else 'no'}") - _append_reboot_request(lines, plan.reboot_required, strategy=UNINSTALL_REBOOT_STRATEGY) + _append_reboot_request(lines, plan.reboot_required, strategy=UNINSTALL_REBOOT_STRATEGY, wait_after_reboot=plan.wait_after_reboot) lines.append("") lines.append("Post-uninstall checks:") if plan.post_uninstall_checks: @@ -170,5 +225,5 @@ def format_uninstall_plan(plan: UninstallPlan) -> str: def uninstall_plan_to_jsonable(plan: UninstallPlan) -> dict[str, object]: data = asdict(plan) data["remote_actions"] = remote_actions_to_jsonable(plan.remote_actions) - _add_reboot_request_json(data, plan.reboot_required, strategy=UNINSTALL_REBOOT_STRATEGY) + _add_reboot_request_json(data, plan.reboot_required, strategy=UNINSTALL_REBOOT_STRATEGY, wait_after_reboot=plan.wait_after_reboot) return data diff --git a/src/timecapsulesmb/deploy/executor.py b/src/timecapsulesmb/deploy/executor.py index d2b42ee4..547ff8ad 100644 --- a/src/timecapsulesmb/deploy/executor.py +++ b/src/timecapsulesmb/deploy/executor.py @@ -93,11 +93,14 @@ def upload_deployment_payload( *, connection: SshConnection, source_resolver: Mapping[str, Path], + on_uploading: Callable[[FileTransfer], None] | None = None, on_uploaded: Callable[[FileTransfer], None] | None = None, ) -> None: planned_modes = {permission.path: permission.mode for permission in plan.permissions} for transfer in plan.uploads: source = _resolve_transfer_source(source_resolver, transfer) + if on_uploading is not None: + on_uploading(transfer) _ensure_payload_volume_before_transfer(connection, plan, transfer) if transfer.mode in {"scp", "generated"}: _scp_transfer(connection, source, transfer) diff --git a/src/timecapsulesmb/deploy/planner.py b/src/timecapsulesmb/deploy/planner.py index 43038dd1..ec1faeb5 100644 --- a/src/timecapsulesmb/deploy/planner.py +++ b/src/timecapsulesmb/deploy/planner.py @@ -5,6 +5,9 @@ from typing import Literal from timecapsulesmb.deploy.commands import ( + EnsureVolumeMountedAction, + InstallPermissionsAction, + PrepareDirsAction, RemovePathAction, RemoteAction, RemotePermission, @@ -13,9 +16,6 @@ StopManagerAction, StopProcessAction, StopWatchdogAction, - ensure_volume_mounted_action, - install_permissions_action, - prepare_dirs_action, ) from timecapsulesmb.device.storage import PayloadHome @@ -81,6 +81,7 @@ class DeploymentPlan: startup_mode: DeploymentStartupMode activation_actions: list[RemoteAction] reboot_required: bool + wait_after_reboot: bool post_deploy_checks: list[PlannedCheck] apple_mount_wait_seconds: int @@ -100,25 +101,29 @@ class UninstallPlan: verify_absent_targets: list[str] remote_actions: list[RemoteAction] reboot_required: bool + wait_after_reboot: bool post_uninstall_checks: list[PlannedCheck] RUNTIME_ACTIVATION_CHECKS = [ + PlannedCheck("managed_runtime_smbd_binary_present", "managed runtime smbd binary is present"), PlannedCheck("managed_runtime_smb_conf_present", "managed runtime smb.conf is present"), + PlannedCheck("active_smb_conf_passdb_ram", "active smb.conf passdb backend uses RAM smbpasswd"), + PlannedCheck("active_smb_conf_username_map_ram", "active smb.conf username map uses RAM username.map"), + PlannedCheck("active_smb_conf_xattr_tdb_persistent", "active smb.conf xattr_tdb:file is persistent disk storage"), + PlannedCheck("managed_share_volumes_mounted", "all managed share volumes are mounted"), + PlannedCheck("managed_runtime_manager_process", "manager is running for managed runtime"), PlannedCheck("managed_smbd_parent_process", "managed smbd parent process is running"), PlannedCheck("managed_smbd_bound_445", "smbd is bound to required TCP 445 sockets"), PlannedCheck("managed_mdns_takeover_ready", "managed mDNS takeover becomes ready"), + PlannedCheck("managed_mdns_settle_healthy", "mdns-advertiser remains healthy after settle delay"), ] NETBSD4_ACTIVATION_CHECKS = RUNTIME_ACTIVATION_CHECKS NETBSD6_REBOOT_DEPLOY_CHECKS = [ PlannedCheck("ssh_goes_down_after_reboot", "SSH goes down after reboot request"), PlannedCheck("ssh_returns_after_reboot", "SSH returns after reboot"), - PlannedCheck("managed_runtime_smb_conf_present", "managed runtime smb.conf is present"), - PlannedCheck("managed_smbd_parent_process", "managed smbd parent process is running"), - PlannedCheck("managed_smbd_bound_445", "smbd is bound to required TCP 445 sockets"), - PlannedCheck("managed_mdns_takeover_ready", "managed mDNS takeover becomes ready"), - PlannedCheck("authenticated_smb_listing", "authenticated SMB listing"), + *RUNTIME_ACTIVATION_CHECKS, ] REBOOT_THEN_ACTIVATION_CHECKS = [ @@ -161,15 +166,17 @@ def _deploy_reboot_required(startup_mode: DeploymentStartupMode) -> bool: return startup_mode in {DEPLOY_STARTUP_REBOOT_THEN_VERIFY, DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE} -def _deploy_activation_actions(startup_mode: DeploymentStartupMode) -> list[RemoteAction]: +def _deploy_activation_actions(startup_mode: DeploymentStartupMode, *, wait_after_reboot: bool) -> list[RemoteAction]: if startup_mode == DEPLOY_STARTUP_ACTIVATE_NOW: return build_runtime_activation_actions() - if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: + if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE and wait_after_reboot: return build_runtime_start_actions() return [] -def _deploy_post_checks(startup_mode: DeploymentStartupMode) -> list[PlannedCheck]: +def _deploy_post_checks(startup_mode: DeploymentStartupMode, *, wait_after_reboot: bool) -> list[PlannedCheck]: + if startup_mode in {DEPLOY_STARTUP_REBOOT_THEN_VERIFY, DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE} and not wait_after_reboot: + return [] if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_VERIFY: return NETBSD6_REBOOT_DEPLOY_CHECKS if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: @@ -188,9 +195,10 @@ def build_deployment_plan( *, startup_mode: DeploymentStartupMode = DEPLOY_STARTUP_REBOOT_THEN_VERIFY, apple_mount_wait_seconds: int = DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_after_reboot: bool = True, ) -> DeploymentPlan: payload_dir = payload_home.payload_dir - ensure_payload_volume = ensure_volume_mounted_action( + ensure_payload_volume = EnsureVolumeMountedAction( payload_home.volume_root, payload_home.device_path, apple_mount_wait_seconds, @@ -212,6 +220,7 @@ def build_deployment_plan( private_dir = f"{payload_dir}/private" cache_dir = f"{payload_dir}/cache" reboot_required = _deploy_reboot_required(startup_mode) + wait_after_reboot = wait_after_reboot if reboot_required else False remote_directories = [ payload_dir, private_dir, @@ -292,13 +301,14 @@ def build_deployment_plan( ensure_payload_volume, RemovePathAction(f"{private_dir}/nbns.enabled"), ensure_payload_volume, - prepare_dirs_action(remote_directories, legacy_symlinks), + PrepareDirsAction(tuple(remote_directories), tuple(legacy_symlinks)), ], - post_upload_actions=[ensure_payload_volume, install_permissions_action(permissions)], + post_upload_actions=[ensure_payload_volume, InstallPermissionsAction(tuple(permissions))], startup_mode=startup_mode, - activation_actions=_deploy_activation_actions(startup_mode), + activation_actions=_deploy_activation_actions(startup_mode, wait_after_reboot=wait_after_reboot), reboot_required=reboot_required, - post_deploy_checks=_deploy_post_checks(startup_mode), + wait_after_reboot=wait_after_reboot, + post_deploy_checks=_deploy_post_checks(startup_mode, wait_after_reboot=wait_after_reboot), apple_mount_wait_seconds=apple_mount_wait_seconds, ) @@ -320,9 +330,11 @@ def build_uninstall_plan( payload_dirs: list[str], *, reboot_after_uninstall: bool = True, + wait_after_reboot: bool = True, ) -> UninstallPlan: volume_roots = _dedupe_ordered(volume_roots) payload_dirs = _dedupe_ordered(payload_dirs) + wait_after_reboot = wait_after_reboot if reboot_after_uninstall else False flash_targets = { "rc.local": "/mnt/Flash/rc.local", "common.sh": "/mnt/Flash/common.sh", @@ -372,5 +384,6 @@ def build_uninstall_plan( RemovePathAction("/root/tc-netbsd4be"), ], reboot_required=reboot_after_uninstall, - post_uninstall_checks=UNINSTALL_REBOOT_CHECKS if reboot_after_uninstall else [], + wait_after_reboot=wait_after_reboot, + post_uninstall_checks=UNINSTALL_REBOOT_CHECKS if wait_after_reboot else [], ) diff --git a/src/timecapsulesmb/deploy/verify.py b/src/timecapsulesmb/deploy/verify.py index b8450e98..883b4f10 100644 --- a/src/timecapsulesmb/deploy/verify.py +++ b/src/timecapsulesmb/deploy/verify.py @@ -5,7 +5,6 @@ from timecapsulesmb.deploy.planner import UninstallPlan from timecapsulesmb.device.probe import ( ManagedRuntimeProbeResult, - probe_managed_runtime_conn, probe_paths_absent_conn, ) from timecapsulesmb.transport.ssh import SshConnection @@ -20,18 +19,6 @@ def __bool__(self) -> bool: return self.ok -def verify_managed_runtime( - connection: SshConnection, - *, - timeout_seconds: int = 180, -) -> ManagedRuntimeProbeResult: - return probe_managed_runtime_conn(connection, timeout_seconds=timeout_seconds) - - -def managed_runtime_ready(result: ManagedRuntimeProbeResult) -> bool: - return result.ready - - def render_managed_runtime_verification( result: ManagedRuntimeProbeResult, *, @@ -40,13 +27,13 @@ def render_managed_runtime_verification( lines: list[str] = [] if heading: lines.append(heading) - for line in result.lines: - if line.startswith("PASS:"): - lines.append(f" ok: {line.removeprefix('PASS:')}") - elif line.startswith("FAIL:"): - lines.append(f" failed: {line.removeprefix('FAIL:')}") - elif line: - lines.append(f" {line}") + for step in result.steps: + if step.status == "pass": + lines.append(f" ok: {step.detail}") + elif step.status == "skip": + lines.append(f" skipped: {step.detail}") + elif step.detail: + lines.append(f" failed: {step.detail}") return lines diff --git a/src/timecapsulesmb/device/probe.py b/src/timecapsulesmb/device/probe.py index 29447555..0fb047f7 100644 --- a/src/timecapsulesmb/device/probe.py +++ b/src/timecapsulesmb/device/probe.py @@ -11,17 +11,17 @@ from timecapsulesmb.core.smb_config import parse_active_payload_dir from timecapsulesmb.device.compat import compatibility_from_probe_result from timecapsulesmb.device.errors import DeviceError -from timecapsulesmb.device.processes import PROBE_PROCESS_HELPERS +from timecapsulesmb.device.processes import PROBE_PROCESS_HELPERS, PS_CAPTURE_COMMAND from timecapsulesmb.transport.local import tcp_open from timecapsulesmb.transport.errors import TransportError -from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, run_ssh, ssh_opts_use_proxy +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, run_ssh, run_ssh_capture_bytes, ssh_opts_use_proxy from timecapsulesmb.core.config import ( AIRPORT_IDENTITIES_BY_MODEL, AIRPORT_IDENTITIES_BY_SYAP, MAX_DNS_LABEL_BYTES, MAX_NETBIOS_NAME_BYTES, ) -from timecapsulesmb.core.net import extract_host, is_link_local_ipv4, is_loopback_ipv4 +from timecapsulesmb.core.net import endpoint_host, is_link_local_ipv4, is_loopback_ipv4 if TYPE_CHECKING: from timecapsulesmb.device.compat import DeviceCompatibility @@ -34,13 +34,23 @@ REMOTE_STATE_PROBE_TIMEOUT_SECONDS = 10 REMOTE_LOG_TAIL_LINES = 80 -RuntimeActivationProbeState = Literal["ready", "startup_running", "not_ready"] -RUNTIME_ACTIVATION_STATE_READY: RuntimeActivationProbeState = "ready" -RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING: RuntimeActivationProbeState = "startup_running" -RUNTIME_ACTIVATION_STATE_NOT_READY: RuntimeActivationProbeState = "not_ready" REMOTE_LOG_TAIL_MAX_CHARS = 8192 REMOTE_LOG_TAIL_TIMEOUT_SECONDS = 10 REMOTE_NETWORK_DIAGNOSTICS_TIMEOUT_SECONDS = 10 +MDNS_BINARY_PROBE_TIMEOUT_SECONDS = 8 +MDNS_BINARY_PROBE_MIN_TIMEOUT_SECONDS = 5 +MDNS_BINARY_PROBE_ATTEMPTS = 2 +MDNS_PROCESS_TABLE_PROBE_TIMEOUT_SECONDS = 12 +MDNS_SOCKET_FAMILIES_PROBE_TIMEOUT_SECONDS = 24 +MDNS_FSTAT_PROBE_TIMEOUT_SECONDS = 16 +MDNS_READINESS_PROBE_TIMEOUT_SECONDS = ( + MDNS_BINARY_PROBE_TIMEOUT_SECONDS + + MDNS_PROCESS_TABLE_PROBE_TIMEOUT_SECONDS + + MDNS_SOCKET_FAMILIES_PROBE_TIMEOUT_SECONDS + + MDNS_FSTAT_PROBE_TIMEOUT_SECONDS +) +NETBSD4_LOGIN_RC_LOCAL_MARKER = b"/mnt/Flash/rc.local" +NETBSD4_LOGIN_PATH = "/etc/rc.d/LOGIN" REMOTE_RUNTIME_RAM_LOG_PATHS = { "remote_rc_local_log_tail": "/mnt/Memory/samba4/var/rc.local.log", "remote_manager_log_tail": "/mnt/Memory/samba4/var/manager.log", @@ -489,18 +499,38 @@ class RemoteInterfaceCandidate: loopback: bool +ProbeStepStatus = Literal["pass", "fail", "timeout", "skip"] + + @dataclass(frozen=True) -class ManagedSmbdProbeResult: - ready: bool +class ProbeStepResult: + id: str + status: ProbeStepStatus detail: str - lines: tuple[str, ...] = () + timeout_seconds: int | None = None + duration_seconds: float | None = None + stdout: str = "" + stderr: str = "" + returncode: int | None = None + + @property + def line(self) -> str: + if self.status == "pass": + return f"PASS:{self.detail}" + if self.status == "skip": + return f"SKIP:{self.detail}" + return f"FAIL:{self.detail}" @dataclass(frozen=True) -class ManagedMdnsTakeoverProbeResult: +class ReadinessProbeResult: ready: bool detail: str - lines: tuple[str, ...] = () + steps: tuple[ProbeStepResult, ...] = () + + @property + def lines(self) -> tuple[str, ...]: + return tuple(step.line for step in self.steps if step.detail) @dataclass(frozen=True) @@ -515,24 +545,24 @@ class RemoteNetworkCapabilitiesProbeResult: class ManagedRuntimeProbeResult: ready: bool detail: str - smbd: ManagedSmbdProbeResult - mdns: ManagedMdnsTakeoverProbeResult - lines: tuple[str, ...] = () + smbd: ReadinessProbeResult + mdns: ReadinessProbeResult + extra_steps: tuple[ProbeStepResult, ...] = () + @property + def steps(self) -> tuple[ProbeStepResult, ...]: + return self.smbd.steps + self.mdns.steps + self.extra_steps -@dataclass(frozen=True) -class RuntimeStartupScriptsProbeResult: - running: bool - detail: str - lines: tuple[str, ...] = () + @property + def lines(self) -> tuple[str, ...]: + return tuple(step.line for step in self.steps if step.detail) @dataclass(frozen=True) -class RuntimeActivationProbeResult: - state: RuntimeActivationProbeState +class RcLocalAutostartProbeResult: + enabled: bool detail: str - runtime: ManagedRuntimeProbeResult | None = None - startup_scripts: RuntimeStartupScriptsProbeResult | None = None + login_size: int @dataclass(frozen=True) @@ -970,20 +1000,6 @@ def preferred_interface_name( return best.name -def read_interface_ipv4_addrs_conn(connection: SshConnection, iface: str) -> tuple[str, ...]: - probe_cmd = ( - f"/sbin/ifconfig {shlex.quote(iface)} 2>/dev/null | " - "sed -n 's/^[[:space:]]*inet[[:space:]]\\([0-9.]*\\).*/\\1/p' | " - "sed -n '/^$/d;p'" - ) - proc = run_ssh( - connection, - f"/bin/sh -c {shlex.quote(probe_cmd)}", - check=False, - ) - return tuple(line.strip() for line in proc.stdout.splitlines() if line.strip()) - - def read_active_smb_conf_conn( connection: SshConnection, *, @@ -1004,31 +1020,79 @@ def _probe_lines(stdout: str) -> tuple[str, ...]: return tuple(line.strip() for line in stdout.splitlines() if line.strip()) -def _probe_detail(lines: tuple[str, ...], default: str) -> str: - failures = [line.removeprefix("FAIL:") for line in lines if line.startswith("FAIL:")] +def _probe_step_from_line(index: int, line: str) -> ProbeStepResult: + if line.startswith("PASS:"): + return ProbeStepResult(id=f"remote_{index}", status="pass", detail=line.removeprefix("PASS:")) + if line.startswith("FAIL:"): + return ProbeStepResult(id=f"remote_{index}", status="fail", detail=line.removeprefix("FAIL:")) + if line.startswith("SKIP:"): + return ProbeStepResult(id=f"remote_{index}", status="skip", detail=line.removeprefix("SKIP:")) + return ProbeStepResult(id=f"remote_{index}", status="fail", detail=line) + + +def _probe_steps_from_lines(lines: tuple[str, ...]) -> tuple[ProbeStepResult, ...]: + return tuple(_probe_step_from_line(index, line) for index, line in enumerate(lines)) + + +def _probe_detail_from_steps(steps: tuple[ProbeStepResult, ...], default: str) -> str: + failures = [step.detail for step in steps if step.status in {"fail", "timeout"}] if failures: return "; ".join(failures) - passes = [line.removeprefix("PASS:") for line in lines if line.startswith("PASS:")] + passes = [step.detail for step in steps if step.status == "pass"] if passes: return "; ".join(passes) return default -def probe_managed_smbd_conn(connection: SshConnection, *, timeout_seconds: int = 20) -> ManagedSmbdProbeResult: - script = rf''' -{SMBD_STATUS_HELPERS} -if [ ! -x /usr/bin/fstat ]; then - echo "FAIL:fstat missing" - exit 1 -fi -ps_out="$(capture_ps_out)" -out="$(capture_fstat_for_ucomm "$ps_out" smbd)" -status=0 -if ! describe_managed_smbd_status "$ps_out" "$out"; then - status=1 -fi -exit "$status" -''' +def _readiness_result_from_lines( + *, + ready: bool, + lines: tuple[str, ...], + default_detail: str, +) -> ReadinessProbeResult: + steps = _probe_steps_from_lines(lines) + return ReadinessProbeResult( + ready=ready, + detail=_probe_detail_from_steps(steps, default_detail), + steps=steps, + ) + + +def _readiness_result_from_steps( + *, + ready: bool, + steps: list[ProbeStepResult], + default_detail: str, +) -> ReadinessProbeResult: + tuple_steps = tuple(steps) + return ReadinessProbeResult( + ready=ready, + detail=_probe_detail_from_steps(tuple_steps, default_detail), + steps=tuple_steps, + ) + + +def _remaining_probe_timeout( + deadline: float, + default_timeout_seconds: int, + *, + minimum_timeout_seconds: int = 1, +) -> int: + remaining = int(deadline - time.monotonic()) + if remaining <= 0: + return minimum_timeout_seconds + return max(minimum_timeout_seconds, min(default_timeout_seconds, remaining)) + + +def _run_timed_probe_step( + connection: SshConnection, + *, + step_id: str, + timeout_detail: str, + script: str, + timeout_seconds: int, +) -> tuple[ProbeStepResult, subprocess.CompletedProcess[str] | None]: + started = time.monotonic() try: proc = run_ssh( connection, @@ -1037,15 +1101,69 @@ def probe_managed_smbd_conn(connection: SshConnection, *, timeout_seconds: int = timeout=timeout_seconds, ) except SshCommandTimeout: - lines = ("FAIL:managed smbd readiness probe timed out",) - return ManagedSmbdProbeResult(ready=False, detail=_probe_detail(lines, "managed smbd not ready"), lines=lines) - lines = _probe_lines(proc.stdout) - if proc.returncode == 0: - return ManagedSmbdProbeResult(ready=True, detail=_probe_detail(lines, "managed smbd ready"), lines=lines) - return ManagedSmbdProbeResult(ready=False, detail=_probe_detail(lines, "managed smbd not ready"), lines=lines) + return ( + ProbeStepResult( + id=step_id, + status="timeout", + detail=f"{timeout_detail} timed out after {timeout_seconds}s", + timeout_seconds=timeout_seconds, + duration_seconds=time.monotonic() - started, + ), + None, + ) + status: ProbeStepStatus = "pass" if proc.returncode == 0 else "fail" + return ( + ProbeStepResult( + id=step_id, + status=status, + detail=f"{timeout_detail} completed" if status == "pass" else f"{timeout_detail} failed with exit code {proc.returncode}", + timeout_seconds=timeout_seconds, + duration_seconds=time.monotonic() - started, + stdout=proc.stdout or "", + stderr=proc.stderr or "", + returncode=proc.returncode, + ), + proc, + ) + + +def _append_step(steps: list[ProbeStepResult], step_id: str, status: ProbeStepStatus, detail: str) -> None: + steps.append(ProbeStepResult(id=step_id, status=status, detail=detail)) + + +def _parse_live_pids_for_ucomm(ps_out: str, ucomm: str) -> tuple[str, ...]: + pids: list[str] = [] + for raw_line in ps_out.splitlines(): + fields = raw_line.split() + if len(fields) < 5: + continue + pid, _ppid, stat, _time_field, proc_ucomm = fields[:5] + if stat.startswith("Z") or proc_ucomm != ucomm: + continue + if pid.isdigit(): + pids.append(pid) + return tuple(pids) + + +def _process_present_for_ucomm(ps_out: str, ucomm: str) -> bool: + return bool(_parse_live_pids_for_ucomm(ps_out, ucomm)) + + +def _fstat_has_udp_port(fstat_out: str, proc_name: str, family: str, port: int) -> bool: + socket_family = "internet6" if family == "ipv6" else "internet" + needle = f" {socket_family} dgram udp " + port_suffix = f":{port}" + for line in fstat_out.splitlines(): + if proc_name in line and needle in line and port_suffix in line: + return True + return False + + +def _mdns_bound_required_5353(fstat_out: str, families: tuple[str, ...]) -> bool: + return bool(families) and all(_fstat_has_udp_port(fstat_out, "mdns-advertiser", family, 5353) for family in families) -def probe_managed_mdns_takeover_conn(connection: SshConnection, *, timeout_seconds: int = 20) -> ManagedMdnsTakeoverProbeResult: +def probe_managed_smbd_conn(connection: SshConnection, *, timeout_seconds: int = 20) -> ReadinessProbeResult: script = rf''' {SMBD_STATUS_HELPERS} if [ ! -x /usr/bin/fstat ]; then @@ -1053,9 +1171,9 @@ def probe_managed_mdns_takeover_conn(connection: SshConnection, *, timeout_secon exit 1 fi ps_out="$(capture_ps_out)" -out="$(capture_fstat_for_ucomm "$ps_out" mdns-advertiser)" +out="$(capture_fstat_for_ucomm "$ps_out" smbd)" status=0 -if ! describe_managed_mdns_status "$ps_out" "$out"; then +if ! describe_managed_smbd_status "$ps_out" "$out"; then status=1 fi exit "$status" @@ -1068,23 +1186,174 @@ def probe_managed_mdns_takeover_conn(connection: SshConnection, *, timeout_secon timeout=timeout_seconds, ) except SshCommandTimeout: - lines = ("FAIL:managed mDNS takeover probe timed out",) - return ManagedMdnsTakeoverProbeResult( - ready=False, - detail=_probe_detail(lines, "managed mDNS takeover not active"), - lines=lines, - ) + lines = ("FAIL:managed smbd readiness probe timed out",) + return _readiness_result_from_lines(ready=False, lines=lines, default_detail="managed smbd not ready") lines = _probe_lines(proc.stdout) if proc.returncode == 0: - return ManagedMdnsTakeoverProbeResult( - ready=True, - detail=_probe_detail(lines, "managed mDNS takeover active"), - lines=lines, + return _readiness_result_from_lines(ready=True, lines=lines, default_detail="managed smbd ready") + return _readiness_result_from_lines(ready=False, lines=lines, default_detail="managed smbd not ready") + + +def probe_managed_mdns_takeover_conn( + connection: SshConnection, + *, + timeout_seconds: int = MDNS_READINESS_PROBE_TIMEOUT_SECONDS, +) -> ReadinessProbeResult: + steps: list[ProbeStepResult] = [] + deadline = time.monotonic() + timeout_seconds + + binary_script = r''' +RUNTIME_MDNS_BIN=${RUNTIME_MDNS_BIN:-/mnt/Flash/mdns-advertiser} +if [ ! -e "$RUNTIME_MDNS_BIN" ]; then + echo "missing" + exit 2 +fi +if [ ! -x "$RUNTIME_MDNS_BIN" ]; then + echo "not_executable" + exit 3 +fi +echo "$RUNTIME_MDNS_BIN" +''' + binary_step, binary_proc = _run_timed_probe_step( + connection, + step_id="mdns_binary_probe", + timeout_detail="mdns-advertiser binary probe", + script=binary_script, + timeout_seconds=_remaining_probe_timeout( + deadline, + MDNS_BINARY_PROBE_TIMEOUT_SECONDS, + minimum_timeout_seconds=MDNS_BINARY_PROBE_MIN_TIMEOUT_SECONDS, + ), + ) + for _attempt in range(1, MDNS_BINARY_PROBE_ATTEMPTS): + if binary_step.status != "timeout": + break + binary_step, binary_proc = _run_timed_probe_step( + connection, + step_id="mdns_binary_probe", + timeout_detail="mdns-advertiser binary probe", + script=binary_script, + timeout_seconds=_remaining_probe_timeout( + deadline, + MDNS_BINARY_PROBE_TIMEOUT_SECONDS, + minimum_timeout_seconds=MDNS_BINARY_PROBE_MIN_TIMEOUT_SECONDS, + ), ) - return ManagedMdnsTakeoverProbeResult( - ready=False, - detail=_probe_detail(lines, "managed mDNS takeover not active"), - lines=lines, + if binary_step.status == "timeout": + steps.append(binary_step) + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + if binary_proc is None or binary_proc.returncode != 0: + stdout = ("" if binary_proc is None else binary_proc.stdout).strip() + if stdout == "missing": + detail = "mdns-advertiser binary missing at /mnt/Flash/mdns-advertiser" + elif stdout == "not_executable": + detail = "mdns-advertiser binary is not executable at /mnt/Flash/mdns-advertiser" + else: + rc = "unknown" if binary_proc is None else str(binary_proc.returncode) + detail = f"mdns-advertiser binary probe failed with exit code {rc}" + _append_step(steps, "mdns_binary", "fail", detail) + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + _append_step(steps, "mdns_binary", "pass", "mdns-advertiser binary is executable") + + ps_step, ps_proc = _run_timed_probe_step( + connection, + step_id="mdns_process_table_probe", + timeout_detail="mDNS process table probe", + script=PS_CAPTURE_COMMAND, + timeout_seconds=_remaining_probe_timeout(deadline, MDNS_PROCESS_TABLE_PROBE_TIMEOUT_SECONDS), + ) + if ps_step.status == "timeout": + steps.append(ps_step) + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + ps_out = "" if ps_proc is None else ps_proc.stdout + mdns_pids = _parse_live_pids_for_ucomm(ps_out, "mdns-advertiser") + apple_mdns_running = _process_present_for_ucomm(ps_out, "mDNSResponder") + + if mdns_pids: + _append_step(steps, "mdns_process", "pass", "mdns-advertiser process is running") + + families_script = r''' +RUNTIME_MDNS_BIN=${RUNTIME_MDNS_BIN:-/mnt/Flash/mdns-advertiser} +"$RUNTIME_MDNS_BIN" --print-mdns-socket-families +''' + families_step, families_proc = _run_timed_probe_step( + connection, + step_id="mdns_socket_families_probe", + timeout_detail="mdns-advertiser socket family probe", + script=families_script, + timeout_seconds=_remaining_probe_timeout(deadline, MDNS_SOCKET_FAMILIES_PROBE_TIMEOUT_SECONDS), + ) + if families_step.status == "timeout": + steps.append(families_step) + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + + family_rc = 1 if families_proc is None else families_proc.returncode + families_out = "" if families_proc is None else families_proc.stdout + mdns_families = _capability_family_tokens(families_out) + if family_rc == 11: + if mdns_pids: + _append_step(steps, "mdns_auto_ip", "fail", "mdns-advertiser is waiting for a usable address") + else: + _append_step(steps, "mdns_process", "fail", "mDNS startup deferred; no usable address has appeared yet") + if apple_mdns_running: + _append_step(steps, "apple_mdns", "fail", "Apple mDNSResponder is still running") + else: + _append_step(steps, "apple_mdns", "pass", "Apple mDNSResponder is stopped") + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + if family_rc != 0: + _append_step( + steps, + "mdns_socket_families", + "fail", + f"mdns-advertiser mDNS socket family probe failed with exit code {family_rc}", + ) + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + if not mdns_families: + _append_step(steps, "mdns_socket_families", "fail", "mdns-advertiser mDNS socket family probe returned no supported family") + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + _append_step(steps, "mdns_socket_families", "pass", f"mdns-advertiser socket families active: {' '.join(mdns_families)}") + + if not mdns_pids: + _append_step(steps, "mdns_process", "fail", "mdns-advertiser process is not running") + if apple_mdns_running: + _append_step(steps, "apple_mdns", "fail", "Apple mDNSResponder is still running") + else: + _append_step(steps, "apple_mdns", "pass", "Apple mDNSResponder is stopped") + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + + fstat_script = "if [ ! -x /usr/bin/fstat ]; then echo fstat_missing; exit 127; fi; " + " ".join( + f"/usr/bin/fstat -p {pid} 2>/dev/null || true;" for pid in mdns_pids + ) + fstat_step, fstat_proc = _run_timed_probe_step( + connection, + step_id="mdns_fstat_probe", + timeout_detail="mdns-advertiser fstat probe", + script=fstat_script, + timeout_seconds=_remaining_probe_timeout(deadline, MDNS_FSTAT_PROBE_TIMEOUT_SECONDS), + ) + if fstat_step.status == "timeout": + steps.append(fstat_step) + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + if fstat_proc is None or fstat_proc.returncode == 127: + _append_step(steps, "mdns_fstat", "fail", "fstat missing") + return _readiness_result_from_steps(ready=False, steps=steps, default_detail="managed mDNS takeover not active") + fstat_out = "" if fstat_proc is None else fstat_proc.stdout + if _mdns_bound_required_5353(fstat_out, mdns_families): + _append_step(steps, "mdns_udp_5353", "pass", "mdns-advertiser bound to required UDP 5353 listeners") + _append_step(steps, "mdns_bind_address", "pass", "mdns-advertiser bind address active") + else: + _append_step(steps, "mdns_udp_5353", "fail", "mdns-advertiser is not bound to required UDP 5353 listener") + + if apple_mdns_running: + _append_step(steps, "apple_mdns", "fail", "Apple mDNSResponder is still running") + else: + _append_step(steps, "apple_mdns", "pass", "Apple mDNSResponder is stopped") + + ready = all(step.status == "pass" for step in steps) + return _readiness_result_from_steps( + ready=ready, + steps=steps, + default_detail="managed mDNS takeover active" if ready else "managed mDNS takeover not active", ) @@ -1163,85 +1432,27 @@ def probe_remote_network_capabilities_conn(connection: SshConnection, *, timeout ) -def probe_runtime_startup_scripts_conn(connection: SshConnection, *, timeout_seconds: int = 5) -> RuntimeStartupScriptsProbeResult: - script = rf''' -{SMBD_STATUS_HELPERS} -ps_out="$(capture_ps_out)" -if runtime_startup_script_present "$ps_out"; then - echo "PASS:managed runtime startup script is running" - exit 0 -fi -echo "FAIL:managed runtime startup script is not running" -exit 1 -''' - try: - proc = run_ssh( - connection, - f"/bin/sh -c {shlex.quote(script)}", - check=False, - timeout=timeout_seconds, - ) - except SshCommandTimeout: - lines = ("FAIL:managed runtime startup script probe timed out",) - return RuntimeStartupScriptsProbeResult( - running=False, - detail=_probe_detail(lines, "managed runtime startup script not running"), - lines=lines, - ) - lines = _probe_lines(proc.stdout) - if proc.returncode == 0: - return RuntimeStartupScriptsProbeResult( - running=True, - detail=_probe_detail(lines, "managed runtime startup script running"), - lines=lines, - ) - return RuntimeStartupScriptsProbeResult( - running=False, - detail=_probe_detail(lines, "managed runtime startup script not running"), - lines=lines, - ) - - -def probe_runtime_activation_state_conn( +def probe_netbsd4_rc_local_autostart_conn( connection: SshConnection, *, - timeout_seconds: int = 20, -) -> RuntimeActivationProbeResult: - first_startup = probe_runtime_startup_scripts_conn(connection, timeout_seconds=5) - if first_startup.running: - return RuntimeActivationProbeResult( - state=RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING, - detail=first_startup.detail, - startup_scripts=first_startup, - ) - - # rc.local is idempotent, and the startup path normally keeps boot.sh - # visible longer than this preflight window. If a short - # race slips between these checks, running rc.local again is acceptable. - runtime = probe_managed_runtime_conn(connection, timeout_seconds=timeout_seconds) - if runtime.ready: - return RuntimeActivationProbeResult( - state=RUNTIME_ACTIVATION_STATE_READY, - detail=runtime.detail, - runtime=runtime, - startup_scripts=first_startup, - ) - - second_startup = probe_runtime_startup_scripts_conn(connection, timeout_seconds=5) - if second_startup.running: - return RuntimeActivationProbeResult( - state=RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING, - detail=second_startup.detail, - runtime=runtime, - startup_scripts=second_startup, - ) - - return RuntimeActivationProbeResult( - state=RUNTIME_ACTIVATION_STATE_NOT_READY, - detail=f"{runtime.detail}; {second_startup.detail}", - runtime=runtime, - startup_scripts=second_startup, + timeout_seconds: int = 30, +) -> RcLocalAutostartProbeResult: + login = run_ssh_capture_bytes( + connection, + f"/bin/dd if={NETBSD4_LOGIN_PATH} bs=4096 2>/dev/null", + timeout=timeout_seconds, + missing_tool_message=( + "Reading NetBSD4 boot autostart state requires local sshpass. " + "Run `./tcapsule bootstrap` to install sshpass, then rerun `tcapsule deploy`." + ), ) + enabled = NETBSD4_LOGIN_RC_LOCAL_MARKER in login + detail = ( + f"{NETBSD4_LOGIN_PATH} invokes /mnt/Flash/rc.local" + if enabled + else f"{NETBSD4_LOGIN_PATH} does not invoke /mnt/Flash/rc.local" + ) + return RcLocalAutostartProbeResult(enabled=enabled, detail=detail, login_size=len(login)) def probe_managed_runtime_conn( @@ -1253,8 +1464,8 @@ def probe_managed_runtime_conn( mdns_settle_seconds: float = 3.0, ) -> ManagedRuntimeProbeResult: deadline = time.monotonic() + timeout_seconds - last_smbd = ManagedSmbdProbeResult(ready=False, detail="managed smbd not ready") - last_mdns = ManagedMdnsTakeoverProbeResult(ready=False, detail="managed mDNS takeover not active") + last_smbd = ReadinessProbeResult(ready=False, detail="managed smbd not ready") + last_mdns = ReadinessProbeResult(ready=False, detail="managed mDNS takeover not active") smbd_ready = False mdns_ready = False @@ -1268,27 +1479,38 @@ def probe_managed_runtime_conn( if not mdns_ready: if not smbd_ready: time.sleep(smbd_mdns_stagger_seconds) - probe_timeout = max(1, min(20, int(deadline - time.monotonic()))) + probe_timeout = max(1, min(MDNS_READINESS_PROBE_TIMEOUT_SECONDS, int(deadline - time.monotonic()))) last_mdns = probe_managed_mdns_takeover_conn(connection, timeout_seconds=probe_timeout) mdns_ready = last_mdns.ready if smbd_ready and mdns_ready: time.sleep(mdns_settle_seconds) - probe_timeout = max(1, min(20, int(deadline - time.monotonic()))) + probe_timeout = max(1, min(MDNS_READINESS_PROBE_TIMEOUT_SECONDS, int(deadline - time.monotonic()))) settled_mdns = probe_managed_mdns_takeover_conn(connection, timeout_seconds=probe_timeout) if settled_mdns.ready: - lines = last_smbd.lines + settled_mdns.lines + ("PASS:mdns-advertiser remained healthy after settle delay",) return ManagedRuntimeProbeResult( ready=True, detail="managed runtime is ready", smbd=last_smbd, mdns=settled_mdns, - lines=lines, + extra_steps=( + ProbeStepResult( + id="mdns_settle", + status="pass", + detail="mdns-advertiser remained healthy after settle delay", + ), + ), ) - last_mdns = ManagedMdnsTakeoverProbeResult( + last_mdns = ReadinessProbeResult( ready=False, detail=f"{settled_mdns.detail}; mdns-advertiser did not survive settle delay", - lines=settled_mdns.lines + ("FAIL:mdns-advertiser did not remain healthy after settle delay",), + steps=settled_mdns.steps + ( + ProbeStepResult( + id="mdns_settle", + status="fail", + detail="mdns-advertiser did not remain healthy after settle delay", + ), + ), ) mdns_ready = False @@ -1298,13 +1520,18 @@ def probe_managed_runtime_conn( time.sleep(sleep_for) timeout_detail = f"runtime verification timed out after {timeout_seconds}s" - lines = last_smbd.lines + last_mdns.lines + (f"FAIL:{timeout_detail}",) return ManagedRuntimeProbeResult( ready=False, detail=f"{timeout_detail}; {last_smbd.detail}; {last_mdns.detail}", smbd=last_smbd, mdns=last_mdns, - lines=lines, + extra_steps=( + ProbeStepResult( + id="runtime_timeout", + status="fail", + detail=timeout_detail, + ), + ), ) @@ -1325,6 +1552,17 @@ def nbns_flash_config_enabled_conn(connection: SshConnection) -> bool: return proc.stdout.strip() == "enabled" +def flash_runtime_config_present_conn(connection: SshConnection) -> bool: + script = f"[ -f {shlex.quote(FLASH_RUNTIME_CONFIG)} ]" + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=REMOTE_STATE_PROBE_TIMEOUT_SECONDS, + ) + return proc.returncode == 0 + + def read_deployed_version_conn(connection: SshConnection) -> DeployedVersionProbeResult: script = ( f"config={shlex.quote(FLASH_RUNTIME_CONFIG)}; " @@ -1512,35 +1750,6 @@ def _remote_interface_debug_summary(candidates: Iterable[RemoteInterfaceCandidat ] -def _network_failure_hint( - *, - configured_iface: str | None, - candidates: tuple[RemoteInterfaceCandidate, ...], - target_host: str, -) -> str | None: - if not configured_iface: - return None - - configured_candidate = next((candidate for candidate in candidates if candidate.name == configured_iface), None) - if configured_candidate is None: - return f"configured interface {configured_iface} was not reported by ifconfig -a" - if not configured_candidate.ipv4_addrs: - return f"configured interface {configured_iface} has no IPv4 address" - - target_matches = [ - candidate.name - for candidate in candidates - if target_host and target_host in candidate.ipv4_addrs and candidate.name != configured_iface - ] - if target_matches: - return f"SSH target {target_host} is on {','.join(target_matches)}, not configured interface {configured_iface}" - - if target_host.startswith("169.254."): - return f"SSH target {target_host} is link-local; configured interface {configured_iface} has IPv4" - - return None - - def read_remote_network_diagnostics_conn(connection: SshConnection) -> dict[str, object]: script = r''' printf 'TC_DIAG_BEGIN ifconfig_a\n' @@ -1565,7 +1774,7 @@ def read_remote_network_diagnostics_conn(connection: SshConnection) -> dict[str, _values, sections = _parse_remote_diagnostic_sections(proc.stdout or "") all_ifconfig = sections.get("ifconfig_a", "") candidates = _parse_ifconfig_candidates(all_ifconfig) - target_host = extract_host(connection.host) + target_host = endpoint_host(connection.host) target_ip_matches = tuple( candidate for candidate in candidates diff --git a/src/timecapsulesmb/device/processes.py b/src/timecapsulesmb/device/processes.py index 077e0878..d3c49301 100644 --- a/src/timecapsulesmb/device/processes.py +++ b/src/timecapsulesmb/device/processes.py @@ -346,27 +346,6 @@ def render_direct_pkill9_manager() -> str: runtime_script_process_present "$1" "$MANAGER_PATH" } -runtime_startup_script_present() { - ps_out=$1 - while IFS= read -r line; do - [ -n "$line" ] || continue - set -- $line - [ "$#" -ge 6 ] || continue - case "$3" in - Z*) continue ;; - esac - shift 5 - for arg do - case "$arg" in - /mnt/Flash/rc.local|/mnt/Flash/boot.sh) return 0 ;; - esac - done - done < bool: - return ensure_volume_root_mounted_conn( - connection, - volume.volume_root, - volume.device_path, - wait_seconds=wait_seconds, - ) - - def verify_payload_home_conn( connection: SshConnection, payload_home: PayloadHome, @@ -506,7 +492,12 @@ def mounted_mast_volumes_conn( ) -> tuple[MaStVolume, ...]: mounted: list[MaStVolume] = [] for volume in volumes: - if ensure_mast_volume_mounted_conn(connection, volume, wait_seconds=wait_seconds): + if ensure_volume_root_mounted_conn( + connection, + volume.volume_root, + volume.device_path, + wait_seconds=wait_seconds, + ): mounted.append(volume) return tuple(mounted) @@ -566,7 +557,12 @@ def select_payload_home_with_diagnostics_conn( ) -> PayloadHomeSelection: checks: list[PayloadCandidateCheck] = [] for volume in ordered_payload_candidate_volumes(volumes): - mounted = ensure_mast_volume_mounted_conn(connection, volume, wait_seconds=wait_seconds) + mounted = ensure_volume_root_mounted_conn( + connection, + volume.volume_root, + volume.device_path, + wait_seconds=wait_seconds, + ) writable = volume_root_is_writable_conn(connection, volume.volume_root) if mounted else None checks.append(PayloadCandidateCheck(volume, mounted, writable)) if mounted and writable: diff --git a/src/timecapsulesmb/discovery/devices.py b/src/timecapsulesmb/discovery/devices.py new file mode 100644 index 00000000..dfaee288 --- /dev/null +++ b/src/timecapsulesmb/discovery/devices.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass +from typing import Iterable + +from timecapsulesmb.core.config import AIRPORT_SYAP_TO_MODEL +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, +) + +_GENERIC_MDNS_MODELS = frozenset({"AirPort", "TimeCapsule", "Time Capsule"}) + + +@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 "" + syap = _non_empty(record.properties.get("syAP") or record.properties.get("syap")) + model = _candidate_model(_non_empty(record.properties.get("model") or record.properties.get("am")), syap) + 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=syap, + model=model, + service_type=record.service_type or "", + fullname=fullname, + selected_record=record, + ) + + +def _record_has_service(record: BonjourResolvedService, service: str) -> bool: + raw_service = getattr(record, "service_type", "") + if isinstance(raw_service, str) and raw_service.startswith(service): + return True + services = getattr(record, "services", set()) + return isinstance(services, (set, frozenset, list, tuple)) and any( + isinstance(value, str) and value.startswith(service) + for value in services + ) + + +def _candidate_model(model: str | None, syap: str | None) -> str | None: + inferred = AIRPORT_SYAP_TO_MODEL.get(syap or "") + if inferred is not None and (model is None or model in _GENERIC_MDNS_MODELS): + return inferred + return model + + +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/identity.py b/src/timecapsulesmb/identity.py index 50199a98..17966629 100644 --- a/src/timecapsulesmb/identity.py +++ b/src/timecapsulesmb/identity.py @@ -64,3 +64,11 @@ def ensure_install_id(path: Path | None = None) -> str: resolved_path.parent.mkdir(parents=True, exist_ok=True) resolved_path.write_text(render_bootstrap_text(install_id, telemetry_enabled=identity.telemetry_enabled)) return install_id + + +def set_telemetry_enabled(enabled: bool, path: Path | None = None) -> InstallIdentity: + resolved_path = path or default_bootstrap_path() + install_id = ensure_install_id(resolved_path) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + resolved_path.write_text(render_bootstrap_text(install_id, telemetry_enabled=enabled)) + return load_install_identity(resolved_path) diff --git a/src/timecapsulesmb/integrations/acp.py b/src/timecapsulesmb/integrations/acp.py index e5341052..74730ac0 100644 --- a/src/timecapsulesmb/integrations/acp.py +++ b/src/timecapsulesmb/integrations/acp.py @@ -63,6 +63,11 @@ class ACPFlashResult: reply_body: bytes +@dataclass(frozen=True) +class ACPIdentity: + syap: int | None = None + + def _resolve_log(log: LogCallback | None, verbose: bool) -> LogCallback | None: if log is not None: return log @@ -374,6 +379,15 @@ def get_property_int( sock.close() +def read_identity( + host: str, + password: str, + *, + timeout: float = 10.0, +) -> ACPIdentity: + return ACPIdentity(syap=get_property_int(host, password, "syAP", timeout=timeout)) + + def flash_firmware_bank( host: str, password: str, diff --git a/src/timecapsulesmb/repair_xattrs.py b/src/timecapsulesmb/repair_xattrs.py index fb7add0e..1359b96f 100644 --- a/src/timecapsulesmb/repair_xattrs.py +++ b/src/timecapsulesmb/repair_xattrs.py @@ -8,7 +8,7 @@ from urllib.parse import unquote from timecapsulesmb.core.config import AppConfig, validate_app_config -from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.net import endpoint_host DEFAULT_EXCLUDED_DIR_NAMES = { @@ -90,7 +90,7 @@ def run_capture(args: list[str]) -> subprocess.CompletedProcess[str]: def ssh_target_host(target: str) -> str: - return extract_host(target).strip() + return endpoint_host(target).strip() def parse_mounted_smb_shares(mount_output: str) -> list[MountedSmbShare]: diff --git a/src/timecapsulesmb/services/__init__.py b/src/timecapsulesmb/services/__init__.py new file mode 100644 index 00000000..da30ee94 --- /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/acp_ssh.py b/src/timecapsulesmb/services/acp_ssh.py new file mode 100644 index 00000000..a29d1a01 --- /dev/null +++ b/src/timecapsulesmb/services/acp_ssh.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, ACPIdentity, enable_ssh, read_identity +from timecapsulesmb.services.callbacks import OperationCallbacks + + +def read_identity_preflight( + host: str, + password: str, + *, + timeout: float = 10.0, + callbacks: OperationCallbacks | None = None, +) -> ACPIdentity: + callbacks = callbacks or OperationCallbacks() + callbacks.debug(acp_identity_probe_attempted=True) + callbacks.message(f"Reading AirPort identity through ACP on {host}...") + callbacks.stage("acp_identity_probe") + try: + identity = read_identity(host, password, timeout=timeout) + except ACPAuthError: + callbacks.debug( + acp_identity_probe_succeeded=False, + acp_identity_probe_failure="authentication_failed", + ) + raise + except ACPError: + callbacks.debug(acp_identity_probe_succeeded=False) + raise + + fields: dict[str, object] = {"acp_identity_probe_succeeded": True} + if identity.syap is not None: + syap = str(identity.syap) + fields["acp_identity_syap"] = syap + callbacks.update(device_syap=syap) + callbacks.debug(**fields) + return identity + + +def enable_ssh_with_identity_preflight( + host: str, + password: str, + *, + reboot_device: bool = True, + timeout: float = 10.0, + callbacks: OperationCallbacks | None = None, +) -> ACPIdentity: + callbacks = callbacks or OperationCallbacks() + identity = read_identity_preflight(host, password, timeout=timeout, callbacks=callbacks) + callbacks.debug(acp_ssh_enable_attempted=True) + callbacks.message(f"Enabling SSH through ACP on {host}...") + callbacks.stage("acp_enable_ssh") + try: + enable_ssh(host, password, reboot_device=reboot_device, log=callbacks.log, timeout=timeout) + except ACPAuthError: + callbacks.debug( + acp_ssh_enable_succeeded=False, + acp_ssh_enable_failure="authentication_failed", + ) + raise + except ACPError: + callbacks.debug(acp_ssh_enable_succeeded=False) + raise + + callbacks.debug(acp_ssh_enable_succeeded=True) + return identity diff --git a/src/timecapsulesmb/services/activation.py b/src/timecapsulesmb/services/activation.py new file mode 100644 index 00000000..4998f0c9 --- /dev/null +++ b/src/timecapsulesmb/services/activation.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from timecapsulesmb.device.probe import ( + ManagedRuntimeProbeResult, + RcLocalAutostartProbeResult, + probe_managed_runtime_conn, + probe_netbsd4_rc_local_autostart_conn, +) +from timecapsulesmb.transport.ssh import SshConnection + + +ActivationDecisionReason = Literal[ + "runtime_already_ready", + "runtime_not_ready", + "firmware_autostart_enabled", + "firmware_autostart_missing", +] + + +@dataclass(frozen=True) +class ActivationDecision: + run_actions: bool + verify_runtime: bool + reason: ActivationDecisionReason + detail: str + runtime: ManagedRuntimeProbeResult | None = None + autostart: RcLocalAutostartProbeResult | None = None + + +def decide_manual_activation( + connection: SshConnection, + *, + runtime_probe_timeout_seconds: int = 20, +) -> ActivationDecision: + runtime = probe_managed_runtime_conn(connection, timeout_seconds=runtime_probe_timeout_seconds) + if runtime.ready: + return ActivationDecision( + run_actions=False, + verify_runtime=False, + reason="runtime_already_ready", + detail=runtime.detail, + runtime=runtime, + ) + return ActivationDecision( + run_actions=True, + verify_runtime=True, + reason="runtime_not_ready", + detail=runtime.detail, + runtime=runtime, + ) + + +def decide_netbsd4_post_reboot_activation( + connection: SshConnection, + *, + autostart_probe_timeout_seconds: int = 30, +) -> ActivationDecision: + autostart = probe_netbsd4_rc_local_autostart_conn(connection, timeout_seconds=autostart_probe_timeout_seconds) + if autostart.enabled: + return ActivationDecision( + run_actions=False, + verify_runtime=True, + reason="firmware_autostart_enabled", + detail=autostart.detail, + autostart=autostart, + ) + return ActivationDecision( + run_actions=True, + verify_runtime=True, + reason="firmware_autostart_missing", + detail=autostart.detail, + autostart=autostart, + ) diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py new file mode 100644 index 00000000..0296cc1b --- /dev/null +++ b/src/timecapsulesmb/services/app.py @@ -0,0 +1,152 @@ +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 + diagnostic_error: 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 optional_bool_param(params: dict[str, object], name: str) -> bool | None: + if name not in params or params.get(name) in (None, ""): + return None + return bool_param(params, name) + + +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/callbacks.py b/src/timecapsulesmb/services/callbacks.py new file mode 100644 index 00000000..1a597273 --- /dev/null +++ b/src/timecapsulesmb/services/callbacks.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass(frozen=True) +class OperationCallbacks: + """Entrypoint-neutral hooks for long-running service operations.""" + + set_stage: Callable[[str], None] | None = None + log: Callable[[str], None] | None = None + add_debug_fields: Callable[..., None] | None = None + update_fields: Callable[..., None] | None = None + + def stage(self, stage: str) -> None: + if self.set_stage is not None: + self.set_stage(stage) + + def message(self, message: str) -> None: + if self.log is not None: + self.log(message) + + def debug(self, **fields: object) -> None: + if self.add_debug_fields is not None: + self.add_debug_fields(**fields) + + def update(self, **fields: object) -> None: + if self.update_fields is not None: + self.update_fields(**fields) diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py new file mode 100644 index 00000000..cfb62cb8 --- /dev/null +++ b/src/timecapsulesmb/services/configure.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import math +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from timecapsulesmb.configure_defaults import existing_config_value_or_default, validated_value_or_empty +from timecapsulesmb.core.config import ( + DEFAULTS, + CONFIG_VALIDATORS, + infer_mdns_device_model_from_airport_syap, + parse_bool, + preserved_env_file_values, + write_env_file, +) +from timecapsulesmb.core.net import canonical_ssh_target, endpoint_host +from timecapsulesmb.device.compat import DeviceCompatibility, render_compatibility_message +from timecapsulesmb.device.probe import ProbedDeviceState, probe_connection_state +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError +from timecapsulesmb.services.acp_ssh import enable_ssh_with_identity_preflight +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.runtime import ssh_target_link_local_resolution_error, wait_for_tcp_port_state +from timecapsulesmb.transport.ssh import SshConnection + + +class ConfigureFlowError(Exception): + def __init__(self, message: str, *, code: str = "configure_failed") -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class ObservedDeviceIdentity: + syap: str | None + syap_source: str | None + model: str | None + model_source: str | None + + +@dataclass(frozen=True) +class ConfigureFlowRequest: + existing: dict[str, str] + env_path: Path + host: str + password: str + ssh_opts: str + configure_id: str + persist_password: bool + discovered_airport_syap: str | None = None + enable_ssh: bool = True + ssh_wait_timeout: int = 180 + verbose_wait: bool = True + internal_share_use_disk_root: bool | None = None + any_protocol: bool | None = None + debug_logging: bool | None = None + ata_idle_seconds: object | None = None + ata_standby: object | None = None + probe: Callable[[SshConnection], ProbedDeviceState] | None = None + write_env: Callable[[Path, Mapping[str, str]], None] | None = None + infer_model_from_syap: Callable[[str], str | None] = infer_mdns_device_model_from_airport_syap + + +@dataclass(frozen=True) +class ConfigureFlowHooks: + after_probe: Callable[[SshConnection, ProbedDeviceState], None] | None = None + before_enable_ssh: Callable[[SshConnection, ProbedDeviceState], None] | None = None + save_without_authentication: Callable[[ProbedDeviceState], bool] | None = None + + +@dataclass(frozen=True) +class ConfigureFlowResult: + values: dict[str, str] + host: str + configure_id: str + connection: SshConnection + probe_state: ProbedDeviceState + compatibility: DeviceCompatibility | None + identity: ObservedDeviceIdentity + + +def configure_ssh_target( + value: str, + ssh_opts: str, + *, + label: str = "Device SSH target", + validate_config_value: bool = False, +) -> str: + if validate_config_value: + validation_error = CONFIG_VALIDATORS["TC_HOST"](value, label) + if validation_error is not None: + raise ValueError(validation_error) + target = canonical_ssh_target(value) + resolution_error = ssh_target_link_local_resolution_error(target, ssh_opts) + if resolution_error is not None: + raise ValueError(resolution_error) + return target + + +def enable_ssh_and_reprobe( + connection: SshConnection, + *, + timeout_seconds: int = 180, + verbose_wait: bool = True, + callbacks: OperationCallbacks | None = None, + probe: Callable[[SshConnection], ProbedDeviceState] | None = None, +) -> ProbedDeviceState | None: + callbacks = callbacks or OperationCallbacks() + if probe is None: + probe = probe_connection_state + host = endpoint_host(connection.host) + callbacks.debug( + configure_acp_enable_attempted=True, + ssh_initially_reachable=False, + ) + callbacks.message("\nSSH is not reachable. Attempting to enable SSH on the device...") + try: + enable_ssh_with_identity_preflight( + host, + connection.password, + reboot_device=True, + callbacks=callbacks, + ) + except ACPAuthError: + callbacks.debug( + configure_acp_enable_succeeded=False, + configure_retry_reason="acp_authentication_failed", + ) + raise + except ACPError: + callbacks.debug(configure_acp_enable_succeeded=False) + raise + + callbacks.debug(configure_acp_enable_succeeded=True) + callbacks.stage("wait_for_ssh_after_acp") + if not wait_for_tcp_port_state( + host, + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + service_name="SSH port", + log=callbacks.log if verbose_wait else None, + ): + callbacks.update(ssh_final_reachable=False) + return None + + callbacks.update(ssh_final_reachable=True) + callbacks.stage("ssh_probe_after_acp") + return probe(connection) + + +def observed_device_identity( + compatibility: DeviceCompatibility | None, + *, + discovered_airport_syap: str | None = None, + infer_model_from_syap: Callable[[str], str | None] = infer_mdns_device_model_from_airport_syap, +) -> ObservedDeviceIdentity: + syap_source: str | None = "probed" + syap = None if compatibility is None else compatibility.exact_syap + if syap is None: + syap = validated_value_or_empty( + "TC_AIRPORT_SYAP", + discovered_airport_syap or "", + "Airport Utility syAP code", + ) or None + syap_source = "discovered" if syap is not None else None + + model_source: str | None = "probed" + model = None if compatibility is None else compatibility.exact_model + if model is None and syap is not None: + model = infer_model_from_syap(syap) + model_source = "derived" if model is not None else None + elif model is None: + model_source = None + + return ObservedDeviceIdentity( + syap=syap, + syap_source=syap_source, + model=model, + model_source=model_source, + ) + + +def run_configure_flow( + request: ConfigureFlowRequest, + *, + callbacks: OperationCallbacks | None = None, + hooks: ConfigureFlowHooks | None = None, +) -> ConfigureFlowResult: + callbacks = callbacks or OperationCallbacks() + hooks = hooks or ConfigureFlowHooks() + + values = build_configure_env_values( + request.existing, + host=request.host, + password=request.password, + ssh_opts=request.ssh_opts, + configure_id=request.configure_id, + internal_share_use_disk_root=request.internal_share_use_disk_root, + any_protocol=request.any_protocol, + debug_logging=request.debug_logging, + ata_idle_seconds=request.ata_idle_seconds, + ata_standby=request.ata_standby, + ) + + callbacks.stage("ssh_probe") + connection = SshConnection(request.host, request.password, request.ssh_opts) + probe_connection = request.probe or probe_connection_state + probed_state = probe_connection(connection) + if hooks.after_probe is not None: + hooks.after_probe(connection, probed_state) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not request.enable_ssh: + raise ConfigureFlowError("SSH is not reachable and enable_ssh is false.", code="ssh_unreachable") + if hooks.before_enable_ssh is not None: + hooks.before_enable_ssh(connection, probed_state) + probed_state = enable_ssh_and_reprobe( + connection, + timeout_seconds=request.ssh_wait_timeout, + verbose_wait=request.verbose_wait, + callbacks=callbacks, + probe=probe_connection, + ) + if probed_state is None: + raise ConfigureFlowError("SSH did not open after enabling via ACP.", code="ssh_enable_timeout") + if hooks.after_probe is not None: + hooks.after_probe(connection, probed_state) + probe = probed_state.probe_result + if not probe.ssh_port_reachable: + raise ConfigureFlowError("SSH did not become reachable after enabling via ACP.", code="ssh_unreachable") + + if not probe.ssh_authenticated: + callbacks.update(ssh_final_reachable=probe.ssh_port_reachable) + if hooks.save_without_authentication is None or not hooks.save_without_authentication(probed_state): + raise ConfigureFlowError( + probe.error or "The provided AirPort SSH target and password did not work.", + code="auth_failed", + ) + else: + callbacks.debug(ssh_final_reachable=True) + callbacks.update(ssh_final_reachable=True) + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + callbacks.debug(configure_failure_reason="unsupported_device") + raise ConfigureFlowError(render_compatibility_message(compatibility), code="unsupported_device") + + identity = observed_device_identity( + compatibility, + discovered_airport_syap=request.discovered_airport_syap, + infer_model_from_syap=request.infer_model_from_syap, + ) + if identity.syap is not None: + values["TC_AIRPORT_SYAP"] = identity.syap + if identity.model is not None: + values["TC_MDNS_DEVICE_MODEL"] = identity.model + + callbacks.stage("write_env") + request.env_path.parent.mkdir(parents=True, exist_ok=True) + write_configure_env_file( + request.env_path, + values, + persist_password=request.persist_password, + writer=request.write_env or write_env_file, + ) + callbacks.update( + configure_id=request.configure_id, + device_syap=identity.syap, + device_model=identity.model, + ) + + return ConfigureFlowResult( + values=values, + host=request.host, + configure_id=request.configure_id, + connection=connection, + probe_state=probed_state, + compatibility=compatibility, + identity=identity, + ) + + +def _optional_unsigned_config_value(value: object, key: str) -> str: + if value is None: + return "" + if isinstance(value, bool): + raise ValueError(f"{key} must be a non-negative integer") + if isinstance(value, int): + if value < 0: + raise ValueError(f"{key} must be a non-negative integer") + return str(value) + if isinstance(value, float): + if not math.isfinite(value) or not value.is_integer() or value < 0: + raise ValueError(f"{key} must be a non-negative integer") + return str(int(value)) + raw_value = str(value).strip() + if raw_value == "": + return "" + if not raw_value.isdigit(): + raise ValueError(f"{key} must be a non-negative integer") + return str(int(raw_value)) + + +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, + ata_idle_seconds: object | None = None, + ata_standby: object | 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_ATA_IDLE_SECONDS": ( + existing_config_value_or_default(existing, "TC_ATA_IDLE_SECONDS", "ATA idle seconds") + if ata_idle_seconds is None + else _optional_unsigned_config_value(ata_idle_seconds, "TC_ATA_IDLE_SECONDS") + ), + "TC_ATA_STANDBY": ( + existing_config_value_or_default(existing, "TC_ATA_STANDBY", "ATA standby timer") + if ata_standby is None + else _optional_unsigned_config_value(ata_standby, "TC_ATA_STANDBY") + ), + "TC_CONFIGURE_ID": configure_id, + }) + return values + + +def write_configure_env_file( + path: Path, + values: Mapping[str, str], + *, + persist_password: bool, + writer: Callable[[Path, Mapping[str, str]], None] = write_env_file, +) -> None: + output = dict(values) + if not persist_password: + output.pop("TC_PASSWORD", None) + writer(path, output) diff --git a/src/timecapsulesmb/services/context.py b/src/timecapsulesmb/services/context.py new file mode 100644 index 00000000..b7e96051 --- /dev/null +++ b/src/timecapsulesmb/services/context.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING + +from timecapsulesmb.telemetry.debug import debug_summary, render_debug_mapping + +if TYPE_CHECKING: + from timecapsulesmb.core.config import AppConfig + from timecapsulesmb.device.probe import ProbedDeviceState + from timecapsulesmb.transport.ssh import SshConnection + + +COMMAND_VALUE_BLACKLIST = { + "TC_PASSWORD", + # Removed naming keys may still exist in old .env files. They are + # intentionally ignored and should not appear as command inputs. + "TC_SAMBA_USER", + "TC_PAYLOAD_DIR_NAME", + "TC_MDNS_HOST_LABEL", + "TC_MDNS_INSTANCE_NAME", + "TC_NETBIOS_NAME", + # These are already first-class operation fields. + "TC_CONFIGURE_ID", + "TC_MDNS_DEVICE_MODEL", + "TC_AIRPORT_SYAP", +} +COMMAND_FIELD_BLACKLIST = { + # These are already first-class operation fields. + "configure_id", + "device_model", + "device_syap", + "device_os_version", + "device_family", + "nbns_enabled", + "reboot_was_attempted", + "device_came_back_after_reboot", +} + + +def _render_connection_debug_lines(connection: SshConnection | None, values: Mapping[str, str] | None) -> list[str]: + host = None + ssh_opts = None + if connection is not None: + host = connection.host + ssh_opts = connection.ssh_opts + elif values is not None: + host = values.get("TC_HOST") or None + ssh_opts = values.get("TC_SSH_OPTS") or None + lines: list[str] = [] + if host: + lines.append(f"host={host}") + if ssh_opts: + lines.append(f"ssh_opts={ssh_opts}") + return lines + + +def render_operation_debug_lines( + *, + operation_name: str, + stage: str | None, + connection: SshConnection | None, + values: Mapping[str, str] | None, + preflight_error: str | None, + finish_fields: Mapping[str, object], + probe_state: ProbedDeviceState | None, + debug_fields: Mapping[str, object], + config: AppConfig | None = None, +) -> list[str]: + debug_values = config.values if config is not None else values + lines = ["Debug context:", f"command={operation_name}"] + if stage: + lines.append(f"stage={stage}") + if config is not None: + lines.append(f"env_path={config.path}") + lines.extend(_render_connection_debug_lines(connection, debug_values)) + if debug_values is not None: + lines.extend(render_debug_mapping(debug_values, blacklist=COMMAND_VALUE_BLACKLIST)) + if preflight_error: + lines.append(f"preflight_error={preflight_error}") + lines.extend(render_debug_mapping(finish_fields, blacklist=COMMAND_FIELD_BLACKLIST)) + if probe_state is not None: + lines.extend(render_debug_mapping(debug_summary(probe_state), blacklist=COMMAND_FIELD_BLACKLIST)) + lines.extend(render_debug_mapping(debug_fields, blacklist=COMMAND_FIELD_BLACKLIST)) + return lines + + +class OperationContext: + """Shared operation diagnostics used by CLI and app/API entrypoints.""" + + def __init__( + self, + operation_name: str, + *, + values: Mapping[str, str] | None = None, + config: AppConfig | None = None, + ) -> None: + self.operation_name = operation_name + self.values = values + self.config = config + self.finish_fields: dict[str, object] = {} + self.error_lines: list[str] = [] + self.preflight_error: str | None = None + self.debug_stage: str | None = None + self.debug_fields: dict[str, object] = {} + self.connection: SshConnection | None = None + self.probe_state: ProbedDeviceState | None = None + + def update_fields(self, **fields: object) -> None: + for key, value in fields.items(): + if value is not None: + self.finish_fields[key] = value + + def set_stage(self, stage: str) -> None: + self.debug_stage = stage + + def add_debug_fields(self, **fields: object) -> None: + for key, value in fields.items(): + if value is not None: + self.debug_fields[key] = debug_summary(value) + + def set_error(self, message: str) -> None: + self.error_lines = [line.rstrip() for line in message.splitlines() if line.strip()] + + def build_error(self) -> str | None: + if not self.error_lines: + return None + return "\n".join([ + *self.error_lines, + "", + *render_operation_debug_lines( + operation_name=self.operation_name, + stage=self.debug_stage, + connection=self.connection, + values=self.values, + preflight_error=self.preflight_error, + finish_fields=self.finish_fields, + probe_state=self.probe_state, + debug_fields=self.debug_fields, + config=self.config, + ), + ]) diff --git a/src/timecapsulesmb/services/credentials.py b/src/timecapsulesmb/services/credentials.py new file mode 100644 index 00000000..d1c214cc --- /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 00000000..7d9369ca --- /dev/null +++ b/src/timecapsulesmb/services/deploy.py @@ -0,0 +1,993 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +import tempfile + +from timecapsulesmb.core.config import DEFAULTS, MANAGED_PAYLOAD_DIR_NAME, AppConfig, parse_bool, shell_quote +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG +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 as _deployment_plan_to_jsonable, + format_deployment_plan as _format_deployment_plan, +) +from timecapsulesmb.deploy.executor import flush_remote_filesystem_writes, run_remote_actions, upload_deployment_payload +from timecapsulesmb.deploy.commands import RemoteAction, StopProcessAction +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DeploymentPlan, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_BOOT_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_MANAGER_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + FileTransfer, +) +from timecapsulesmb.deploy.planner import ( + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, + DEPLOY_STARTUP_ACTIVATE_NOW, + DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, + DEPLOY_STARTUP_REBOOT_THEN_VERIFY, + DeploymentStartupMode, + build_deployment_plan, +) +from timecapsulesmb.device.compat import ( + DeviceCompatibility, + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, +) +from timecapsulesmb.device.errors import DeviceError +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + MaStDiscoveryResult, + PayloadHome, + PayloadHomeSelection, + PayloadVerificationResult, + build_dry_run_payload_home, + payload_candidate_checks_debug_summary, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, +) +from timecapsulesmb.services import storage as storage_service +from timecapsulesmb.services.activation import decide_netbsd4_post_reboot_activation +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.reboot import RebootFlowError, request_reboot, request_reboot_and_wait +from timecapsulesmb.services.runtime import ManagedTargetState +from timecapsulesmb.services.runtime_verification import verify_managed_runtime_ready +from timecapsulesmb.transport.ssh import SshConnection + + +DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE = ( + "Timed out waiting for SSH after reboot.\n\n" + "The payload was uploaded and the reboot request succeeded, but the device did not accept SSH again " + "before the 4 minute timeout. It may still be booting, or it may have come back with a different IP address.\n\n" + "Next steps:\n" + " 1. Wait a few more minutes.\n" + " 2. If the device is reachable at a new IP, update TC_HOST or rerun configure.\n" + " 3. Make sure you are connected to the same network/wifi as the device.\n" + " 4. On NetBSD 4 devices, run `tcapsule activate` once SSH is reachable; " + "deploy did not get far enough to activate Samba 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." +) +DEPLOY_UPLOAD_BOOT_SOURCES = frozenset({ + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_BOOT_SOURCE, + PACKAGED_MANAGER_SOURCE, +}) +DEPLOY_UPLOAD_ACCOUNT_SOURCES = frozenset({ + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, +}) + + +@dataclass(frozen=True) +class DeployPayloadContext: + compatibility: DeviceCompatibility + payload_family: str + is_netbsd4: bool + startup_mode: DeploymentStartupMode + + +@dataclass(frozen=True) +class DeployArtifactPaths: + smbd: Path + mdns_advertiser: Path + nbns_advertiser: Path + + +@dataclass(frozen=True) +class PreparedDeployPlan: + payload_context: DeployPayloadContext + artifacts: DeployArtifactPaths + payload_home: PayloadHome + plan: DeploymentPlan + + +@dataclass(frozen=True) +class DeployRuntimeConfig: + nbns_enabled: bool + debug_logging: bool | None = None + internal_share_use_disk_root: bool | None = None + any_protocol: bool | None = None + ata_idle_seconds: str | int | None = None + ata_standby: str | int | None = None + + +@dataclass(frozen=True) +class DeployCompletionMessages: + activate_now_message: str = "Starting deployed runtime without reboot." + activate_now_heading: str = "Waiting for managed runtime to finish starting..." + activate_now_failure: str = "Managed runtime activation failed." + post_reboot_activation_message: str = "Activating deployed runtime after reboot." + netbsd4_autostart_message: str = "NetBSD4 firmware autostart is enabled; waiting for managed runtime." + netbsd4_heading: str = "Waiting for managed runtime to finish starting..." + netbsd4_failure: str = "NetBSD4 activation failed." + reboot_request_message: str | None = None + reboot_runtime_wait_message: str | None = None + reboot_heading: str = "Waiting for managed runtime to finish starting..." + reboot_failure: str = "Managed runtime did not become ready after reboot." + + +@dataclass(frozen=True) +class DeployCompletionResult: + payload_dir: str + payload_family: str + is_netbsd4: bool + rebooted: bool + reboot_requested: bool + waited: bool + verified: bool + message: str | None = None + + +@dataclass(frozen=True) +class DeployOptions: + dry_run: bool + no_reboot: bool + no_wait: bool + mount_wait_seconds: int = DEFAULT_APPLE_MOUNT_WAIT_SECONDS + allow_unsupported: bool = False + payload_dir_name: str = MANAGED_PAYLOAD_DIR_NAME + + @property + def effective_no_wait(self) -> bool: + return effective_no_wait_for_deploy(requested=self.no_wait, no_reboot=self.no_reboot) + + +@dataclass(frozen=True) +class DeployPreflight: + payload_context: DeployPayloadContext + artifacts: DeployArtifactPaths + plan: DeploymentPlan + + @property + def payload_family(self) -> str: + return self.payload_context.payload_family + + @property + def is_netbsd4(self) -> bool: + return self.payload_context.is_netbsd4 + + @property + def startup_mode(self) -> DeploymentStartupMode: + return self.payload_context.startup_mode + + @property + def requires_reboot(self) -> bool: + return bool(self.plan.reboot_required) + + +@dataclass(frozen=True) +class DeployServiceDependencies: + validate_artifacts: Callable[..., object] + resolve_payload_artifacts: Callable[..., object] + build_deployment_plan: Callable[..., DeploymentPlan] + wait_for_mast_volumes: Callable[..., MaStDiscoveryResult] + select_payload_home: Callable[..., PayloadHomeSelection] + run_remote_actions: Callable[..., object] + render_flash_config: Callable[..., str] + render_smbpasswd: Callable[..., tuple[str, str]] + boot_asset_path: Callable[..., object] + upload_deployment_payload: Callable[..., object] + verify_payload_home: Callable[..., PayloadVerificationResult] + flush_remote_writes: Callable[..., object] + request_reboot: Callable[..., object] + request_reboot_and_wait: Callable[..., object] + decide_post_reboot_activation: Callable[..., object] + verify_runtime: Callable[..., object] + + +class DeployArtifactValidationError(ValueError): + """Raised when local deploy artifacts fail validation.""" + + +def default_deploy_service_dependencies() -> DeployServiceDependencies: + return DeployServiceDependencies( + validate_artifacts=validate_artifacts, + resolve_payload_artifacts=resolve_payload_artifacts, + build_deployment_plan=build_deployment_plan, + wait_for_mast_volumes=storage_service.wait_for_mast_volumes_conn, + select_payload_home=select_payload_home_with_diagnostics_conn, + run_remote_actions=run_remote_actions, + render_flash_config=render_flash_runtime_config, + render_smbpasswd=render_smbpasswd, + boot_asset_path=boot_asset_path, + upload_deployment_payload=upload_deployment_payload, + verify_payload_home=verify_payload_home_conn, + flush_remote_writes=flush_remote_filesystem_writes, + request_reboot=request_reboot, + request_reboot_and_wait=request_reboot_and_wait, + decide_post_reboot_activation=decide_netbsd4_post_reboot_activation, + verify_runtime=verify_managed_runtime_ready, + ) + + +def _best_effort_debug_summary(render, value: object) -> object | None: + try: + return render(value) + except Exception: + return None + + +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 startup_mode_for_deploy(*, no_reboot: bool, is_netbsd4: bool) -> DeploymentStartupMode: + if no_reboot: + return DEPLOY_STARTUP_ACTIVATE_NOW + if is_netbsd4: + return DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE + return DEPLOY_STARTUP_REBOOT_THEN_VERIFY + + +def activation_complete_message(*, is_netbsd4: bool) -> str: + if is_netbsd4: + return f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}" + return "Runtime activation complete." + + +def effective_no_wait_for_deploy(*, requested: bool, no_reboot: bool) -> bool: + return False if no_reboot else requested + + +def deploy_upload_stage(transfer: FileTransfer) -> str: + if transfer.source_id == BINARY_SMBD_SOURCE: + return "upload_smbd" + if transfer.source_id == BINARY_MDNS_SOURCE: + return "upload_mdns_advertiser" + if transfer.source_id == BINARY_NBNS_SOURCE: + return "upload_nbns_advertiser" + if transfer.source_id in DEPLOY_UPLOAD_BOOT_SOURCES: + return "upload_boot_files" + if transfer.source_id == GENERATED_FLASH_CONFIG_SOURCE: + return "upload_runtime_config" + if transfer.source_id in DEPLOY_UPLOAD_ACCOUNT_SOURCES: + return "upload_samba_accounts" + return "upload_payload" + + +def deploy_artifact_failures(distribution_root, *, validate=validate_artifacts) -> list[str]: + return [message for _, ok, message in validate(distribution_root) if not ok] + + +def deployment_plan_to_jsonable(plan: DeploymentPlan) -> dict[str, object]: + return _deployment_plan_to_jsonable(plan) + + +def format_deployment_plan(plan: DeploymentPlan) -> str: + return _format_deployment_plan(plan) + + +def uploaded_file_message(transfer: FileTransfer) -> str | None: + if transfer.source_id == BINARY_SMBD_SOURCE: + return "Uploaded smbd." + if transfer.source_id == BINARY_MDNS_SOURCE and transfer.mode == "flash_atomic": + return "Uploaded mdns-advertiser." + if transfer.source_id == BINARY_NBNS_SOURCE: + return "Uploaded nbns-advertiser." + if transfer.source_id == PACKAGED_DFREE_SH_SOURCE: + return "Uploaded boot files." + if transfer.source_id == GENERATED_FLASH_CONFIG_SOURCE: + return "Uploaded runtime config." + if transfer.source_id == GENERATED_USERNAME_MAP_SOURCE: + return "Uploaded Samba account files." + return None + + +def pre_upload_action_message(action: RemoteAction) -> str | None: + if isinstance(action, StopProcessAction) and action.name == "nbns-advertiser": + return "Cleaning up previous deployment files..." + return None + + +def resolve_deploy_artifact_paths( + distribution_root, + payload_family: str, + *, + resolver=None, + dependencies: DeployServiceDependencies | None = None, +) -> DeployArtifactPaths: + if resolver is None: + resolver = (dependencies or default_deploy_service_dependencies()).resolve_payload_artifacts + resolved_artifacts = resolver(distribution_root, payload_family) + return DeployArtifactPaths( + smbd=resolved_artifacts["smbd"].absolute_path, + mdns_advertiser=resolved_artifacts["mdns-advertiser"].absolute_path, + nbns_advertiser=resolved_artifacts["nbns-advertiser"].absolute_path, + ) + + +def prepare_deploy_preflight( + connection: SshConnection, + target: ManagedTargetState, + distribution_root, + options: DeployOptions, + *, + callbacks: OperationCallbacks | None = None, + dependencies: DeployServiceDependencies | None = None, +) -> DeployPreflight: + callbacks = callbacks or OperationCallbacks() + dependencies = dependencies or default_deploy_service_dependencies() + + callbacks.stage("validate_artifacts") + failures = deploy_artifact_failures(distribution_root, validate=dependencies.validate_artifacts) + if failures: + raise DeployArtifactValidationError("; ".join(failures)) + + callbacks.stage("check_compatibility") + compatibility = require_supported_payload(target, allow_unsupported=options.allow_unsupported) + payload_context = prepare_deploy_payload_context( + connection, + compatibility, + no_reboot=options.no_reboot, + ) + callbacks.update(deploy_startup_mode=payload_context.startup_mode) + artifacts = resolve_deploy_artifact_paths( + distribution_root, + payload_context.payload_family, + dependencies=dependencies, + ) + plan = dependencies.build_deployment_plan( + connection.host, + build_dry_run_payload_home(options.payload_dir_name), + artifacts.smbd, + artifacts.mdns_advertiser, + artifacts.nbns_advertiser, + startup_mode=payload_context.startup_mode, + apple_mount_wait_seconds=options.mount_wait_seconds, + wait_after_reboot=not options.effective_no_wait, + ) + return DeployPreflight( + payload_context=payload_context, + artifacts=artifacts, + plan=plan, + ) + + +def require_supported_payload(target: ManagedTargetState, *, allow_unsupported: bool) -> DeviceCompatibility: + probe_state = target.probe_state + if probe_state is None: + raise DeviceError("Failed to determine remote device OS compatibility.") + compatibility = probe_state.compatibility + if compatibility is None: + raise DeviceError(probe_state.probe_result.error or "Failed to determine remote device OS compatibility.") + if not compatibility.supported and not allow_unsupported: + raise DeviceError(render_compatibility_message(compatibility)) + if not compatibility.payload_family: + compatibility_message = render_compatibility_message(compatibility) + if compatibility_message: + raise DeviceError( + f"{compatibility_message}\nNo deployable payload is available for this detected device." + ) + raise DeviceError("No deployable payload is available for this detected device.") + return compatibility + + +def prepare_deploy_payload_context( + connection: SshConnection, + compatibility: DeviceCompatibility, + *, + no_reboot: bool, +) -> DeployPayloadContext: + if not compatibility.payload_family: + raise DeviceError("No deployable payload is available for this detected device.") + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + if is_netbsd4: + # Apple NetBSD 4 firmware can expose /usr/bin/scp but hang after + # writing the file. Use the SSH pipe upload fallback consistently. + connection.remote_has_scp = False + return DeployPayloadContext( + compatibility=compatibility, + payload_family=payload_family, + is_netbsd4=is_netbsd4, + startup_mode=startup_mode_for_deploy(no_reboot=no_reboot, is_netbsd4=is_netbsd4), + ) + + +def select_deploy_payload_home( + connection: SshConnection, + *, + dry_run: bool, + payload_dir_name: str, + mount_wait_seconds: int, + callbacks: OperationCallbacks | None = None, + wait_for_mast_volumes: Callable[..., MaStDiscoveryResult] | None = None, + select_payload_home: Callable[..., PayloadHomeSelection] | None = None, +) -> PayloadHome: + callbacks = callbacks or OperationCallbacks() + if dry_run: + return build_dry_run_payload_home(payload_dir_name) + + mast_discovery = storage_service.wait_for_mast_volumes_with_diagnostics( + connection, + callbacks=callbacks, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + wait_for_mast_volumes=wait_for_mast_volumes, + ) + if not mast_discovery.volumes: + raise DeviceError( + no_mast_volumes_message( + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + ) + + callbacks.stage("select_payload_home") + if select_payload_home is None: + select_payload_home = select_payload_home_with_diagnostics_conn + selection = select_payload_home( + connection, + mast_discovery.volumes, + payload_dir_name, + wait_seconds=mount_wait_seconds, + ) + callbacks.debug( + mast_candidate_checks=_best_effort_debug_summary( + payload_candidate_checks_debug_summary, + getattr(selection, "checks", ()), + ), + ) + if selection.payload_home is None: + raise DeviceError(no_writable_mast_volumes_message(len(mast_discovery.volumes))) + return selection.payload_home + + +def prepare_deployment_plan( + connection: SshConnection, + distribution_root, + payload_context: DeployPayloadContext, + *, + dry_run: bool, + payload_dir_name: str, + mount_wait_seconds: int, + wait_after_reboot: bool = True, + callbacks: OperationCallbacks | None = None, + resolver=None, + wait_for_mast_volumes: Callable[..., MaStDiscoveryResult] | None = None, + select_payload_home: Callable[..., PayloadHomeSelection] | None = None, + build_plan=None, + artifacts: DeployArtifactPaths | None = None, + dependencies: DeployServiceDependencies | None = None, +) -> PreparedDeployPlan: + dependencies = dependencies or default_deploy_service_dependencies() + if artifacts is None: + artifacts = resolve_deploy_artifact_paths( + distribution_root, + payload_context.payload_family, + resolver=resolver, + dependencies=dependencies, + ) + payload_home = select_deploy_payload_home( + connection, + dry_run=dry_run, + payload_dir_name=payload_dir_name, + mount_wait_seconds=mount_wait_seconds, + callbacks=callbacks, + wait_for_mast_volumes=wait_for_mast_volumes or dependencies.wait_for_mast_volumes, + select_payload_home=select_payload_home or dependencies.select_payload_home, + ) + if callbacks is not None: + callbacks.stage("build_deployment_plan") + if build_plan is None: + build_plan = dependencies.build_deployment_plan + plan = build_plan( + connection.host, + payload_home, + artifacts.smbd, + artifacts.mdns_advertiser, + artifacts.nbns_advertiser, + startup_mode=payload_context.startup_mode, + apple_mount_wait_seconds=mount_wait_seconds, + wait_after_reboot=wait_after_reboot, + ) + if callbacks is not None: + callbacks.debug( + payload_volume_root=plan.volume_root, + payload_device_path=plan.device_path, + payload_dir=plan.payload_dir, + ) + return PreparedDeployPlan( + payload_context=payload_context, + artifacts=artifacts, + payload_home=payload_home, + plan=plan, + ) + + +def _deployment_upload_sources( + plan: DeploymentPlan, + password: str, + flash_config_text: str, + tmpdir: Path, + boot_assets: ExitStack, + *, + render_smbpasswd_func=None, + boot_asset_path_func=None, + dependencies: DeployServiceDependencies | None = None, +) -> Mapping[str, Path]: + dependencies = dependencies or default_deploy_service_dependencies() + if render_smbpasswd_func is None: + render_smbpasswd_func = dependencies.render_smbpasswd + if boot_asset_path_func is None: + boot_asset_path_func = dependencies.boot_asset_path + 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_func(password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + return { + 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_func("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path_func("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path_func("dfree.sh")), + PACKAGED_BOOT_SOURCE: boot_assets.enter_context(boot_asset_path_func("boot.sh")), + PACKAGED_MANAGER_SOURCE: boot_assets.enter_context(boot_asset_path_func("manager.sh")), + } + + +def _verify_deployed_payload( + callbacks: OperationCallbacks, + connection: SshConnection, + payload_home: PayloadHome, + *, + wait_seconds: int, + post_sync: bool, + verify_payload_home=None, + on_verified: Callable[[PayloadVerificationResult, bool], None] | None = None, + dependencies: DeployServiceDependencies | None = None, +) -> None: + if verify_payload_home is None: + verify_payload_home = (dependencies or default_deploy_service_dependencies()).verify_payload_home + callbacks.stage("verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home(connection, payload_home, wait_seconds=wait_seconds) + callbacks.debug( + **{"payload_post_sync_verification" if post_sync else "payload_upload_verification": verification.detail} + ) + if on_verified is not None: + on_verified(verification, post_sync) + if not verification.ok: + raise DeviceError(payload_verification_error(payload_home, verification)) + + +def upload_and_verify_deployment_payload( + config: AppConfig, + connection: SshConnection, + prepared_plan: PreparedDeployPlan, + runtime_config: DeployRuntimeConfig, + *, + callbacks: OperationCallbacks | None = None, + initial_upload_stage: str | None = "upload_payload", + on_pre_upload_action_done: Callable[[RemoteAction, int, int], None] | None = None, + on_before_upload: Callable[[], None] | None = None, + on_after_upload: Callable[[], None] | None = None, + on_uploaded: Callable[[FileTransfer], None] | None = None, + on_uploading: Callable[[FileTransfer], None] | None = None, + on_before_post_upload_actions: Callable[[], None] | None = None, + on_before_verify: Callable[[bool], None] | None = None, + on_before_flush: Callable[[], None] | None = None, + on_verified: Callable[[PayloadVerificationResult, bool], None] | None = None, + run_remote_actions_func=None, + render_flash_config_func=None, + render_smbpasswd_func=None, + boot_asset_path_func=None, + upload_payload_func=None, + verify_payload_home=None, + flush_remote_writes=None, + dependencies: DeployServiceDependencies | None = None, +) -> None: + callbacks = callbacks or OperationCallbacks() + dependencies = dependencies or default_deploy_service_dependencies() + if run_remote_actions_func is None: + run_remote_actions_func = dependencies.run_remote_actions + if render_flash_config_func is None: + render_flash_config_func = dependencies.render_flash_config + if upload_payload_func is None: + upload_payload_func = dependencies.upload_deployment_payload + if flush_remote_writes is None: + flush_remote_writes = dependencies.flush_remote_writes + plan = prepared_plan.plan + payload_home = prepared_plan.payload_home + + callbacks.stage("pre_upload_actions") + run_remote_actions_func(connection, plan.pre_upload_actions, on_action_done=on_pre_upload_action_done) + callbacks.stage("prepare_deployment_files") + flash_config_text = render_flash_config_func( + config, + payload_home, + nbns_enabled=runtime_config.nbns_enabled, + debug_logging=runtime_config.debug_logging, + internal_share_use_disk_root=runtime_config.internal_share_use_disk_root, + any_protocol=runtime_config.any_protocol, + ata_idle_seconds=runtime_config.ata_idle_seconds, + ata_standby=runtime_config.ata_standby, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + upload_sources = _deployment_upload_sources( + plan, + connection.password, + flash_config_text, + Path(tmp), + boot_assets, + render_smbpasswd_func=render_smbpasswd_func, + boot_asset_path_func=boot_asset_path_func, + dependencies=dependencies, + ) + if initial_upload_stage is not None: + callbacks.stage(initial_upload_stage) + if on_before_upload is not None: + on_before_upload() + upload_kwargs: dict[str, object] = { + "connection": connection, + "source_resolver": upload_sources, + } + if on_uploaded is not None: + upload_kwargs["on_uploaded"] = on_uploaded + if on_uploading is not None: + upload_kwargs["on_uploading"] = on_uploading + upload_payload_func(plan, **upload_kwargs) + if on_after_upload is not None: + on_after_upload() + + callbacks.stage("post_upload_actions") + if on_before_post_upload_actions is not None: + on_before_post_upload_actions() + run_remote_actions_func(connection, plan.post_upload_actions) + if on_before_verify is not None: + on_before_verify(False) + _verify_deployed_payload( + callbacks, + connection, + payload_home, + wait_seconds=plan.apple_mount_wait_seconds, + post_sync=False, + verify_payload_home=verify_payload_home, + on_verified=on_verified, + dependencies=dependencies, + ) + callbacks.stage("flush_payload_upload") + if on_before_flush is not None: + on_before_flush() + flush_remote_writes(connection) + if on_before_verify is not None: + on_before_verify(True) + _verify_deployed_payload( + callbacks, + connection, + payload_home, + wait_seconds=plan.apple_mount_wait_seconds, + post_sync=True, + verify_payload_home=verify_payload_home, + on_verified=on_verified, + dependencies=dependencies, + ) + + +def _run_activation_actions_and_verify( + connection: SshConnection, + activation_actions: list[RemoteAction], + *, + callbacks: OperationCallbacks, + activation_message: str, + activation_stage: str, + verification_stage: str, + verification_timeout_seconds: int, + verification_heading: str, + failure_message: str, + run_remote_actions_func=None, + verify_runtime_func=None, + dependencies: DeployServiceDependencies | None = None, +) -> None: + dependencies = dependencies or default_deploy_service_dependencies() + if run_remote_actions_func is None: + run_remote_actions_func = dependencies.run_remote_actions + if verify_runtime_func is None: + verify_runtime_func = dependencies.verify_runtime + callbacks.stage(activation_stage) + callbacks.message(activation_message) + run_remote_actions_func(connection, activation_actions) + verify_runtime_func( + connection, + callbacks=callbacks, + stage=verification_stage, + timeout_seconds=verification_timeout_seconds, + heading=verification_heading, + failure_message=failure_message, + ) + + +def complete_deployment_after_upload( + connection: SshConnection, + prepared_plan: PreparedDeployPlan, + *, + no_wait: bool, + callbacks: OperationCallbacks | None = None, + messages: DeployCompletionMessages | None = None, + run_remote_actions_func=None, + request_reboot_func=None, + request_reboot_and_wait_func=None, + decide_post_reboot_activation=None, + verify_runtime_func=None, + dependencies: DeployServiceDependencies | None = None, +) -> DeployCompletionResult: + callbacks = callbacks or OperationCallbacks() + messages = messages or DeployCompletionMessages() + dependencies = dependencies or default_deploy_service_dependencies() + if run_remote_actions_func is None: + run_remote_actions_func = dependencies.run_remote_actions + if request_reboot_func is None: + request_reboot_func = dependencies.request_reboot + if request_reboot_and_wait_func is None: + request_reboot_and_wait_func = dependencies.request_reboot_and_wait + if decide_post_reboot_activation is None: + decide_post_reboot_activation = dependencies.decide_post_reboot_activation + if verify_runtime_func is None: + verify_runtime_func = dependencies.verify_runtime + plan = prepared_plan.plan + payload_context = prepared_plan.payload_context + payload_family = payload_context.payload_family + is_netbsd4 = payload_context.is_netbsd4 + startup_mode = payload_context.startup_mode + + if startup_mode == DEPLOY_STARTUP_ACTIVATE_NOW: + _run_activation_actions_and_verify( + connection, + plan.activation_actions, + callbacks=callbacks, + activation_message=messages.activate_now_message, + activation_stage="activate_runtime", + verification_stage="verify_runtime_activation", + verification_timeout_seconds=200, + verification_heading=messages.activate_now_heading, + failure_message=messages.activate_now_failure, + run_remote_actions_func=run_remote_actions_func, + verify_runtime_func=verify_runtime_func, + dependencies=dependencies, + ) + return DeployCompletionResult( + payload_dir=plan.payload_dir, + payload_family=payload_family, + is_netbsd4=is_netbsd4, + rebooted=False, + reboot_requested=False, + waited=False, + verified=True, + message=activation_complete_message(is_netbsd4=is_netbsd4), + ) + + if no_wait: + if messages.reboot_request_message: + callbacks.message(messages.reboot_request_message) + request_reboot_func( + connection, + strategy="ssh_shutdown_then_reboot", + callbacks=callbacks, + raise_on_request_error=True, + ) + return DeployCompletionResult( + payload_dir=plan.payload_dir, + payload_family=payload_family, + is_netbsd4=is_netbsd4, + rebooted=False, + reboot_requested=True, + waited=False, + verified=False, + ) + + if messages.reboot_request_message: + callbacks.message(messages.reboot_request_message) + request_reboot_and_wait_func( + connection, + strategy="ssh_shutdown_then_reboot", + callbacks=callbacks, + down_timeout_seconds=60, + up_timeout_seconds=240, + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + reboot_up_timeout_message=DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, + ) + + if startup_mode == DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE: + callbacks.stage("probe_runtime") + decision = decide_post_reboot_activation(connection) + callbacks.debug( + activation_decision=decision.reason, + manual_activation_required=decision.run_actions, + ) + callbacks.message(decision.detail) + if decision.run_actions: + _run_activation_actions_and_verify( + connection, + plan.activation_actions, + callbacks=callbacks, + activation_message=messages.post_reboot_activation_message, + activation_stage="post_reboot_activation", + verification_stage="verify_runtime_activation", + verification_timeout_seconds=200, + verification_heading=messages.netbsd4_heading, + failure_message=messages.netbsd4_failure, + run_remote_actions_func=run_remote_actions_func, + verify_runtime_func=verify_runtime_func, + dependencies=dependencies, + ) + else: + callbacks.message(messages.netbsd4_autostart_message) + verify_runtime_func( + connection, + callbacks=callbacks, + stage="verify_runtime_activation", + timeout_seconds=200, + heading=messages.netbsd4_heading, + failure_message=messages.netbsd4_failure, + ) + return DeployCompletionResult( + payload_dir=plan.payload_dir, + payload_family=payload_family, + is_netbsd4=True, + rebooted=True, + reboot_requested=True, + waited=True, + verified=True, + message=activation_complete_message(is_netbsd4=is_netbsd4), + ) + + if messages.reboot_runtime_wait_message: + callbacks.message(messages.reboot_runtime_wait_message) + verify_runtime_func( + connection, + callbacks=callbacks, + stage="verify_runtime_reboot", + timeout_seconds=240, + heading=messages.reboot_heading, + failure_message=messages.reboot_failure, + ) + return DeployCompletionResult( + payload_dir=plan.payload_dir, + payload_family=payload_family, + is_netbsd4=is_netbsd4, + rebooted=True, + reboot_requested=True, + waited=True, + verified=True, + ) + + +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 _runtime_unsigned_config_value(config: AppConfig, key: str, default: str) -> str: + raw_value = config.get(key, default).strip() + if raw_value == "": + raw_value = default + if raw_value == "": + return "" + if not raw_value.isdigit(): + raise ValueError(f"{key} must be a non-negative integer") + return str(int(raw_value)) + + +def _runtime_unsigned_override_value(value: str | int) -> str | int: + if isinstance(value, int): + if value < 0: + raise ValueError("runtime setting override must be a non-negative integer") + return value + raw_value = value.strip() + if raw_value == "": + return "" + if not raw_value.isdigit(): + raise ValueError("runtime setting override must be a non-negative integer") + return str(int(raw_value)) + + +def render_flash_runtime_config( + config: AppConfig, + payload_home: PayloadHome, + *, + nbns_enabled: bool, + debug_logging: bool | None = None, + internal_share_use_disk_root: bool | None = None, + any_protocol: bool | None = None, + ata_idle_seconds: str | int | None = None, + ata_standby: str | int | None = None, + 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"]) + runtime_ata_idle_seconds = ( + _runtime_unsigned_config_value(config, "TC_ATA_IDLE_SECONDS", DEFAULTS["TC_ATA_IDLE_SECONDS"]) + if ata_idle_seconds is None + else _runtime_unsigned_override_value(ata_idle_seconds) + ) + runtime_ata_standby = ( + _runtime_unsigned_config_value(config, "TC_ATA_STANDBY", DEFAULTS["TC_ATA_STANDBY"]) + if ata_standby is None + else _runtime_unsigned_override_value(ata_standby) + ) + effective_internal_root = ( + parse_bool(internal_root_default) + if internal_share_use_disk_root is None + else internal_share_use_disk_root + ) + effective_any_protocol = ( + parse_bool(any_protocol_default) + if any_protocol is None + else any_protocol + ) + effective_debug_logging = parse_bool(configured_debug_logging) if debug_logging is None else 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 effective_internal_root else 0), + ("ANY_PROTOCOL", 1 if effective_any_protocol else 0), + ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), + ("ATA_IDLE_SECONDS", runtime_ata_idle_seconds), + ("ATA_STANDBY", runtime_ata_standby), + ("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 00000000..ab21614b --- /dev/null +++ b/src/timecapsulesmb/services/doctor.py @@ -0,0 +1,332 @@ +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 _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 _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 _native_dns_sd_smb_names(native_dns_sd: object) -> list[str]: + 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 [] + + lines: list[str] = [] + expected_summary, expected_instance = _bonjour_expected_summary(results, debug_fields) + if expected_summary: + lines.append(f"INFO expected Bonjour identity: {expected_summary}") + zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") + zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) + if zeroconf_instance_count == 0: + lines.append( + "INFO Python zeroconf discovered 0 Bonjour instances during doctor; " + "mDNS advertiser/discovery path needs investigation" + ) + elif zeroconf_instance_count is not None: + lines.append( + f"INFO Python zeroconf discovered {zeroconf_instance_count} Bonjour instance(s), " + "but no matching _smb._tcp instance" + ) + if _authenticated_smb_listing_passed(debug_fields): + lines.append("INFO SMB works over unicast, but Bonjour discovered no matching _smb._tcp records") + zeroconf_summary = _zeroconf_debug_summary(zeroconf) + if zeroconf_summary: + lines.append(f"INFO Python zeroconf diagnostics: {zeroconf_summary}") + lines.extend(_mdns_transport_context_from_debug(debug_fields)) + lines.extend(_mdns_counter_context_from_debug(debug_fields)) + lines.extend(_native_dns_sd_context_from_debug(debug_fields, expected_instance=expected_instance)) + return lines + + +def _authenticated_smb_listing_passed(debug_fields: Mapping[str, object]) -> bool: + for attempt in _as_sequence(_mapping_value(debug_fields, "authenticated_smb_listing_attempts")): + outcome = _mapping_value(attempt, "outcome") + expected_share_found = _mapping_value(attempt, "expected_share_found") + disk_shares = _as_sequence(_mapping_value(attempt, "disk_shares")) + if outcome == "pass" and (expected_share_found is True or disk_shares): + return True + return False + + +def _debug_scalar_text(value: object) -> str | None: + if value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + return None + + +def _debug_summary_fields(value: object, keys: tuple[str, ...]) -> str: + parts: list[str] = [] + for key in keys: + text = _debug_scalar_text(_mapping_value(value, key)) + if text is not None: + parts.append(f"{key}={text}") + return " ".join(parts) + + +def _bonjour_expected_summary( + results: list[CheckResult], + debug_fields: Mapping[str, object], +) -> tuple[str, str | None]: + expected = _mapping_value(debug_fields, "bonjour_expected") + instance = _mapping_value(expected, "instance_name") + if not isinstance(instance, str) or not instance: + instance = _expected_bonjour_instance_from_results(results) + host_label = _mapping_value(expected, "host_label") + target_ip = _mapping_value(expected, "target_ip") + parts: list[str] = [] + if isinstance(instance, str) and instance: + parts.append(f"instance_name={instance!r}") + if isinstance(host_label, str) and host_label: + parts.append(f"host_label={host_label!r}") + if isinstance(target_ip, str) and target_ip: + parts.append(f"target_ip={target_ip!r}") + return " ".join(parts), instance if isinstance(instance, str) and instance else None + + +def _zeroconf_debug_summary(zeroconf: object) -> str: + return _debug_summary_fields( + zeroconf, + ( + "ip_version", + "zeroconf_interfaces", + "instance_count", + "resolved_count", + "service_event_count", + "ptr_record_count", + "resolve_attempt_count", + "resolve_success_count", + "resolve_error_count", + ), + ) + + +def _native_dns_sd_context_from_debug( + debug_fields: Mapping[str, object], + *, + expected_instance: str | None, +) -> list[str]: + lines: list[str] = [] + native_error = _mapping_value(debug_fields, "bonjour_native_dns_sd_error") + if isinstance(native_error, str) and native_error: + lines.append(f"INFO native dns-sd diagnostic error: {native_error}") + + native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") + summary = _debug_summary_fields(native_dns_sd, ("status", "timeout_sec", "elapsed_sec")) + if summary: + lines.append(f"INFO native dns-sd diagnostics: {summary}") + names = _native_dns_sd_smb_names(native_dns_sd) + if names: + names_text = ", ".join(repr(name) for name in names) + lines.append(f"INFO native dns-sd observed _smb._tcp instances: {names_text}") + else: + lines.append("INFO native dns-sd observed 0 _smb._tcp Add events") + if expected_instance is not None: + matched = "yes" if expected_instance in names else "no" + lines.append(f"INFO native dns-sd observed expected _smb._tcp instance: {matched}") + return lines + + +def _mdns_transport_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + if not isinstance(mdns_log, str): + return [] + transport = _last_regex_group(r"mdns transport active: ([^\n]+)", mdns_log) + if not transport: + return [] + return [f"INFO mdns-advertiser transport state: {transport}"] + + +def _mdns_counter_context_from_debug(debug_fields: Mapping[str, object]) -> list[str]: + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + if not isinstance(mdns_log, str): + return [] + counters = _last_regex_group(r"mdns counters: ([^\n]+)", mdns_log) + if not counters: + return [] + return [f"INFO mdns-advertiser counters: {counters}"] + + +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/flash.py b/src/timecapsulesmb/services/flash.py new file mode 100644 index 00000000..8b4dfd09 --- /dev/null +++ b/src/timecapsulesmb/services/flash.py @@ -0,0 +1,677 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import partial +import json +from pathlib import Path +from typing import Callable + +from timecapsulesmb.apple_firmware import normalize_syap +from timecapsulesmb.core.config import AIRPORT_IDENTITIES_BY_SYAP +from timecapsulesmb.core.net import endpoint_host +from timecapsulesmb.core.paths import default_user_data_dir, safe_path_part +from timecapsulesmb.device.compat import DeviceCompatibility, is_netbsd4_payload_family, payload_family_description +from timecapsulesmb.device.errors import DeviceError +from timecapsulesmb.flash import ( + FlashAnalysis, + FlashAnalysisError, + FlashInspection, + inspection_error_message, + inspection_to_jsonable, + inspect_flash_banks, + require_zopfli_gzip_available, + sha256_hex, +) +from timecapsulesmb.flash_workflow import ( + FlashPlan, + plan_check_apple, + plan_download_only, + plan_patch_primary, + plan_restore_apple, + write_and_validate_plan, +) +from timecapsulesmb.integrations.acp import ACPError, flash_firmware_bank, get_property_int +from timecapsulesmb.transport.ssh import SshConnection, run_ssh_capture_bytes + + +FLASH_READ_TIMEOUT_SECONDS = 180 +FLASH_WRITE_TIMEOUT_SECONDS = 300 +WRITE_OPERATIONS = {"patch", "restore"} +READ_OPERATIONS = {"read_only", "patch", "restore", "check_apple", "download_only"} +POWERCYCLE_REQUIRED_MESSAGE = ( + "POWER-CYCLE REQUIRED: unplug the device, wait 10 seconds, then plug it back in." +) +STALE_BACKUP_AFTER_WRITE_MESSAGE = ( + "This flash backup was used for a firmware write. Back up and inspect again before planning another flash action." +) +FLASH_UNSUPPORTED_DEVICE_MESSAGE = ( + "flash is only supported for NetBSD4 AirPort storage devices. " + "If your device should be supported, please add details to " + "https://github.com/jamesyc/TimeCapsuleSMB/issues/160." +) + + +@dataclass(frozen=True) +class FlashTarget: + connection: SshConnection + acp_host: str + compatibility: DeviceCompatibility + + +@dataclass(frozen=True) +class FlashInputs: + primary: bytes + secondary: bytes + cks1: int | None + cks2: int | None + syap: str + live_login: bytes + + +@dataclass(frozen=True) +class FlashAnalysisBundle: + inspection: FlashInspection + analysis: FlashAnalysis | None + backup_dir: Path + manifest: dict[str, object] + + +def _emit(log: object | None, message: str) -> None: + if log is None: + return + log(message) # type: ignore[misc] + + +def default_flash_backup_root() -> Path: + return default_user_data_dir() / "flash-backups" + + +def build_flash_backup_dir(*, base_dir: Path | None, host: str, syap: str) -> Path: + if base_dir is not None: + return base_dir.expanduser().resolve() + root = default_flash_backup_root() + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%fZ") + return root / f"{timestamp}-{safe_path_part(host)}-syAP{safe_path_part(syap)}" + + +def require_netbsd4_flash_target( + connection: SshConnection, + compatibility: DeviceCompatibility, + *, + update_fields: Callable[..., None] | None = None, + log: Callable[[str], None] | None = None, + unsupported_message: str = FLASH_UNSUPPORTED_DEVICE_MESSAGE, +) -> FlashTarget: + if update_fields is not None: + update_fields(device_family=compatibility.payload_family) + if not is_netbsd4_payload_family(compatibility.payload_family): + raise DeviceError(unsupported_message) + if log is not None: + log(f"Using {payload_family_description(compatibility.payload_family)} payload family for flash work.") + return FlashTarget( + connection=connection, + acp_host=endpoint_host(connection.host), + compatibility=compatibility, + ) + + +def dump_remote_bank(connection: SshConnection, device: str, *, log: object | None = None) -> bytes: + _emit(log, f"SSH: /bin/dd if={device} bs=65536 2>/dev/null") + return run_ssh_capture_bytes( + connection, + f"/bin/dd if={device} bs=65536 2>/dev/null", + timeout=FLASH_READ_TIMEOUT_SECONDS, + ) + + +def read_live_login(connection: SshConnection, *, log: object | None = None) -> bytes: + _emit(log, "SSH: /bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null") + return run_ssh_capture_bytes(connection, "/bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null", timeout=30) + + +def read_acp_property_int(acp_host: str, password: str, name: str) -> int: + try: + return get_property_int(acp_host, password, name) + except ACPError as exc: + raise FlashAnalysisError(f"ACP property {name} read failed: {exc}") from exc + + +def read_flash_inputs( + connection: SshConnection, + *, + acp_host: str, + password: str, + log: object | None = None, +) -> FlashInputs: + _emit(log, "Reading primary firmware bank from /dev/rflash0.raw...") + primary = dump_remote_bank(connection, "/dev/rflash0.raw", log=log) + _emit(log, "Reading secondary firmware bank from /dev/rflash1.raw...") + secondary = dump_remote_bank(connection, "/dev/rflash1.raw", log=log) + _emit(log, "Reading ACP checksum properties cks1 and cks2...") + cks1 = read_acp_property_int(acp_host, password, "cks1") + cks2 = read_acp_property_int(acp_host, password, "cks2") + _emit(log, "Reading ACP product property syAP...") + syap = normalize_syap(read_acp_property_int(acp_host, password, "syAP")) + _emit(log, "Reading live /etc/rc.d/LOGIN...") + live_login = read_live_login(connection, log=log) + return FlashInputs(primary=primary, secondary=secondary, cks1=cks1, cks2=cks2, syap=syap, live_login=live_login) + + +def dump_remote_bank_for_validation(connection: SshConnection, device: str, *, log: object | None = None) -> bytes: + _emit(log, f"Reading back written firmware bank from {device}...") + return dump_remote_bank(connection, device, log=log) + + +def get_property_int_for_validation( + host: str, + password: str, + name: str, + *, + log: object | None = None, + **kwargs: object, +) -> int: + _emit(log, f"Reading ACP checksum property {name} after write...") + return get_property_int(host, password, name, **kwargs) + + +def _mark_manifest_no_write(manifest: dict[str, object], decision: str) -> None: + banks = manifest.get("banks") + if not isinstance(banks, list): + return + for bank in banks: + if isinstance(bank, dict): + bank["would_write"] = False + bank["write_decision"] = decision + + +def _manifest_banks(manifest: dict[str, object]) -> list[dict[str, object]]: + banks = manifest.get("banks") + if not isinstance(banks, list): + return [] + return [bank for bank in banks if isinstance(bank, dict)] + + +def apply_flash_plan_to_manifest(manifest: dict[str, object], plan: FlashPlan) -> None: + target_name = None if plan.target_bank is None else plan.target_bank.name + for bank in _manifest_banks(manifest): + if bank.get("name") != target_name: + bank["would_write"] = False + if target_name is not None and plan.mode == "patch": + bank["write_decision"] = "secondary backup left unmodified" + elif target_name is not None: + bank["write_decision"] = "inactive bank left unmodified" + continue + + bank["would_write"] = plan.write_requested + if plan.mode == "patch": + if plan.already_satisfied: + bank["write_decision"] = "primary bank already patched; no write needed" + elif plan.write_requested: + bank["write_decision"] = "primary bank patch planned" + elif plan.mode == "restore": + if plan.write_requested: + bank["write_decision"] = "active bank restore from Apple firmware planned" + else: + bank["write_decision"] = "active bank already matches requested Apple stock firmware; no write needed" + elif plan.mode == "check_apple": + bank["write_decision"] = "check only; no firmware write planned" + elif plan.mode == "download_only": + bank["write_decision"] = "download only; no firmware write planned" + + +def manifest_from_inspection( + *, + operation: str, + inspection: FlashInspection, + target: FlashTarget, + inputs: FlashInputs, + backup_dir: Path, +) -> dict[str, object]: + payload = inspection_to_jsonable( + inspection, + write_policy="primary_bank_patch" if operation == "patch" else "active_bank_only", + ) + if operation != "patch": + _mark_manifest_no_write(payload, "backup only; no patch candidate built") + identity = AIRPORT_IDENTITIES_BY_SYAP.get(inputs.syap) + files: dict[str, str] = { + "primary": str(backup_dir / "primary.raw"), + "secondary": str(backup_dir / "secondary.raw"), + "manifest": str(backup_dir / "manifest.json"), + } + payload.update({ + "operation": operation, + "host": target.acp_host, + "syap": inputs.syap, + "device_model": None if identity is None else identity.mdns_model, + "os_release": target.compatibility.os_release, + "backup_dir": str(backup_dir), + "files": files, + "acp_properties": { + "cks1": inputs.cks1, + "cks2": inputs.cks2, + }, + "live_login": { + "size": len(inputs.live_login), + "sha256": sha256_hex(inputs.live_login), + }, + }) + return payload + + +def save_flash_banks(*, backup_dir: Path, primary: bytes, secondary: bytes) -> None: + backup_dir.mkdir(parents=True, exist_ok=True) + (backup_dir / "primary.raw").write_bytes(primary) + (backup_dir / "secondary.raw").write_bytes(secondary) + + +def save_flash_manifest(*, backup_dir: Path, manifest: dict[str, object]) -> None: + backup_dir.mkdir(parents=True, exist_ok=True) + (backup_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + + +def load_flash_manifest(backup_dir: Path) -> dict[str, object]: + manifest_path = backup_dir.expanduser().resolve() / "manifest.json" + try: + data = json.loads(manifest_path.read_text()) + except OSError as exc: + raise FlashAnalysisError(f"flash manifest not found: {manifest_path}") from exc + except json.JSONDecodeError as exc: + raise FlashAnalysisError(f"flash manifest is not valid JSON: {manifest_path}") from exc + if not isinstance(data, dict): + raise FlashAnalysisError(f"flash manifest is not an object: {manifest_path}") + return data + + +def _manifest_file_path(manifest: dict[str, object], backup_dir: Path, name: str) -> Path: + files = manifest.get("files") + if isinstance(files, dict) and isinstance(files.get(name), str): + return Path(str(files[name])).expanduser().resolve() + return backup_dir / f"{name}.raw" + + +def _parse_optional_int(value: object) -> int | None: + if value in (None, ""): + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + text = value.strip() + if not text: + return None + return int(text, 16) if text.lower().startswith("0x") else int(text, 10) + return None + + +def _manifest_acp_checksum(manifest: dict[str, object], name: str) -> int | None: + properties = manifest.get("acp_properties") + if isinstance(properties, dict): + value = properties.get("cks1" if name == "primary" else "cks2") + parsed = _parse_optional_int(value) + if parsed is not None: + return parsed + for bank in _manifest_banks(manifest): + if bank.get("name") == name: + return _parse_optional_int(bank.get("acp_checksum")) + return None + + +def _read_backup_raw(backup_dir: Path, manifest: dict[str, object]) -> tuple[bytes, bytes]: + try: + primary = _manifest_file_path(manifest, backup_dir, "primary").read_bytes() + secondary = _manifest_file_path(manifest, backup_dir, "secondary").read_bytes() + except OSError as exc: + raise FlashAnalysisError(f"flash backup raw bank file could not be read: {exc}") from exc + return primary, secondary + + +def _backup_syap(manifest: dict[str, object]) -> str: + syap = str(manifest.get("syap") or "").strip() + if not syap: + raise FlashAnalysisError("flash manifest is missing syAP") + return normalize_syap(syap) + + +def _backup_os_release(manifest: dict[str, object]) -> str: + os_release = str(manifest.get("os_release") or "").strip() + if not os_release: + raise FlashAnalysisError("flash manifest is missing os_release") + return os_release + + +def _backup_was_used_for_write(manifest: dict[str, object]) -> bool: + outcome = manifest.get("write_outcome") + if not isinstance(outcome, dict): + return False + return bool(outcome.get("write_may_have_modified_device")) + + +def require_backup_fresh_for_plan(manifest: dict[str, object]) -> None: + if _backup_was_used_for_write(manifest): + raise FlashAnalysisError(STALE_BACKUP_AFTER_WRITE_MESSAGE) + + +def inspect_backup( + backup_dir: Path, + *, + operation: str, +) -> FlashAnalysisBundle: + if operation not in READ_OPERATIONS: + raise FlashAnalysisError(f"unsupported flash operation: {operation}") + backup_dir = backup_dir.expanduser().resolve() + manifest = load_flash_manifest(backup_dir) + if operation != "read_only": + require_backup_fresh_for_plan(manifest) + primary, secondary = _read_backup_raw(backup_dir, manifest) + inspection = inspect_flash_banks( + primary_data=primary, + secondary_data=secondary, + cks1=_manifest_acp_checksum(manifest, "primary"), + cks2=_manifest_acp_checksum(manifest, "secondary"), + os_release=_backup_os_release(manifest), + build_primary_patch_candidate=operation == "patch", + ) + return FlashAnalysisBundle( + inspection=inspection, + analysis=inspection.strict_analysis, + backup_dir=backup_dir, + manifest=manifest, + ) + + +def backup_flash( + *, + target: FlashTarget, + backup_dir: Path | None, + operation: str = "read_only", + log: object | None = None, + stage: Callable[[str], None] | None = None, +) -> FlashAnalysisBundle: + if stage is not None: + stage("read_flash") + inputs = read_flash_inputs( + target.connection, + acp_host=target.acp_host, + password=target.connection.password, + log=log, + ) + resolved_backup_dir = build_flash_backup_dir(base_dir=backup_dir, host=target.acp_host, syap=inputs.syap) + if stage is not None: + stage("save_raw_backup") + save_flash_banks(backup_dir=resolved_backup_dir, primary=inputs.primary, secondary=inputs.secondary) + if stage is not None: + stage("analyze_flash") + inspection = inspect_flash_banks( + primary_data=inputs.primary, + secondary_data=inputs.secondary, + cks1=inputs.cks1, + cks2=inputs.cks2, + os_release=target.compatibility.os_release, + build_primary_patch_candidate=operation == "patch", + ) + manifest = manifest_from_inspection( + operation=operation, + inspection=inspection, + target=target, + inputs=inputs, + backup_dir=resolved_backup_dir, + ) + if stage is not None: + stage("save_backup") + save_flash_manifest(backup_dir=resolved_backup_dir, manifest=manifest) + return FlashAnalysisBundle( + inspection=inspection, + analysis=inspection.strict_analysis, + backup_dir=resolved_backup_dir, + manifest=manifest, + ) + + +def plan_from_operation( + *, + operation: str, + inspection: FlashInspection, + analysis: FlashAnalysis | None, + force: bool, + syap: str, + firmware_template: Path | None, + firmware_version: str | None, +) -> FlashPlan | None: + if operation == "patch": + return plan_patch_primary( + inspection, + force=force, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if analysis is None: + raise FlashAnalysisError(inspection_error_message(inspection)) + if operation == "restore": + return plan_restore_apple( + analysis, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if operation == "check_apple": + return plan_check_apple( + analysis, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if operation == "download_only": + return plan_download_only( + analysis, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + if operation == "read_only": + return None + raise FlashAnalysisError(f"unsupported flash plan operation: {operation}") + + +def save_primary_patched_bank_if_ready(*, backup_dir: Path, inspection: FlashInspection) -> Path | None: + primary = inspection.primary.analysis + if primary is None or primary.patch is None: + return None + path = backup_dir / "primary.patched.raw" + path.write_bytes(primary.patch.target_bank) + return path + + +def save_acp_flash_payload(*, backup_dir: Path, plan: FlashPlan) -> Path | None: + if plan.target_bank is None or plan.payload is None: + return None + suffix = "patched" if plan.mode == "patch" else plan.mode + path = backup_dir / f"{plan.target_bank.name}.{suffix}.basebinary" + path.write_bytes(plan.payload.data) + return path + + +def plan_flash_from_backup( + *, + backup_dir: Path, + operation: str, + force: bool, + firmware_template: Path | None, + firmware_version: str | None, +) -> tuple[FlashAnalysisBundle, FlashPlan | None]: + if operation == "patch": + require_zopfli_gzip_available() + bundle = inspect_backup(backup_dir, operation=operation) + syap = _backup_syap(bundle.manifest) + plan = plan_from_operation( + operation=operation, + inspection=bundle.inspection, + analysis=bundle.analysis, + force=force, + syap=syap, + firmware_template=firmware_template, + firmware_version=firmware_version, + ) + bundle.manifest["operation"] = operation + bundle.manifest["flash_plan_params"] = { + "operation": operation, + "force": force, + "firmware_template": None if firmware_template is None else str(firmware_template), + "firmware_version": firmware_version, + } + if plan is not None: + if operation == "patch": + patched_primary_path = save_primary_patched_bank_if_ready( + backup_dir=bundle.backup_dir, + inspection=bundle.inspection, + ) + if patched_primary_path is not None: + files = bundle.manifest.get("files") + if isinstance(files, dict): + files["primary_patched"] = str(patched_primary_path) + payload_path = save_acp_flash_payload(backup_dir=bundle.backup_dir, plan=plan) + files = bundle.manifest.get("files") + if isinstance(files, dict) and payload_path is not None and plan.target_bank is not None: + files[f"{plan.target_bank.name}_{plan.mode}_basebinary_payload"] = str(payload_path) + bundle.manifest["flash_plan"] = plan.to_jsonable() + apply_flash_plan_to_manifest(bundle.manifest, plan) + save_flash_manifest(backup_dir=bundle.backup_dir, manifest=bundle.manifest) + return bundle, plan + + +def write_outcome_payload( + *, + plan: FlashPlan, + status: str, + write_validated: bool, + write_may_have_modified_device: bool, + stage: str | None = None, + message: str | None = None, +) -> dict[str, object]: + outcome: dict[str, object] = { + "status": status, + "mode": plan.mode, + "write_validated": write_validated, + "write_may_have_modified_device": write_may_have_modified_device, + } + if plan.target_bank is not None: + outcome.update({ + "bank": plan.target_bank.name, + "device": plan.target_bank.device, + }) + if plan.payload is not None: + outcome.update({ + "firmware_payload_sha256": plan.payload.payload_sha256, + "firmware_payload_size": len(plan.payload.data), + "expected_prefix_sha256": plan.payload.expected_prefix_sha256, + "expected_prefix_size": len(plan.payload.expected_prefix), + }) + if stage is not None: + outcome["stage"] = stage + if message is not None: + outcome["message"] = message + return outcome + + +def record_write_outcome( + *, + bundle: FlashAnalysisBundle, + plan: FlashPlan, + status: str, + write_validated: bool, + write_may_have_modified_device: bool, + stage: str | None = None, + message: str | None = None, + write_result: dict[str, object] | None = None, +) -> None: + bundle.manifest["write_outcome"] = write_outcome_payload( + plan=plan, + status=status, + write_validated=write_validated, + write_may_have_modified_device=write_may_have_modified_device, + stage=stage, + message=message, + ) + if write_result is not None: + bundle.manifest["write_result"] = write_result + save_flash_manifest(backup_dir=bundle.backup_dir, manifest=bundle.manifest) + + +def record_post_write_action( + *, + bundle: FlashAnalysisBundle, + post_write_action: str, + reboot_requested: bool, + rebooted: bool, + waited_after_reboot: bool, +) -> None: + outcome = bundle.manifest.get("write_outcome") + if not isinstance(outcome, dict): + outcome = {} + bundle.manifest["write_outcome"] = outcome + outcome.update({ + "post_write_action": post_write_action, + "reboot_requested": reboot_requested, + "rebooted": rebooted, + "waited_after_reboot": waited_after_reboot, + }) + save_flash_manifest(backup_dir=bundle.backup_dir, manifest=bundle.manifest) + + +def validate_live_target_matches_backup( + *, + connection: SshConnection, + plan: FlashPlan, + log: object | None = None, +) -> None: + if plan.target_bank is None: + raise FlashAnalysisError("flash plan has no target bank") + _emit(log, f"Verifying live {plan.target_bank.name} bank still matches the saved backup...") + live = dump_remote_bank(connection, plan.target_bank.device, log=log) + live_sha256 = sha256_hex(live) + if live_sha256 != plan.target_bank.sha256: + raise FlashAnalysisError( + "refusing to write because the live firmware bank changed since the saved backup: " + f"bank={plan.target_bank.name} live_sha256={live_sha256} backup_sha256={plan.target_bank.sha256}" + ) + + +def write_flash_plan( + *, + target: FlashTarget, + bundle: FlashAnalysisBundle, + plan: FlashPlan, + log: object | None = None, +) -> dict[str, object]: + if plan.target_bank is None or plan.payload is None: + raise FlashAnalysisError("flash plan has no write payload") + record_write_outcome( + bundle=bundle, + plan=plan, + status="attempting", + write_validated=False, + write_may_have_modified_device=True, + stage="write_primary_bank" if plan.mode == "patch" else "write_active_bank", + ) + write_result = write_and_validate_plan( + connection=target.connection, + acp_host=target.acp_host, + plan=plan, + os_release=target.compatibility.os_release, + flash_firmware_bank_func=flash_firmware_bank, + dump_remote_bank_func=partial(dump_remote_bank_for_validation, log=log), + get_property_int_func=partial(get_property_int_for_validation, log=log), + timeout=FLASH_WRITE_TIMEOUT_SECONDS, + ) + record_write_outcome( + bundle=bundle, + plan=plan, + status="validated", + write_validated=True, + write_may_have_modified_device=True, + write_result=write_result, + ) + return write_result diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py new file mode 100644 index 00000000..798a82e6 --- /dev/null +++ b/src/timecapsulesmb/services/maintenance.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +import shlex + +from timecapsulesmb.deploy.executor import DETACHED_SHUTDOWN_REBOOT_COMMAND +from timecapsulesmb.device.processes import ( + render_direct_pkill9_by_ucomm, + render_direct_pkill9_manager, + 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) -> 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] + raise RuntimeError(MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE) + + +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_manager(), + 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) diff --git a/src/timecapsulesmb/services/reachability.py b/src/timecapsulesmb/services/reachability.py new file mode 100644 index 00000000..8273f74a --- /dev/null +++ b/src/timecapsulesmb/services/reachability.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import ipaddress +import shutil +import subprocess +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Callable + +from timecapsulesmb.core.config import DEFAULTS, AppConfig +from timecapsulesmb.core.net import canonical_ssh_target, endpoint_host, parse_endpoint, resolve_host_ips +from timecapsulesmb.transport.errors import TransportError +from timecapsulesmb.transport.local import tcp_connect_error +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, run_ssh, ssh_opts_use_proxy + + +REACHABILITY_OK_TOKEN = "timecapsulesmb-reachability-ok" + + +@dataclass(frozen=True) +class ReachabilityCheck: + id: str + status: str + message: str + host: str | None = None + detail: str | None = None + + +@dataclass(frozen=True) +class ReachabilityResult: + status: str + summary: str + ssh_host: str | None + smb_host: str | None + checks: list[ReachabilityCheck] = field(default_factory=list) + + +def run_reachability( + config: AppConfig, + params: Mapping[str, object], + *, + password: str = "", + stage: Callable[[str], None] | None = None, +) -> ReachabilityResult: + emit_stage(stage, "build_candidates") + ssh_target = ssh_target_from_params(config, params) + ssh_host = endpoint_host(ssh_target) + smb_hosts = smb_hosts_from_params(config, params, ssh_host=ssh_host) + ping_hosts = unique_hosts([ssh_host, *smb_hosts]) + tcp_timeout = non_negative_float(params.get("tcp_timeout"), default=2.0) + ssh_timeout = non_negative_int(params.get("ssh_timeout"), default=8) + + if not ssh_host and not smb_hosts: + check = ReachabilityCheck( + id="candidates", + status="SKIP", + message="No saved host candidates were available.", + ) + return ReachabilityResult( + status="skipped", + summary="No saved host candidates were available.", + ssh_host=ssh_target or None, + smb_host=None, + checks=[check], + ) + + checks: list[ReachabilityCheck] = [] + emit_stage(stage, "check_dns") + checks.append(check_dns(ping_hosts)) + emit_stage(stage, "check_ping") + checks.append(check_ping(ping_hosts, timeout=tcp_timeout)) + emit_stage(stage, "check_ssh_port") + ssh_port = check_ssh_port(ssh_host, config, timeout=tcp_timeout) + checks.append(ssh_port) + emit_stage(stage, "check_ssh_auth") + ssh_auth = check_ssh_auth( + ssh_target, + config, + password=password, + port_check=ssh_port, + timeout=ssh_timeout, + ) + checks.append(ssh_auth) + emit_stage(stage, "check_smb_port") + smb_port = check_smb_port(smb_hosts, timeout=tcp_timeout) + checks.append(smb_port) + + return result_from_checks(ssh_target=ssh_target, smb_hosts=smb_hosts, checks=checks) + + +def ssh_target_from_params(config: AppConfig, params: Mapping[str, object]) -> str: + for key in ("ssh_host", "host"): + value = string_value(params.get(key)) + if value: + return root_ssh_target(value) + return config.get("TC_HOST", "") + + +def smb_hosts_from_params(config: AppConfig, params: Mapping[str, object], *, ssh_host: str) -> list[str]: + candidates: list[str] = [] + add_param_hosts(candidates, params.get("smb_hosts")) + add_param_hosts(candidates, params.get("smb_host")) + add_param_hosts(candidates, params.get("hosts")) + add_param_hosts(candidates, params.get("host")) + if config.has_value("TC_HOST"): + candidates.append(endpoint_host(config.get("TC_HOST"))) + if ssh_host: + candidates.append(ssh_host) + return unique_hosts(candidates) + + +def add_param_hosts(candidates: list[str], value: object) -> None: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for item in value: + candidates.append(endpoint_host(string_value(item))) + return + candidates.append(endpoint_host(string_value(value))) + + +def root_ssh_target(value: str) -> str: + try: + return canonical_ssh_target(value) + except ValueError: + endpoint = parse_endpoint(value) + if not endpoint.host: + return value.strip() + user = endpoint.user or "root" + return f"{user}@{endpoint.host}" + + +def check_dns(hosts: Sequence[str]) -> ReachabilityCheck: + if not hosts: + return ReachabilityCheck(id="dns", status="SKIP", message="No hosts were available for DNS resolution.") + + resolved: list[str] = [] + failures: list[str] = [] + for host in hosts: + if is_ip_literal(host): + resolved.append(host) + continue + ips = resolve_host_ips(host) + if ips: + resolved.append(f"{host} -> {', '.join(ips)}") + else: + failures.append(host) + + if resolved: + return ReachabilityCheck( + id="dns", + status="PASS", + message="Host resolution succeeded.", + host=hosts[0], + detail="; ".join(resolved), + ) + return ReachabilityCheck( + id="dns", + status="FAIL", + message="Host resolution failed.", + host=hosts[0], + detail=", ".join(failures) if failures else None, + ) + + +def check_ping(hosts: Sequence[str], *, timeout: float) -> ReachabilityCheck: + if not hosts: + return ReachabilityCheck(id="ping", status="SKIP", message="No hosts were available for ping.") + + failures: list[str] = [] + for host in hosts: + ping = ping_command(host) + if ping is None: + return ReachabilityCheck(id="ping", status="SKIP", message="No ping command is available.") + try: + proc = subprocess.run( + ping, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=max(1.0, timeout + 1.0), + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + failures.append(f"{host}: {type(exc).__name__}") + continue + if proc.returncode == 0: + return ReachabilityCheck( + id="ping", + status="PASS", + message="Host responds to ping.", + host=host, + ) + error = (proc.stderr or b"").decode("utf-8", errors="replace").strip() + failures.append(f"{host}: {error or f'rc={proc.returncode}'}") + + return ReachabilityCheck( + id="ping", + status="FAIL", + message="Host did not respond to ping.", + host=hosts[0], + detail="; ".join(failures), + ) + + +def ping_command(host: str) -> list[str] | None: + command_name = "ping6" if is_ipv6_literal(host) else "ping" + command = shutil.which(command_name) + if command is None and command_name == "ping6": + command = shutil.which("ping") + if command is None: + return None + return [command, "-c", "1", host] + + +def check_ssh_port(host: str, config: AppConfig, *, timeout: float) -> ReachabilityCheck: + if not host: + return ReachabilityCheck(id="ssh_port", status="SKIP", message="No SSH host is configured.") + ssh_opts = config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]) + if ssh_opts_use_proxy(ssh_opts): + return ReachabilityCheck( + id="ssh_port", + status="SKIP", + message="Direct SSH port check skipped because SSH uses a proxy.", + host=host, + ) + error = tcp_connect_error(host, 22, timeout=timeout) + if error is None: + return ReachabilityCheck(id="ssh_port", status="PASS", message="SSH port is reachable.", host=host) + return ReachabilityCheck( + id="ssh_port", + status="FAIL", + message="SSH port is not reachable.", + host=host, + detail=error, + ) + + +def check_ssh_auth( + ssh_target: str, + config: AppConfig, + *, + password: str, + port_check: ReachabilityCheck, + timeout: int, +) -> ReachabilityCheck: + if not ssh_target: + return ReachabilityCheck(id="ssh_auth", status="SKIP", message="No SSH target is configured.") + if not password: + return ReachabilityCheck(id="ssh_auth", status="SKIP", message="SSH authentication skipped because no password is available.") + if port_check.status == "FAIL": + return ReachabilityCheck(id="ssh_auth", status="SKIP", message="SSH authentication skipped because the SSH port is closed.") + + connection = SshConnection( + host=ssh_target, + password=password, + ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), + ) + try: + proc = run_ssh( + connection, + f"/bin/sh -c 'printf {REACHABILITY_OK_TOKEN}'", + check=False, + timeout=timeout, + ) + except (TransportError, SshCommandTimeout) as exc: + return ReachabilityCheck( + id="ssh_auth", + status="FAIL", + message="SSH authentication failed.", + host=endpoint_host(ssh_target), + detail=str(exc), + ) + if proc.returncode == 0 and proc.stdout.strip().endswith(REACHABILITY_OK_TOKEN): + return ReachabilityCheck( + id="ssh_auth", + status="PASS", + message="SSH authentication worked.", + host=endpoint_host(ssh_target), + ) + return ReachabilityCheck( + id="ssh_auth", + status="FAIL", + message="SSH authentication failed.", + host=endpoint_host(ssh_target), + detail=proc.stdout.strip() or f"rc={proc.returncode}", + ) + + +def check_smb_port(hosts: Sequence[str], *, timeout: float) -> ReachabilityCheck: + if not hosts: + return ReachabilityCheck(id="smb_port", status="SKIP", message="No SMB hosts are configured.") + + failures: list[str] = [] + for host in hosts: + error = tcp_connect_error(host, 445, timeout=timeout) + if error is None: + return ReachabilityCheck(id="smb_port", status="PASS", message="SMB port is reachable.", host=host) + failures.append(f"{host}: {error}") + + return ReachabilityCheck( + id="smb_port", + status="FAIL", + message="SMB port is not reachable.", + host=hosts[0], + detail="; ".join(failures), + ) + + +def result_from_checks( + *, + ssh_target: str, + smb_hosts: Sequence[str], + checks: Sequence[ReachabilityCheck], +) -> ReachabilityResult: + by_id = {check.id: check for check in checks} + ssh_signal = by_id.get("ssh_auth") and by_id["ssh_auth"].status == "PASS" + if not ssh_signal: + ssh_signal = by_id.get("ssh_port") and by_id["ssh_port"].status == "PASS" + smb_signal = by_id.get("smb_port") and by_id["smb_port"].status == "PASS" + + if ssh_signal and smb_signal: + status = "reachable" + summary = "SSH reachable; SMB port reachable." + elif ssh_signal and not smb_signal: + status = "partial" + summary = "SSH reachable, SMB port closed." + elif smb_signal and not ssh_signal: + status = "partial" + summary = "SMB port reachable, SSH closed." + else: + status = "unreachable" + summary = "Could not reach SSH or SMB." + + smb_host = None + smb_check = by_id.get("smb_port") + if smb_check is not None and smb_check.status == "PASS": + smb_host = smb_check.host + elif smb_hosts: + smb_host = smb_hosts[0] + + return ReachabilityResult( + status=status, + summary=summary, + ssh_host=ssh_target or None, + smb_host=smb_host, + checks=list(checks), + ) + + +def unique_hosts(values: Iterable[str]) -> list[str]: + seen: set[str] = set() + hosts: list[str] = [] + for raw in values: + host = endpoint_host(raw) + if not host: + continue + key = host.lower() + if key in seen: + continue + seen.add(key) + hosts.append(host) + return hosts + + +def is_ip_literal(host: str) -> bool: + try: + ipaddress.ip_address(host.split("%", 1)[0]) + return True + except ValueError: + return False + + +def is_ipv6_literal(host: str) -> bool: + try: + return ipaddress.ip_address(host.split("%", 1)[0]).version == 6 + except ValueError: + return False + + +def string_value(value: object) -> str: + return "" if value is None else str(value).strip() + + +def non_negative_float(value: object, *, default: float) -> float: + if value in (None, ""): + return default + try: + parsed = float(value) + except (TypeError, ValueError): + return default + if parsed < 0: + return default + return parsed + + +def non_negative_int(value: object, *, default: int) -> int: + if value in (None, ""): + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + if parsed < 0: + return default + return parsed + + +def emit_stage(stage: Callable[[str], None] | None, name: str) -> None: + if stage is not None: + stage(name) diff --git a/src/timecapsulesmb/services/reboot.py b/src/timecapsulesmb/services/reboot.py new file mode 100644 index 00000000..b9131365 --- /dev/null +++ b/src/timecapsulesmb/services/reboot.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.net import endpoint_host +from timecapsulesmb.deploy.executor import remote_request_reboot +from timecapsulesmb.device.probe import wait_for_ssh_state_conn +from timecapsulesmb.integrations.acp import ACPError +from timecapsulesmb.integrations.acp import reboot as acp_reboot +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError + + +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 +SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE = "SSH: /bin/sync; /sbin/shutdown -r now (fallback /sbin/reboot)" + + +@dataclass(frozen=True) +class RebootCycleResult: + went_down: bool + came_back_up: bool + + @property + def completed(self) -> bool: + return self.went_down and self.came_back_up + + +@dataclass(frozen=True) +class RebootFlowError(RuntimeError): + message: str + reason: str + + def __str__(self) -> str: + return self.message + + +def request_reboot( + connection: SshConnection, + *, + strategy: str, + callbacks: OperationCallbacks | None = None, + progress_log: Callable[[str], None] | None = None, + raise_on_request_error: bool = False, + request_reboot_func: Callable[[SshConnection], None] | None = None, + request_acp_reboot: Callable[..., object] | None = None, +) -> None: + if request_reboot_func is None: + request_reboot_func = remote_request_reboot + if request_acp_reboot is None: + request_acp_reboot = acp_reboot + try: + _request_reboot( + connection, + strategy=strategy, + callbacks=callbacks, + progress_log=progress_log, + raise_on_request_error=raise_on_request_error, + request_reboot=request_reboot_func, + request_acp_reboot=request_acp_reboot, + ) + except SshCommandTimeout as exc: + raise RebootFlowError(f"SSH reboot request timed out: {exc}", "request_timeout") from exc + except SshError as exc: + raise RebootFlowError(f"SSH reboot request failed: {exc}", "request_failed") from exc + + +def request_reboot_and_wait( + connection: SshConnection, + *, + strategy: str, + callbacks: OperationCallbacks | None = None, + progress_log: Callable[[str], None] | None = None, + raise_on_request_error: bool = False, + down_timeout_seconds: int, + up_timeout_seconds: int, + reboot_no_down_message: str, + reboot_up_timeout_message: str, + request_reboot_func: Callable[[SshConnection], None] | None = None, + request_acp_reboot: Callable[..., object] | None = None, + wait_for_ssh_state: Callable[..., bool] | None = None, +) -> None: + if request_reboot_func is None: + request_reboot_func = remote_request_reboot + if request_acp_reboot is None: + request_acp_reboot = acp_reboot + if wait_for_ssh_state is None: + wait_for_ssh_state = wait_for_ssh_state_conn + try: + result = _request_reboot_and_observe( + connection, + strategy=strategy, + callbacks=callbacks, + progress_log=progress_log, + raise_on_request_error=raise_on_request_error, + down_timeout_seconds=down_timeout_seconds, + up_timeout_seconds=up_timeout_seconds, + request_reboot=request_reboot_func, + request_acp_reboot=request_acp_reboot, + wait_for_ssh_state=wait_for_ssh_state, + ) + except SshCommandTimeout as exc: + raise RebootFlowError(f"SSH reboot request timed out: {exc}", "request_timeout") from exc + except SshError as exc: + raise RebootFlowError(f"SSH reboot request failed: {exc}", "request_failed") from exc + _raise_if_incomplete(result, reboot_no_down_message, reboot_up_timeout_message) + + +def observe_reboot_cycle( + connection: SshConnection, + *, + callbacks: OperationCallbacks | None = None, + down_timeout_seconds: int, + up_timeout_seconds: int, + reboot_no_down_message: str, + reboot_up_timeout_message: str, + wait_for_ssh_state: Callable[..., bool] | None = None, +) -> None: + if wait_for_ssh_state is None: + wait_for_ssh_state = wait_for_ssh_state_conn + result = _observe_reboot_cycle( + connection, + callbacks=callbacks, + down_timeout_seconds=down_timeout_seconds, + up_timeout_seconds=up_timeout_seconds, + wait_for_ssh_state=wait_for_ssh_state, + ) + _raise_if_incomplete(result, reboot_no_down_message, reboot_up_timeout_message) + + +def _request_reboot( + connection: SshConnection, + *, + strategy: str, + callbacks: OperationCallbacks | None = None, + progress_log: Callable[[str], None] | None = None, + raise_on_request_error: bool = False, + request_reboot: Callable[[SshConnection], None] = remote_request_reboot, + request_acp_reboot: Callable[..., object] = acp_reboot, +) -> None: + callbacks = callbacks or OperationCallbacks() + callbacks.stage("reboot") + callbacks.update(reboot_was_attempted=True) + callbacks.debug(reboot_request_strategy=strategy) + if strategy == "acp_then_ssh": + _request_reboot_acp_then_ssh( + connection, + callbacks=callbacks, + progress_log=progress_log, + raise_on_request_error=raise_on_request_error, + request_reboot=request_reboot, + request_acp_reboot=request_acp_reboot, + ) + return + _request_reboot_via_ssh( + connection, + callbacks=callbacks, + progress_log=progress_log, + request_reboot=request_reboot, + raise_on_request_error=raise_on_request_error, + ) + + +def _request_reboot_acp_then_ssh( + connection: SshConnection, + *, + callbacks: OperationCallbacks, + progress_log: Callable[[str], None] | None, + raise_on_request_error: bool, + request_reboot: Callable[[SshConnection], None], + request_acp_reboot: Callable[..., object], +) -> None: + callbacks.debug(acp_reboot_attempted=True) + try: + request_acp_reboot( + endpoint_host(connection.host), + connection.password, + timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS, + ) + except ACPError as exc: + callbacks.debug( + acp_reboot_succeeded=False, + acp_reboot_error=system_exit_message(exc), + ) + callbacks.message("ACP reboot request failed; trying SSH reboot request.") + _request_reboot_via_ssh( + connection, + callbacks=callbacks, + progress_log=progress_log, + request_reboot=request_reboot, + raise_on_request_error=raise_on_request_error, + ) + return + + callbacks.debug(acp_reboot_succeeded=True) + callbacks.message("ACP reboot requested.") + + +def _request_reboot_via_ssh( + connection: SshConnection, + *, + callbacks: OperationCallbacks, + progress_log: Callable[[str], None] | None, + request_reboot: Callable[[SshConnection], None], + progress_message: str = SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, + raise_on_request_error: bool, +) -> None: + callbacks.debug(ssh_reboot_attempted=True) + if progress_log is not None: + progress_log(progress_message) + try: + request_reboot(connection) + except SshCommandTimeout as exc: + callbacks.debug( + ssh_reboot_succeeded=False, + ssh_reboot_timed_out=True, + ssh_reboot_error=system_exit_message(exc), + ) + if raise_on_request_error: + raise + callbacks.message("SSH reboot request timed out; checking whether the device is rebooting...") + return + except SshError as exc: + callbacks.debug( + ssh_reboot_succeeded=False, + ssh_reboot_error=system_exit_message(exc), + ) + if raise_on_request_error: + raise + callbacks.message("SSH reboot request failed; checking whether the device is rebooting anyway...") + return + + callbacks.debug(ssh_reboot_succeeded=True) + callbacks.message("SSH reboot requested.") + + +def _request_reboot_and_observe( + connection: SshConnection, + *, + strategy: str, + callbacks: OperationCallbacks | None = None, + progress_log: Callable[[str], None] | None = None, + raise_on_request_error: bool = False, + down_timeout_seconds: int, + up_timeout_seconds: int, + request_reboot: Callable[[SshConnection], None] = remote_request_reboot, + request_acp_reboot: Callable[..., object] = acp_reboot, + wait_for_ssh_state: Callable[..., bool] = wait_for_ssh_state_conn, +) -> RebootCycleResult: + callbacks = callbacks or OperationCallbacks() + _request_reboot( + connection, + strategy=strategy, + callbacks=callbacks, + progress_log=progress_log, + raise_on_request_error=raise_on_request_error, + request_reboot=request_reboot, + request_acp_reboot=request_acp_reboot, + ) + return _observe_reboot_cycle( + connection, + callbacks=callbacks, + down_timeout_seconds=down_timeout_seconds, + up_timeout_seconds=up_timeout_seconds, + wait_for_ssh_state=wait_for_ssh_state, + ) + + +def _observe_reboot_cycle( + connection: SshConnection, + *, + callbacks: OperationCallbacks | None = None, + down_timeout_seconds: int, + up_timeout_seconds: int, + wait_for_ssh_state: Callable[..., bool] = wait_for_ssh_state_conn, +) -> RebootCycleResult: + callbacks = callbacks or OperationCallbacks() + callbacks.message("Waiting for the device to go down...") + callbacks.stage("wait_for_reboot_down") + if not wait_for_ssh_state(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + return RebootCycleResult(went_down=False, came_back_up=False) + + callbacks.message("Device went down; waiting for it to come back up...") + callbacks.stage("wait_for_reboot_up") + if not wait_for_ssh_state(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + return RebootCycleResult(went_down=True, came_back_up=False) + + callbacks.update(device_came_back_after_reboot=True) + callbacks.message("Device is back online.") + return RebootCycleResult(went_down=True, came_back_up=True) + + +def _raise_if_incomplete( + result: RebootCycleResult, + reboot_no_down_message: str, + reboot_up_timeout_message: str, +) -> None: + if not result.went_down: + raise RebootFlowError(reboot_no_down_message, "did_not_go_down") + if not result.came_back_up: + raise RebootFlowError(reboot_up_timeout_message, "did_not_come_back_up") diff --git a/src/timecapsulesmb/services/repair_xattrs.py b/src/timecapsulesmb/services/repair_xattrs.py new file mode 100644 index 00000000..b8591fdc --- /dev/null +++ b/src/timecapsulesmb/services/repair_xattrs.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +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, +) +from timecapsulesmb.services.callbacks import OperationCallbacks + + +class RepairXattrsServiceError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RepairXattrsRequest: + path: Path | None + dry_run: bool + approve_repairs: bool + recursive: bool = True + max_depth: int | None = None + include_hidden: bool = False + include_time_machine: bool = False + fix_permissions: bool = False + verbose: bool = False + + +@dataclass(frozen=True) +class RepairRunResult: + returncode: int + root: Path + findings: list[RepairFinding] + candidates: list[RepairCandidate] + summary: RepairSummary + report: str | None = None + telemetry_result: str = "success" + error: str | None = None + + @property + def ok(self) -> bool: + return self.returncode == 0 + + def to_payload_fields(self) -> dict[str, object]: + return { + "returncode": self.returncode, + "root": str(self.root), + "finding_count": len(self.findings), + "repairable_count": len(self.candidates), + "stats": self.summary, + "report": self.report, + "telemetry_result": self.telemetry_result, + "error": self.error, + } + + +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( + request: RepairXattrsRequest, + config: AppConfig, + *, + callbacks: OperationCallbacks | None = None, + confirm: Callable[[str], bool] | None = None, +) -> RepairRunResult: + callbacks = callbacks or OperationCallbacks() + + def emit(message: str) -> None: + callbacks.message(message) + + callbacks.stage("resolve_scan_root") + callbacks.update( + dry_run=request.dry_run, + recursive=request.recursive, + max_depth=request.max_depth, + include_hidden=request.include_hidden, + include_time_machine=request.include_time_machine, + fix_permissions=request.fix_permissions, + explicit_path=request.path is not None, + ) + if request.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 RepairXattrsServiceError(str(exc)) from exc + else: + root = request.path + if root is None: + raise RepairXattrsServiceError("Could not determine mounted share path. Pass --path explicitly.") + try: + root = validate_repair_root_under_volumes(root) + except RuntimeError as exc: + raise RepairXattrsServiceError(str(exc)) from exc + + summary = RepairSummary() + callbacks.update(repair_root=str(root)) + callbacks.stage("scan_findings") + emit(f"Scanning {root}") + try: + findings = find_findings( + root, + recursive=request.recursive, + max_depth=request.max_depth, + include_hidden=request.include_hidden, + include_time_machine=request.include_time_machine, + include_directories=True, + include_root_directory=True, + fix_permissions=request.fix_permissions, + summary=summary, + ) + except RuntimeError as exc: + raise RepairXattrsServiceError(str(exc)) from exc + repairs = actionable_findings(findings) + candidates = [finding_to_candidate(finding) for finding in repairs] + callbacks.update( + 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)) + return RepairRunResult(0, root, findings, candidates, summary) + + callbacks.stage("report_findings") + _emit_lines(emit, render_diagnostic_lines(findings, verbose=request.verbose)) + if candidates: + _emit_lines(emit, render_candidate_lines(candidates, dry_run=request.dry_run)) + + if request.dry_run: + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + emit("No changes made.") + report = build_repair_report(findings) + return RepairRunResult(0, root, findings, candidates, summary, report=report, telemetry_result="failure", error=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) + return RepairRunResult(1, root, findings, candidates, summary, report=report, telemetry_result="failure", error=report) + + callbacks.stage("confirm_repair") + if not request.approve_repairs and confirm is None: + message = "Running `repair-xattrs` in non-interactive mode requires `--yes` to apply repairs." + emit(message) + return RepairRunResult(1, root, findings, candidates, summary, report=message, telemetry_result="failure", error=message) + if not request.approve_repairs and not 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) + return RepairRunResult(0, root, findings, candidates, summary, report=report, telemetry_result="failure", error=report) + + callbacks.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 + callbacks.update(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) + return RepairRunResult(1, root, findings, candidates, summary, report=report, telemetry_result="failure", error=report) + 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 00000000..aa053a5d --- /dev/null +++ b/src/timecapsulesmb/services/runtime.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +import time + +from timecapsulesmb.core.config import DEFAULTS, AppConfig, ConfigError, load_app_config, require_valid_app_config +from timecapsulesmb.core.net import ( + canonical_ssh_target, + endpoint_host, + ipv4_literal, + is_link_local_ipv4, + is_link_local_ipv6, + resolve_host_ipv4s, + resolve_host_ipv6s, +) +from timecapsulesmb.core.paths import AppPaths, 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 + +PasswordProvider = Callable[[str], str] + + +@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: Callable[..., AppPaths] | None = None, +) -> AppConfig: + if resolve_paths is None: + resolve_paths = resolve_app_paths + 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: Callable[..., AppPaths] | None = None, +) -> AppConfig: + try: + if resolve_paths is None: + resolve_paths = resolve_app_paths + 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, + allow_password_prompt: bool = True, + password_provider: PasswordProvider | None = None, +) -> tuple[str, str]: + raw_host = config.require("TC_HOST") + try: + host = canonical_ssh_target(raw_host) + except ValueError as exc: + raise ConfigError(str(exc)) from exc + password = config.get("TC_PASSWORD") + if not password and not allow_empty_password: + if not allow_password_prompt or password_provider is None: + raise ConfigError("TC_PASSWORD is required when --no-input is used.") + password = password_provider("Device root password: ") + return host, password + + +def resolve_env_connection( + config: AppConfig, + *, + required_keys: tuple[str, ...] = (), + allow_empty_password: bool = False, + allow_password_prompt: bool = True, + password_provider: PasswordProvider | None = None, +) -> SshConnection: + for key in required_keys: + config.require(key) + host, password = resolve_ssh_credentials( + config, + allow_empty_password=allow_empty_password, + allow_password_prompt=allow_password_prompt, + password_provider=password_provider, + ) + 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 = endpoint_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)) + link_local_ipv6s = tuple(ip for ip in resolve_host_ipv6s(host) if is_link_local_ipv6(ip)) + link_local_hosts = link_local_ips + link_local_ipv6s + if not link_local_hosts: + return None + noun = "address" if len(link_local_hosts) == 1 else "addresses" + return ( + f"{field_name} host {host} resolves to link-local {noun} " + f"{', '.join(link_local_hosts)}. Use the device's LAN IP or a hostname that resolves " + "to its LAN IP; link-local addresses are only suitable for temporary SSH recovery." + ) + + +def resolve_validated_managed_target( + config: AppConfig, + *, + command_name: str, + profile: str, + include_probe: bool = False, + allow_password_prompt: bool = True, + password_provider: PasswordProvider | None = None, +) -> 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, + allow_password_prompt=allow_password_prompt, + password_provider=password_provider, + ) + 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, + tcp_open_func: Callable[[str, int], bool] = tcp_open, +) -> 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_func(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/src/timecapsulesmb/services/runtime_verification.py b/src/timecapsulesmb/services/runtime_verification.py new file mode 100644 index 00000000..a12fdf5d --- /dev/null +++ b/src/timecapsulesmb/services/runtime_verification.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import Callable + +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.deploy.verify import render_managed_runtime_verification +from timecapsulesmb.device.errors import DeviceError +from timecapsulesmb.device.probe import ( + ManagedRuntimeProbeResult, + probe_managed_runtime_conn, + read_remote_network_diagnostics_conn, + read_runtime_log_tails_conn, + runtime_startup_failure_debug_fields, +) +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.transport.ssh import SshConnection + + +def verify_managed_runtime_ready( + connection: SshConnection, + *, + callbacks: OperationCallbacks | None = None, + stage: str, + timeout_seconds: int, + heading: str, + failure_message: str, + probe_runtime: Callable[..., ManagedRuntimeProbeResult] | None = None, + read_runtime_logs: Callable[[SshConnection], dict[str, object]] | None = None, + read_network_diagnostics: Callable[[SshConnection], dict[str, object]] | None = None, +) -> ManagedRuntimeProbeResult: + callbacks = callbacks or OperationCallbacks() + if probe_runtime is None: + probe_runtime = probe_managed_runtime_conn + if read_runtime_logs is None: + read_runtime_logs = read_runtime_log_tails_conn + if read_network_diagnostics is None: + read_network_diagnostics = read_remote_network_diagnostics_conn + callbacks.stage(stage) + verification = probe_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification(verification, heading=heading): + callbacks.message(line) + if verification.ready: + return verification + + detail = verification.detail.strip() + runtime_log_fields: dict[str, object] = {} + try: + runtime_log_fields = read_runtime_logs(connection) + callbacks.debug(**runtime_log_fields) + except Exception as exc: + callbacks.debug(remote_runtime_log_tail_error=system_exit_message(exc)) + + startup_failure_fields = runtime_startup_failure_debug_fields( + runtime_log_fields, + verification_detail=detail, + ) + if startup_failure_fields: + callbacks.debug(**startup_failure_fields) + if startup_failure_fields.get("runtime_startup_failure") == "network_auto_ip_unavailable": + try: + callbacks.debug(**read_network_diagnostics(connection)) + except Exception as exc: + callbacks.debug(remote_network_diagnostics_error=system_exit_message(exc)) + + if detail: + failure_message = f"{failure_message.rstrip()} {detail}" + raise DeviceError(failure_message) diff --git a/src/timecapsulesmb/services/storage.py b/src/timecapsulesmb/services/storage.py new file mode 100644 index 00000000..eca5ea3e --- /dev/null +++ b/src/timecapsulesmb/services/storage.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Callable + +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + MaStDiscoveryResult, + MaStVolume, + mast_volumes_debug_summary, + mounted_mast_volumes_conn, + read_mast_volumes_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.transport.ssh import SshConnection + + +MAST_ACP_OUTPUT_DEBUG_LIMIT = 8192 + + +def mast_acp_output_debug_text(raw_output: str) -> str: + if not raw_output: + return "" + if len(raw_output) <= MAST_ACP_OUTPUT_DEBUG_LIMIT: + return raw_output + omitted = len(raw_output) - MAST_ACP_OUTPUT_DEBUG_LIMIT + return f"{raw_output[:MAST_ACP_OUTPUT_DEBUG_LIMIT]}..." + + +def _best_effort_mast_debug_summary(volumes: object) -> object | None: + try: + return mast_volumes_debug_summary(volumes) + except Exception: + return None + + +def _record_mast_read_diagnostics( + callbacks: OperationCallbacks, + volumes: tuple[MaStVolume, ...], +) -> None: + callbacks.debug( + mast_volume_count=len(volumes), + mast_candidates=_best_effort_mast_debug_summary(volumes), + ) + + +def read_mast_volumes_with_diagnostics( + connection: SshConnection, + *, + callbacks: OperationCallbacks, + stage: str = "read_mast", + read_mast_volumes: Callable[[SshConnection], tuple[MaStVolume, ...]] | None = None, +) -> tuple[MaStVolume, ...]: + callbacks.stage(stage) + if read_mast_volumes is None: + read_mast_volumes = read_mast_volumes_conn + volumes = read_mast_volumes(connection) + _record_mast_read_diagnostics(callbacks, volumes) + return volumes + + +def mount_mast_volumes_with_diagnostics( + connection: SshConnection, + *, + callbacks: OperationCallbacks, + wait_seconds: int, + read_stage: str = "read_mast", + mount_stage: str = "mount_mast_volumes", + read_mast_volumes: Callable[[SshConnection], tuple[MaStVolume, ...]] | None = None, + mounted_mast_volumes: Callable[ + [SshConnection, tuple[MaStVolume, ...]], + tuple[MaStVolume, ...], + ] | None = None, +) -> tuple[MaStVolume, ...]: + mast_volumes = read_mast_volumes_with_diagnostics( + connection, + callbacks=callbacks, + stage=read_stage, + read_mast_volumes=read_mast_volumes, + ) + callbacks.stage(mount_stage) + if mounted_mast_volumes is None: + mounted_mast_volumes = mounted_mast_volumes_conn + mounted_volumes = mounted_mast_volumes( + connection, + mast_volumes, + wait_seconds=wait_seconds, + ) + callbacks.debug( + mast_mounted_volume_count=len(mounted_volumes), + mast_mounted_candidates=_best_effort_mast_debug_summary(mounted_volumes), + ) + return mounted_volumes + + +def wait_for_mast_volumes_with_diagnostics( + connection: SshConnection, + *, + callbacks: OperationCallbacks, + attempts: int = MAST_DISCOVERY_ATTEMPTS, + delay_seconds: int = MAST_DISCOVERY_DELAY_SECONDS, + stage: str = "read_mast", + wait_for_mast_volumes: Callable[..., MaStDiscoveryResult] | None = None, +) -> MaStDiscoveryResult: + callbacks.stage(stage) + if wait_for_mast_volumes is None: + wait_for_mast_volumes = wait_for_mast_volumes_conn + mast_discovery = wait_for_mast_volumes( + connection, + attempts=attempts, + delay_seconds=delay_seconds, + ) + mast_volumes = mast_discovery.volumes + fields: dict[str, object] = { + "mast_read_attempts": mast_discovery.attempts, + "mast_volume_count": len(mast_volumes), + "mast_candidates": _best_effort_mast_debug_summary(mast_volumes), + } + if not mast_volumes: + fields["mast_acp_output_chars"] = len(mast_discovery.raw_output) + fields["mast_acp_output"] = mast_acp_output_debug_text(mast_discovery.raw_output) + callbacks.debug(**fields) + return mast_discovery diff --git a/src/timecapsulesmb/cli/version_check.py b/src/timecapsulesmb/services/version_check.py similarity index 80% rename from src/timecapsulesmb/cli/version_check.py rename to src/timecapsulesmb/services/version_check.py index 6557538e..f4b24ad4 100644 --- a/src/timecapsulesmb/cli/version_check.py +++ b/src/timecapsulesmb/services/version_check.py @@ -23,7 +23,9 @@ @dataclass(frozen=True) class VersionMetadata: + current_version: int min_supported_version: int + latest_tag: str | None download_url: str message: str @@ -34,6 +36,11 @@ class VersionCheckResult: checked_url: str = VERSION_CHECK_URL message: str = DEFAULT_UNSUPPORTED_MESSAGE download_url: str = DEFAULT_DOWNLOAD_URL + local_version_code: int = CLI_VERSION_CODE + current_version: int | None = None + min_supported_version: int | None = None + latest_tag: str | None = None + source: str = "unavailable" UrlOpen = Callable[..., Any] @@ -62,8 +69,13 @@ def parse_version_metadata(payload: object) -> VersionMetadata | None: message = payload.get("message") if not isinstance(message, str) or not message.strip(): message = DEFAULT_UNSUPPORTED_MESSAGE + latest_tag = payload.get("latest_tag") + if not isinstance(latest_tag, str) or not latest_tag.strip(): + latest_tag = None return VersionMetadata( + current_version=current_version, min_supported_version=min_supported_version, + latest_tag=latest_tag.strip() if latest_tag else None, download_url=download_url.strip(), message=message.strip(), ) @@ -157,7 +169,7 @@ def check_client_version( opener=opener, ) except Exception: - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult(should_block=False, checked_url=url, local_version_code=local_version_code) def _check_client_version( @@ -173,12 +185,20 @@ def _check_client_version( cached_payload = load_fresh_cached_payload(cache_path=cache_path, now=timestamp) cached_metadata = parse_version_metadata(cached_payload) if cached_metadata is not None and local_version_code >= cached_metadata.min_supported_version: - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult( + should_block=False, + checked_url=url, + local_version_code=local_version_code, + current_version=cached_metadata.current_version, + min_supported_version=cached_metadata.min_supported_version, + latest_tag=cached_metadata.latest_tag, + source="cache", + ) fetched_payload = fetch_version_payload(url=url, timeout=timeout, opener=opener) fetched_metadata = parse_version_metadata(fetched_payload) if fetched_metadata is None: - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult(should_block=False, checked_url=url, local_version_code=local_version_code) save_cached_payload(fetched_payload, cache_path=cache_path, now=timestamp) if local_version_code < fetched_metadata.min_supported_version: @@ -187,8 +207,21 @@ def _check_client_version( checked_url=url, message=fetched_metadata.message, download_url=fetched_metadata.download_url, + local_version_code=local_version_code, + current_version=fetched_metadata.current_version, + min_supported_version=fetched_metadata.min_supported_version, + latest_tag=fetched_metadata.latest_tag, + source="network", ) - return VersionCheckResult(should_block=False, checked_url=url) + return VersionCheckResult( + should_block=False, + checked_url=url, + local_version_code=local_version_code, + current_version=fetched_metadata.current_version, + min_supported_version=fetched_metadata.min_supported_version, + latest_tag=fetched_metadata.latest_tag, + source="network", + ) def render_version_block_message(result: VersionCheckResult) -> str: diff --git a/src/timecapsulesmb/telemetry/__init__.py b/src/timecapsulesmb/telemetry/__init__.py index 1de0a513..8c86a3f4 100644 --- a/src/timecapsulesmb/telemetry/__init__.py +++ b/src/timecapsulesmb/telemetry/__init__.py @@ -17,7 +17,7 @@ from timecapsulesmb.identity import load_install_identity -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 DEFAULT_TELEMETRY_URL = "https://timecapsulesmb.jamesyc.com/v1/events" TELEMETRY_URL_ENV = "TCAPSULE_TELEMETRY_URL" TELEMETRY_TOKEN_ENV = "TCAPSULE_TELEMETRY_TOKEN" @@ -75,10 +75,26 @@ def from_config( ) return cls(endpoint=endpoint, token=token, context=context, enabled=identity.telemetry_enabled) - def emit(self, event: str, *, synchronous: bool = False, **fields: object) -> None: + def emit( + self, + event: str, + *, + synchronous: bool = False, + operation: str | None = None, + phase: str | None = None, + operation_id: str | None = None, + entrypoint: str | None = None, + client: str | None = None, + options: dict[str, object] | None = None, + details: dict[str, object] | None = None, + **fields: object, + ) -> None: if not self.enabled or self.context is None: return try: + inferred_operation, inferred_phase = infer_operation_phase(event) + operation = operation or inferred_operation + phase = phase or inferred_phase payload: dict[str, object] = { "schema_version": SCHEMA_VERSION, "event": event, @@ -91,6 +107,16 @@ def emit(self, event: str, *, synchronous: bool = False, **fields: object) -> No "host_os": self.context.host_os, "host_os_version": self.context.host_os_version, } + if operation: + payload["operation"] = operation + if phase: + payload["phase"] = phase + if operation_id: + payload["operation_id"] = operation_id + if entrypoint: + payload["entrypoint"] = entrypoint + if client: + payload["client"] = client if self.context.configure_id: payload["configure_id"] = self.context.configure_id if self.context.device_model: @@ -99,6 +125,10 @@ def emit(self, event: str, *, synchronous: bool = False, **fields: object) -> No payload["device_syap"] = self.context.device_syap if self.context.nbns_enabled is not None: payload["nbns_enabled"] = self.context.nbns_enabled + if options is not None: + payload["options"] = options + if details is not None: + payload["details"] = details for key, value in fields.items(): if value is not None: payload[key] = value @@ -183,6 +213,14 @@ def run_text_command(command: list[str]) -> str | None: return value or None +def infer_operation_phase(event: str) -> tuple[str | None, str | None]: + if event.endswith("_started"): + return event.removesuffix("_started").replace("_", "-"), "started" + if event.endswith("_finished"): + return event.removesuffix("_finished").replace("_", "-"), "finished" + return None, None + + def parse_os_release() -> dict[str, str]: path = Path("/etc/os-release") if not path.exists(): diff --git a/src/timecapsulesmb/telemetry/operation.py b/src/timecapsulesmb/telemetry/operation.py new file mode 100644 index 00000000..29456a64 --- /dev/null +++ b/src/timecapsulesmb/telemetry/operation.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import os +import time +import uuid +from collections.abc import Callable, Mapping +from dataclasses import asdict, is_dataclass +from enum import Enum +from pathlib import Path +from typing import Optional + +from timecapsulesmb.telemetry import TelemetryClient + + +OPTION_KEYS = frozenset({ + "action", + "allow_unsupported", + "any_protocol", + "ata_idle_seconds", + "ata_standby", + "bonjour_timeout", + "debug_logging", + "dry_run", + "enable_ssh", + "fix_permissions", + "force", + "include_hidden", + "include_time_machine", + "internal_share_use_disk_root", + "list_volumes", + "max_depth", + "mode", + "mount_wait", + "nbns_enabled", + "no_reboot", + "no_wait", + "persist_password", + "recursive", + "reboot_after_write", + "skip_bonjour", + "skip_smb", + "skip_ssh", + "ssh_wait_timeout", + "timeout", + "verbose", + "wait_after_reboot", + "yes", +}) +SENSITIVE_KEY_PARTS = ("credentials", "password", "secret", "token", "key") +RESERVED_EVENT_FIELD_KEYS = frozenset({ + "schema_version", + "event", + "event_id", + "occurred_at", + "operation", + "phase", + "operation_id", + "entrypoint", + "client", + "options", + "details", + "command_id", +}) + + +class OperationTelemetrySession: + def __init__( + self, + telemetry: TelemetryClient, + operation: str, + *, + entrypoint: str, + client: str, + started_event: str | None = None, + finished_event: str | None = None, + operation_id: str | None = None, + options: Mapping[str, object] | None = None, + ) -> None: + self.telemetry = telemetry + self.operation = operation + self.entrypoint = entrypoint + self.client = client + self.started_event = started_event or legacy_event_name(operation, "started") + self.finished_event = finished_event or legacy_event_name(operation, "finished") + self.operation_id = operation_id or str(uuid.uuid4()) + self.options = dict(options or {}) + self.start_time = time.monotonic() + + def start(self, **fields: object) -> None: + self._emit( + self.started_event, + phase="started", + options=self.options or None, + **fields, + ) + + def finish( + self, + *, + result: str, + error: object | None = None, + stage: str | None = None, + risk: str | None = None, + options: Mapping[str, object] | None = None, + details: Mapping[str, object] | None = None, + **fields: object, + ) -> None: + emit_options = dict(options or self.options) + duration_sec = round(time.monotonic() - self.start_time, 3) + self._emit( + self.finished_event, + synchronous=True, + phase="finished", + result=result, + duration_sec=duration_sec, + error=error, + stage=stage, + risk=risk, + options=emit_options or None, + details=dict(details or {}) or None, + **fields, + ) + + def _emit(self, event: str, *, phase: str, **fields: object) -> None: + try: + synchronous = bool(fields.pop("synchronous", False)) + options = fields.pop("options", None) + details = fields.pop("details", None) + emit_fields = _avoid_reserved_field_collisions(fields) + self.telemetry.emit( + event, + synchronous=synchronous, + operation=self.operation, + phase=phase, + operation_id=self.operation_id, + entrypoint=self.entrypoint, + client=self.client, + options=options if isinstance(options, dict) else None, + details=details if isinstance(details, dict) else None, + # Retain the old field during schema v4 rollout for existing dashboards/queries. + command_id=self.operation_id, + **emit_fields, + ) + except Exception: + pass + + +def legacy_event_name(operation: str, phase: str) -> str: + return f"{operation.replace('-', '_')}_{phase}" + + +def client_from_environment(*, entrypoint: str) -> str: + value = os.getenv("TCAPSULE_CLIENT", "").strip() + if value: + return value + return "terminal" if entrypoint == "cli" else entrypoint + + +def telemetry_options_from_params(params: Mapping[str, object]) -> dict[str, object]: + options: dict[str, object] = {} + for key in sorted(OPTION_KEYS): + if key in params: + value = params.get(key) + if value is not None: + options[key] = _jsonable(value) + return options + + +def telemetry_options_from_args(args: object | None) -> dict[str, object]: + if args is None: + return {} + if isinstance(args, Mapping): + return telemetry_options_from_params(args) + try: + values = vars(args) + except TypeError: + return {} + return telemetry_options_from_params(values) + + +def telemetry_details_from_payload( + operation: str, + params: Mapping[str, object], + payload: object | None, +) -> dict[str, object]: + extractor = DETAIL_EXTRACTORS.get(operation, _details_common) + return extractor(params, payload) + + +DetailExtractor = Callable[[Mapping[str, object], Optional[object]], dict[str, object]] + + +def _details_common(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_key(payload, details, "summary") + return details + + +def _details_activate(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ("already_active", "message", "summary")) + _copy_counts(payload, details) + return details + + +def _details_configure(params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + _copy_param(params, details, "enable_ssh") + _copy_param(params, details, "persist_password") + if isinstance(params.get("selected_record"), Mapping): + details["selected_bonjour_record"] = True + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ( + "configure_id", + "host", + "ssh_authenticated", + "device_model", + "device_syap", + "summary", + )) + _copy_compatibility(payload, details) + return details + + +def _details_deploy(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ( + "message", + "netbsd4", + "payload_dir", + "payload_family", + "reboot_requested", + "rebooted", + "requires_reboot", + "summary", + "verified", + "waited", + )) + return details + + +def _details_discover(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + counts = payload.get("counts") + if isinstance(counts, Mapping): + _copy_payload_key(counts, details, "instances", to_key="instance_count") + _copy_payload_key(counts, details, "resolved", to_key="resolved_count") + _copy_payload_key(counts, details, "devices", to_key="device_count") + _copy_payload_key(payload, details, "summary") + return details + + +def _details_doctor(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ("counts", "error", "fatal", "summary")) + return details + + +def _details_flash(params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + _copy_param(params, details, "action", to_key="flash_action") + _copy_param(params, details, "mode", to_key="flash_mode") + _copy_param(params, details, "force") + _copy_param(params, details, "reboot_after_write") + _copy_param(params, details, "wait_after_reboot") + if params.get("backup_dir") not in (None, ""): + details["backup_dir_provided"] = True + if params.get("firmware_template") not in (None, ""): + details["firmware_template_provided"] = True + _copy_param(params, details, "firmware_version") + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ( + "mode", + "write_requested", + "already_satisfied", + "write_status", + "write_validated", + "post_write_action", + "reboot_requested", + "rebooted", + "waited_after_reboot", + "summary", + )) + _copy_counts(payload, details) + plan = payload.get("flash_plan") + if isinstance(plan, Mapping): + _copy_payload_key(plan, details, "target_bank") + _copy_payload_key(plan, details, "write_may_modify_device") + outcome = payload.get("write_outcome") + if isinstance(outcome, Mapping): + _copy_payload_key(outcome, details, "target_bank") + _copy_payload_key(outcome, details, "write_may_have_modified_device") + return details + + +def _details_fsck(params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + _copy_param(params, details, "volume") + if isinstance(payload, Mapping): + target = payload.get("target") + if isinstance(target, Mapping): + _copy_payload_key(target, details, "name", to_key="volume") + _copy_payload_key(target, details, "device", to_key="fsck_device") + _copy_payload_key(target, details, "mountpoint", to_key="fsck_mountpoint") + _copy_payload_key(payload, details, "device", to_key="fsck_device") + _copy_payload_key(payload, details, "fsck_device") + _copy_payload_key(payload, details, "mountpoint", to_key="fsck_mountpoint") + _copy_payload_key(payload, details, "fsck_mountpoint") + _copy_payload_key(payload, details, "reboot_was_attempted", to_key="reboot_requested") + _copy_payload_key(payload, details, "device_came_back_after_reboot", to_key="verified") + _copy_payload_keys(payload, details, ("returncode", "reboot_requested", "waited", "verified", "summary")) + _copy_counts(payload, details) + return details + + +def _details_reachability(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ("status", "ssh_host", "smb_host", "counts", "summary")) + return details + + +def _details_repair_xattrs(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ( + "error", + "finding_count", + "repairable_count", + "returncode", + "root", + "summary_text", + "telemetry_result", + )) + _copy_counts(payload, details) + return details + + +def _details_uninstall(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ( + "reboot_requested", + "rebooted", + "requires_reboot", + "summary", + "verified", + "waited", + )) + _copy_counts(payload, details) + return details + + +def _details_validate_install(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ("ok", "counts", "summary")) + return details + + +def _details_version_check(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ( + "should_block", + "local_version_code", + "current_version", + "min_supported_version", + "latest_tag", + "source", + "summary", + )) + return details + + +def _details_capabilities(_params: Mapping[str, object], payload: object | None) -> dict[str, object]: + details: dict[str, object] = {} + if isinstance(payload, Mapping): + _copy_payload_keys(payload, details, ( + "api_schema_version", + "helper_version", + "helper_version_code", + "artifact_manifest_sha256", + "summary", + )) + operations = payload.get("operations") + if isinstance(operations, list): + details["operation_count"] = len(operations) + return details + + +DETAIL_EXTRACTORS: dict[str, DetailExtractor] = { + "activate": _details_activate, + "capabilities": _details_capabilities, + "configure": _details_configure, + "deploy": _details_deploy, + "discover": _details_discover, + "doctor": _details_doctor, + "flash": _details_flash, + "fsck": _details_fsck, + "reachability": _details_reachability, + "repair-xattrs": _details_repair_xattrs, + "uninstall": _details_uninstall, + "validate-install": _details_validate_install, + "version-check": _details_version_check, +} + + +def _avoid_reserved_field_collisions(fields: Mapping[str, object]) -> dict[str, object]: + output: dict[str, object] = {} + for key, value in fields.items(): + if key in RESERVED_EVENT_FIELD_KEYS: + output[f"legacy_{key}"] = value + else: + output[key] = value + return output + + +def confirmation_details(confirmation: object) -> dict[str, object]: + to_jsonable = getattr(confirmation, "to_jsonable", None) + if callable(to_jsonable): + value = to_jsonable() + else: + value = confirmation + if not isinstance(value, Mapping): + return {} + details = _jsonable(value) + return details if isinstance(details, dict) else {} + + +def _copy_param(source: Mapping[str, object], target: dict[str, object], key: str, *, to_key: str | None = None) -> None: + value = source.get(key) + if value not in (None, ""): + target[to_key or key] = _jsonable(value) + + +def _copy_payload_key(source: Mapping[str, object], target: dict[str, object], key: str, *, to_key: str | None = None) -> None: + value = source.get(key) + if value is not None: + target[to_key or key] = _jsonable(value) + + +def _copy_payload_keys(source: Mapping[str, object], target: dict[str, object], keys: tuple[str, ...]) -> None: + for key in keys: + _copy_payload_key(source, target, key) + + +def _copy_counts(source: Mapping[str, object], target: dict[str, object]) -> None: + counts = source.get("counts") + if isinstance(counts, Mapping): + target["counts"] = _jsonable(counts) + + +def _copy_compatibility(source: Mapping[str, object], target: dict[str, object]) -> None: + compatibility = source.get("compatibility") + if not isinstance(compatibility, Mapping): + return + _copy_payload_key(compatibility, target, "payload_family", to_key="device_family") + os_name = compatibility.get("os_name") + os_release = compatibility.get("os_release") + arch = compatibility.get("arch") + if os_name and os_release and arch: + target["device_os_version"] = f"{os_name} {os_release} ({arch})" + + +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, Mapping): + output: dict[str, object] = {} + for key, item in value.items(): + key_text = str(key) + if any(part in key_text.lower() for part in SENSITIVE_KEY_PARTS): + continue + output[key_text] = _jsonable(item) + return output + if isinstance(value, (list, tuple, set)): + return [_jsonable(item) for item in value] + return value diff --git a/src/timecapsulesmb/transport/ssh.py b/src/timecapsulesmb/transport/ssh.py index 987bae87..1bf82cc7 100644 --- a/src/timecapsulesmb/transport/ssh.py +++ b/src/timecapsulesmb/transport/ssh.py @@ -282,7 +282,13 @@ def _run_sshpass_ssh( return proc -def run_ssh_capture_bytes(connection: SshConnection, remote_cmd: str, *, timeout: int = 120) -> bytes: +def run_ssh_capture_bytes( + connection: SshConnection, + remote_cmd: str, + *, + timeout: int = 120, + missing_tool_message: str | None = None, +) -> bytes: """Run a remote command over SSH and return raw stdout bytes. This intentionally uses a pipe instead of the pexpect PTY path because @@ -292,7 +298,7 @@ def run_ssh_capture_bytes(connection: SshConnection, remote_cmd: str, *, timeout connection, remote_cmd, timeout=timeout, - missing_tool_message=( + missing_tool_message=missing_tool_message or ( "Reading raw firmware banks requires local sshpass. " "Run `./tcapsule bootstrap` to install sshpass, then rerun `tcapsule flash`." ), diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..3c34cde4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + + +@pytest.fixture(autouse=True) +def block_unmocked_telemetry_posts(monkeypatch: pytest.MonkeyPatch): + urlopen_mock = mock.Mock(side_effect=AssertionError("tests must not send telemetry")) + monkeypatch.setattr("timecapsulesmb.telemetry.urllib.request.urlopen", urlopen_mock) + yield + urlopen_mock.assert_not_called() diff --git a/tests/test_acp.py b/tests/test_acp.py index 009dcf72..8374e099 100644 --- a/tests/test_acp.py +++ b/tests/test_acp.py @@ -140,6 +140,16 @@ def test_get_property_int_validates_sized_reply_body_checksum(self) -> None: self.assertEqual(value, 0x3000) + def test_read_identity_reads_syap_property(self) -> None: + body = acp._compose_property_element("syAP", 119) + response = acp._compose_header(command=acp.COMMAND_GETPROP, payload=body) + body + fake_socket = FakeSocket(response) + with mock.patch("timecapsulesmb.integrations.acp.socket.create_connection", return_value=fake_socket): + identity = acp.read_identity("10.0.0.2", "pw") + + self.assertEqual(identity.syap, 119) + self.assertIn(b"syAP", fake_socket.sent) + def test_get_property_int_rejects_bad_sized_reply_body_checksum(self) -> None: body = acp._compose_property_element("dbug", 0x3000) corrupted = bytearray(body) diff --git a/tests/test_app_api.py b/tests/test_app_api.py new file mode 100644 index 00000000..6fab1d7a --- /dev/null +++ b/tests/test_app_api.py @@ -0,0 +1,3515 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import io +import json +import os +import sys +import tempfile +import unittest +from contextlib import ExitStack, 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.app.context import AppOperationContext +from timecapsulesmb.app.confirmations import build_confirmation +from timecapsulesmb import repair_xattrs as repair_xattrs_domain +from timecapsulesmb.app import contracts, helper, service +from timecapsulesmb.services.version_check import VersionCheckResult +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 ( + ManagedRuntimeProbeResult, + ProbeResult, + ProbeStepResult, + ProbedDeviceState, + ReadinessProbeResult, +) +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, ACPConnectionError, ACPError, ACPIdentity +from timecapsulesmb.services.app import AppOperationError, jsonable +from timecapsulesmb.services.flash import ( + FLASH_UNSUPPORTED_DEVICE_MESSAGE, + STALE_BACKUP_AFTER_WRITE_MESSAGE, + require_backup_fresh_for_plan, +) +from timecapsulesmb.services.reboot import RebootFlowError +from timecapsulesmb.services.repair_xattrs import RepairRunResult, RepairXattrsRequest +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, + ) + + +def managed_runtime_probe(ready: bool = True) -> ManagedRuntimeProbeResult: + status = "PASS" if ready else "FAIL" + detail = "managed runtime is ready" if ready else "managed runtime is not ready" + smbd = readiness_result(ready, detail, (f"{status}:managed smbd ready",)) + mdns = readiness_result(ready, detail, (f"{status}:managed mDNS takeover active",)) + return ManagedRuntimeProbeResult( + ready=ready, + detail=detail, + smbd=smbd, + mdns=mdns, + ) + + +def readiness_result(ready: bool, detail: str, lines: tuple[str, ...]) -> ReadinessProbeResult: + steps = [] + for index, line in enumerate(lines): + if line.startswith("PASS:"): + steps.append(ProbeStepResult(f"test_{index}", "pass", line.removeprefix("PASS:"))) + elif line.startswith("FAIL:"): + steps.append(ProbeStepResult(f"test_{index}", "fail", line.removeprefix("FAIL:"))) + else: + steps.append(ProbeStepResult(f"test_{index}", "fail", line)) + return ReadinessProbeResult(ready=ready, detail=detail, steps=tuple(steps)) + + +class AppApiTests(unittest.TestCase): + def setUp(self) -> None: + self._exit_stack = ExitStack() + self._telemetry_client = mock.Mock() + # App API tests exercise GUI/backend telemetry-enabled operations. + # Keep telemetry mocked here so unit tests never POST to the live telemetry service. + self._telemetry_factory = self._exit_stack.enter_context( + mock.patch("timecapsulesmb.app.service.TelemetryClient.from_config", return_value=self._telemetry_client) + ) + # This tripwire catches future tests that accidentally bypass the app-service telemetry mock. + self._telemetry_urlopen = self._exit_stack.enter_context( + mock.patch("timecapsulesmb.telemetry.urllib.request.urlopen", side_effect=AssertionError("tests must not send telemetry")) + ) + + def tearDown(self) -> None: + self._exit_stack.close() + + 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 assert_confirmation( + self, + collector: CollectingSink, + presentation_id: str, + presentation_values: dict[str, object] | None = None, + ) -> dict[str, object]: + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + details = error["details"] + self.assertEqual(details["presentation_id"], presentation_id) + self.assertTrue(details["message"].endswith("?"), details["message"]) + self.assertIn("confirmation_id", details) + for key, value in (presentation_values or {}).items(): + self.assertEqual(details["presentation_values"][key], value) + return details + + def confirmation_id_for( + self, + operation: str, + params: dict[str, object], + context: dict[str, object], + ) -> str: + return build_confirmation( + operation=operation, + params=params, + title="test", + message="test?", + action_title="test", + risk="remote_write", + summary="test", + context=context, + presentation_id="test", + ).confirmation_id + + @staticmethod + def fake_reboot_request(*_args, callbacks=None, **_kwargs) -> None: + if callbacks is not None: + if callbacks.set_stage is not None: + callbacks.set_stage("reboot") + if callbacks.update_fields is not None: + callbacks.update_fields(reboot_was_attempted=True) + if callbacks.add_debug_fields is not None: + callbacks.add_debug_fields( + reboot_request_strategy="ssh_shutdown_then_reboot", + ssh_reboot_attempted=True, + ssh_reboot_succeeded=True, + ) + + @staticmethod + def fake_reboot_request_and_wait(*_args, callbacks=None, **_kwargs) -> None: + AppApiTests.fake_reboot_request(callbacks=callbacks) + if callbacks is not None and callbacks.update_fields is not None: + callbacks.update_fields(device_came_back_after_reboot=True) + + 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("capabilities", 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_app_operation_context_builds_operation_callbacks(self) -> None: + collector = CollectingSink() + context = AppOperationContext("deploy", collector.sink) + callbacks = context.to_operation_callbacks() + + callbacks.set_stage("reboot") + callbacks.update_fields(reboot_was_attempted=True) + callbacks.add_debug_fields(reboot_request_strategy="ssh") + callbacks.log("reboot requested") + + self.assertEqual(context.current_stage, "reboot") + self.assertEqual(context.finish_fields["reboot_was_attempted"], True) + self.assertEqual(context.diagnostics.debug_fields["reboot_request_strategy"], "ssh") + self.assertEqual(collector.events_of_type("log")[0]["message"], "reboot requested") + + def test_app_operation_context_runtime_callbacks_remain_compatible(self) -> None: + collector = CollectingSink() + context = AppOperationContext("deploy", collector.sink) + + context.to_operation_callbacks().set_stage("reboot") + + self.assertEqual(context.current_stage, "reboot") + + 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("capabilities", "resolve_paths") + collector.sink.stage("deploy", "upload_payload") + collector.sink.stage("uninstall", "uninstall_payload") + collector.sink.stage("deploy", "reboot") + collector.sink.stage("fsck", "list_fsck_volumes") + + 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]) + self.assertEqual(stages[4]["risk"], "remote_read") + self.assertTrue(stages[4]["cancellable"]) + self.assertIn("description", stages[4]) + + 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"], "Found 2 metadata issue(s), 1 repairable.") + self.assertEqual(repair["summary_text"], "Found 2 metadata 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"], "Found 2 metadata issue(s), 1 repairable.") + self.assertEqual(repair["summary_text"], "Found 2 metadata 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": "capabilities", "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("set-telemetry", payload["operations"]) + self.assertIn("version-check", payload["operations"]) + self.assertIn("flash", payload["operations"]) + self.assertIn("reachability", payload["operations"]) + self.assertNotIn("telemetry-identity", payload["operations"]) + self.assertNotIn("paths", payload["operations"]) + self.assertIn("helper_version", payload) + self.assertIn("artifact_manifest_sha256", payload) + + def test_flash_backup_operation_returns_manifest_payload(self) -> None: + collector = CollectingSink() + manifest = { + "backup_dir": "/tmp/flash-backup", + "banks": [{"name": "primary"}, {"name": "secondary"}], + } + bundle = SimpleNamespace(manifest=manifest) + + with mock.patch("timecapsulesmb.app.ops.flash.load_request_config", return_value=object()): + with mock.patch("timecapsulesmb.app.ops.flash._resolve_flash_target", return_value=object()): + with mock.patch("timecapsulesmb.app.ops.flash.backup_flash", return_value=bundle) as backup_mock: + rc = service.run_api_request( + {"operation": "flash", "params": {"action": "backup", "credentials": {"password": "pw"}}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + backup_mock.assert_called_once() + self.assertIn("stage", backup_mock.call_args.kwargs) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["backup_dir"], "/tmp/flash-backup") + self.assertEqual(payload["counts"], {"banks": 2}) + + def test_flash_backup_accepts_request_scoped_password(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values( + {"TC_HOST": "root@10.0.0.2"}, + file_values={"TC_HOST": "root@10.0.0.2"}, + ) + manifest = { + "backup_dir": "/tmp/flash-backup", + "banks": [{"name": "primary"}], + } + bundle = SimpleNamespace(manifest=manifest) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=config): + with mock.patch( + "timecapsulesmb.app.ops.flash.require_connection_compatibility", + return_value=supported_compatibility("netbsd4be_samba4"), + ): + with mock.patch("timecapsulesmb.app.ops.flash.backup_flash", return_value=bundle) as backup_mock: + rc = service.run_api_request( + { + "operation": "flash", + "params": {"action": "backup", "credentials": {"password": "request-pw"}}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + target = backup_mock.call_args.kwargs["target"] + self.assertEqual(target.connection.password, "request-pw") + self.assertFalse(config.has_file_value("TC_PASSWORD")) + + def test_flash_backup_reports_unsupported_device_message_to_app(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values( + {"TC_HOST": "root@10.0.0.2"}, + file_values={"TC_HOST": "root@10.0.0.2"}, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=config): + with mock.patch( + "timecapsulesmb.app.ops.flash.require_connection_compatibility", + return_value=supported_compatibility("netbsd6_samba4"), + ): + with mock.patch("timecapsulesmb.app.ops.flash.backup_flash") as backup_mock: + rc = service.run_api_request( + { + "operation": "flash", + "params": {"action": "backup", "credentials": {"password": "request-pw"}}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "unsupported_device") + self.assertEqual(error["message"], FLASH_UNSUPPORTED_DEVICE_MESSAGE) + self.assertIn("https://github.com/jamesyc/TimeCapsuleSMB/issues/160", error["message"]) + backup_mock.assert_not_called() + + def test_flash_plan_operation_uses_saved_backup_without_device_config(self) -> None: + collector = CollectingSink() + manifest = { + "backup_dir": "/tmp/flash-backup", + "flash_plan": { + "mode": "check_apple", + "write_requested": False, + "already_satisfied": True, + "apple_match": { + "matched": True, + "template_source": "catalog", + "template_version": "7.8.1", + "template_product_id": "116", + "template_sha256": "template-sha", + "inner_sha256": "inner-sha", + "inner_size": 123, + "key_id": "key-one", + "inner_model": 116, + "inner_version": "0x00070801", + }, + }, + } + bundle = SimpleNamespace(manifest=manifest) + + with mock.patch("timecapsulesmb.app.ops.flash.plan_flash_from_backup", return_value=(bundle, object())) as plan_mock: + with mock.patch("timecapsulesmb.app.ops.flash.load_request_config", side_effect=AssertionError("plan should not load device config")): + rc = service.run_api_request( + { + "operation": "flash", + "params": { + "action": "plan", + "backup_dir": "/tmp/flash-backup", + "mode": "check_apple", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + plan_mock.assert_called_once() + self.assertEqual(plan_mock.call_args.kwargs["operation"], "check_apple") + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["mode"], "check_apple") + self.assertFalse(payload["write_requested"]) + self.assertEqual(payload["summary"], "Active firmware bank matches Apple stock firmware 7.8.1.") + self.assertEqual(payload["apple_firmware_match"]["matched"], True) + self.assertEqual(payload["apple_firmware_match"]["template_version"], "7.8.1") + self.assertIsNone(payload["firmware_payload"]) + + def test_flash_plan_payload_promotes_download_payload_and_saved_path(self) -> None: + payload = contracts.flash_plan_payload({ + "backup_dir": "/tmp/flash-backup", + "files": { + "secondary_download_only_basebinary_payload": "/tmp/flash-backup/secondary.download_only.basebinary", + }, + "flash_plan": { + "mode": "download_only", + "target_bank": "secondary", + "write_requested": False, + "already_satisfied": False, + "apple_match": { + "matched": False, + "template_source": "catalog", + "template_version": "7.8.1", + }, + "payload": { + "template_source": "catalog", + "template_path": "/Users/example/Library/Application Support/TimeCapsuleSMB/firmware.basebinary", + "template_product_id": "116", + "template_version": "7.8.1", + "template_sha256": "template-sha", + "payload_sha256": "payload-sha", + "payload_size": 456, + "expected_prefix_sha256": "prefix-sha", + "expected_prefix_size": 123, + "key_id": "key-one", + "inner_model": 116, + "inner_version": "0x00070801", + "inner_payload_size": 123, + }, + }, + }) + + self.assertEqual(payload["summary"], "Apple restore firmware validated (version 7.8.1, product 116).") + self.assertEqual(payload["firmware_payload"]["payload_sha256"], "payload-sha") + self.assertEqual( + payload["firmware_payload_path"], + "/tmp/flash-backup/secondary.download_only.basebinary", + ) + self.assertEqual(payload["apple_firmware_match"]["matched"], False) + + def test_flash_plan_rejects_backup_manifest_used_for_write(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + backup_dir = Path(tmp) + (backup_dir / "manifest.json").write_text(json.dumps({ + "write_outcome": { + "status": "validated", + "mode": "patch", + "write_may_have_modified_device": True, + }, + })) + collector = CollectingSink() + + rc = service.run_api_request( + { + "operation": "flash", + "params": { + "action": "plan", + "backup_dir": str(backup_dir), + "mode": "restore", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["message"], STALE_BACKUP_AFTER_WRITE_MESSAGE) + + def test_flash_backup_freshness_allows_noop_or_cancelled_write_outcomes(self) -> None: + for status in ("not_needed", "cancelled"): + with self.subTest(status=status): + require_backup_fresh_for_plan({ + "write_outcome": { + "status": status, + "write_may_have_modified_device": False, + }, + }) + + def test_flash_write_requires_confirmation_then_validates_and_writes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + backup_dir = Path(tmp) + manifest = { + "backup_dir": str(backup_dir), + "write_outcome": { + "status": "validated", + "mode": "patch", + "write_validated": True, + }, + } + target_bank = SimpleNamespace(name="primary", sha256="bank-sha") + plan = SimpleNamespace(already_satisfied=False, target_bank=target_bank) + bundle = SimpleNamespace(manifest=manifest, backup_dir=backup_dir) + target = SimpleNamespace(acp_host="10.0.0.2", connection=object()) + + def run(params: dict[str, object]) -> CollectingSink: + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.flash.plan_flash_from_backup", return_value=(bundle, plan)): + with mock.patch("timecapsulesmb.app.ops.flash.load_request_config", return_value=object()): + with mock.patch("timecapsulesmb.app.ops.flash._resolve_flash_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.flash.validate_live_target_matches_backup") as validate_mock: + with mock.patch("timecapsulesmb.app.ops.flash.write_flash_plan") as write_mock: + with mock.patch("timecapsulesmb.app.ops.flash.request_reboot") as reboot_mock: + rc = service.run_api_request( + {"operation": "flash", "params": params}, + collector.sink, + ) + collector.rc = rc # type: ignore[attr-defined] + collector.validate_mock = validate_mock # type: ignore[attr-defined] + collector.write_mock = write_mock # type: ignore[attr-defined] + collector.reboot_mock = reboot_mock # type: ignore[attr-defined] + return collector + + first = run({"action": "write", "backup_dir": str(backup_dir), "mode": "patch"}) + + self.assertEqual(first.rc, 1) # type: ignore[attr-defined] + details = self.assert_confirmation(first, "flash.patch_write", {"host": "10.0.0.2", "mode": "patch"}) + first.validate_mock.assert_not_called() # type: ignore[attr-defined] + first.write_mock.assert_not_called() # type: ignore[attr-defined] + + second = run({ + "action": "write", + "backup_dir": str(backup_dir), + "mode": "patch", + "confirmation_id": details["confirmation_id"], + }) + + self.assertEqual(second.rc, 0) # type: ignore[attr-defined] + second.validate_mock.assert_called_once() # type: ignore[attr-defined] + second.write_mock.assert_called_once() # type: ignore[attr-defined] + second.reboot_mock.assert_not_called() # type: ignore[attr-defined] + payload = self.assert_single_terminal_event(second, "result")["payload"] + self.assertEqual(payload["write_status"], "validated") + self.assertTrue(payload["write_validated"]) + self.assertEqual(payload["post_write_action"], "manual_power_cycle") + self.assertFalse(payload["reboot_requested"]) + + def test_flash_write_payload_restore_summary_mentions_manual_reboot_without_reboot_request(self) -> None: + payload = contracts.flash_write_payload({ + "backup_dir": "/tmp/flash-backup", + "write_outcome": { + "status": "validated", + "mode": "restore", + "write_validated": True, + "post_write_action": "manual_reboot", + }, + }) + + self.assertEqual(payload["summary"], "Flash restore write validated; manual reboot required.") + + def test_flash_restore_write_defaults_to_reboot_and_wait(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + backup_dir = Path(tmp) + manifest = { + "backup_dir": str(backup_dir), + "write_outcome": { + "status": "validated", + "mode": "restore", + "write_validated": True, + "write_may_have_modified_device": True, + }, + } + target_bank = SimpleNamespace(name="primary", sha256="bank-sha") + plan = SimpleNamespace(already_satisfied=False, target_bank=target_bank) + bundle = SimpleNamespace(manifest=manifest, backup_dir=backup_dir) + connection = object() + target = SimpleNamespace(acp_host="10.0.0.2", connection=connection) + collector = CollectingSink() + params = { + "action": "write", + "backup_dir": str(backup_dir), + "mode": "restore", + } + params["confirmation_id"] = self.confirmation_id_for( + "flash", + params, + { + "host": "10.0.0.2", + "backup_dir": str(backup_dir), + "mode": "restore", + "target_bank": "primary", + "target_sha256": "bank-sha", + "reboot_after_write": True, + "wait_after_reboot": True, + }, + ) + + with mock.patch("timecapsulesmb.app.ops.flash.plan_flash_from_backup", return_value=(bundle, plan)): + with mock.patch("timecapsulesmb.app.ops.flash.load_request_config", return_value=object()): + with mock.patch("timecapsulesmb.app.ops.flash._resolve_flash_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.flash.validate_live_target_matches_backup"): + with mock.patch("timecapsulesmb.app.ops.flash.write_flash_plan"): + with mock.patch("timecapsulesmb.app.ops.flash.request_reboot_and_wait") as reboot_wait: + rc = service.run_api_request( + { + "operation": "flash", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + reboot_wait.assert_called_once() + self.assertIs(reboot_wait.call_args.args[0], connection) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["post_write_action"], "ssh_reboot") + self.assertTrue(payload["reboot_requested"]) + self.assertTrue(payload["rebooted"]) + self.assertTrue(payload["waited_after_reboot"]) + self.assertEqual(payload["summary"], "Flash restore write validated; device rebooted.") + + def test_flash_patch_write_rejects_reboot_request(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.flash.plan_flash_from_backup") as plan_flash: + rc = service.run_api_request( + { + "operation": "flash", + "params": { + "action": "write", + "backup_dir": "/tmp/flash-backup", + "mode": "patch", + "reboot_after_write": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + plan_flash.assert_not_called() + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn("Flash patch cannot request reboot", error["message"]) + + def test_set_telemetry_operation_updates_bootstrap_preference(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + bootstrap_path = Path(tmp) / ".bootstrap" + app_paths = SimpleNamespace(bootstrap_path=bootstrap_path) + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.readiness.resolve_app_paths", return_value=app_paths): + rc = service.run_api_request( + {"operation": "set-telemetry", "params": {"enabled": False}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + stages = collector.events_of_type("stage") + self.assertEqual([stage["stage"] for stage in stages], ["resolve_paths", "write_bootstrap"]) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertFalse(payload["telemetry_enabled"]) + self.assertEqual(payload["bootstrap_path"], str(bootstrap_path)) + self.assertIn("TELEMETRY=false", bootstrap_path.read_text()) + + def test_telemetry_identity_operation_is_not_exposed(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "telemetry-identity", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "unknown_operation") + + def test_version_check_operation_returns_structured_update_status(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app_paths = SimpleNamespace(version_check_cache_path=Path(tmp) / "version-cache.json") + collector = CollectingSink() + result = VersionCheckResult( + should_block=True, + checked_url="https://example.invalid/version.json", + message="Please update.", + download_url="https://example.invalid/download", + local_version_code=20000, + current_version=20005, + min_supported_version=20005, + latest_tag="v2.0.5", + source="network", + ) + + with mock.patch("timecapsulesmb.app.ops.readiness.resolve_app_paths", return_value=app_paths): + with mock.patch("timecapsulesmb.app.ops.readiness.check_client_version", return_value=result) as check: + rc = service.run_api_request( + { + "operation": "version-check", + "params": {"url": "https://example.invalid/version.json"}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + check.assert_called_once_with( + url="https://example.invalid/version.json", + cache_path=app_paths.version_check_cache_path, + ) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertTrue(payload["should_block"]) + self.assertTrue(payload["update_available"]) + self.assertEqual(payload["current_version"], 20005) + self.assertEqual(payload["latest_tag"], "v2.0.5") + self.assertEqual(payload["source"], "network") + self.assertEqual(payload["summary"], "Update required.") + + def test_version_check_payload_reports_optional_update(self) -> None: + payload = contracts.version_check_payload( + VersionCheckResult( + should_block=False, + local_version_code=20000, + current_version=20005, + min_supported_version=19999, + source="network", + ) + ) + + self.assertFalse(payload["should_block"]) + self.assertTrue(payload["update_available"]) + self.assertEqual(payload["summary"], "Update available.") + + def test_version_check_payload_reports_current_when_versions_match(self) -> None: + payload = contracts.version_check_payload( + VersionCheckResult( + should_block=False, + local_version_code=20005, + current_version=20005, + min_supported_version=19999, + source="network", + ) + ) + + self.assertFalse(payload["should_block"]) + self.assertFalse(payload["update_available"]) + self.assertEqual(payload["summary"], "TimeCapsuleSMB is up to date.") + + def test_version_check_payload_preserves_unavailable_summary(self) -> None: + payload = contracts.version_check_payload( + VersionCheckResult( + should_block=False, + local_version_code=20000, + current_version=None, + min_supported_version=None, + source="unavailable", + ) + ) + + self.assertFalse(payload["should_block"]) + self.assertFalse(payload["update_available"]) + self.assertEqual(payload["summary"], "Version metadata is unavailable.") + + def test_version_check_operation_rejects_non_http_url(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request( + {"operation": "version-check", "params": {"url": "file:///tmp/version.json"}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + + def test_missing_params_defaults_to_empty_object(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "capabilities"}, collector.sink) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertEqual(result["operation"], "capabilities") + + 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": "capabilities", "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, _context, 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, _context): + 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_dispatcher_emits_api_operation_telemetry(self) -> None: + collector = CollectingSink() + + def run_fsck(params, context): + context.stage("run_fsck") + return service.OperationResult(True, { + "device": "/dev/dk2", + "mountpoint": "/Volumes/Data", + "returncode": 0, + "reboot_requested": True, + "waited": True, + "verified": True, + }) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch.dict(os.environ, {"TCAPSULE_CLIENT": "macos_gui"}, clear=False): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request( + { + "operation": "fsck", + "params": { + "volume": "Data", + "dry_run": False, + "no_reboot": False, + "no_wait": False, + "mount_wait": 30, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(self._telemetry_client.emit.call_count, 2) + started = self._telemetry_client.emit.call_args_list[0] + finished = self._telemetry_client.emit.call_args_list[1] + self.assertEqual(started.args, ("fsck_started",)) + self.assertEqual(started.kwargs["operation"], "fsck") + self.assertEqual(started.kwargs["phase"], "started") + self.assertEqual(started.kwargs["entrypoint"], "api") + self.assertEqual(started.kwargs["client"], "macos_gui") + self.assertEqual(started.kwargs["options"], { + "dry_run": False, + "mount_wait": 30, + "no_reboot": False, + "no_wait": False, + }) + self.assertEqual(finished.args, ("fsck_finished",)) + self.assertEqual(finished.kwargs["phase"], "finished") + self.assertEqual(finished.kwargs["operation_id"], started.kwargs["operation_id"]) + self.assertEqual(finished.kwargs["result"], "success") + self.assertEqual(finished.kwargs["stage"], "run_fsck") + self.assertEqual(finished.kwargs["risk"], "destructive") + self.assertEqual(finished.kwargs["details"]["volume"], "Data") + self.assertEqual(finished.kwargs["details"]["fsck_device"], "/dev/dk2") + self.assertEqual(finished.kwargs["details"]["fsck_mountpoint"], "/Volumes/Data") + self.assertEqual(finished.kwargs["details"]["returncode"], 0) + self.assertTrue(finished.kwargs["details"]["reboot_requested"]) + self.assertTrue(finished.kwargs["details"]["waited"]) + self.assertTrue(finished.kwargs["details"]["verified"]) + + def test_dispatcher_defaults_api_telemetry_client_when_environment_is_unset(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("run_fsck") + return service.OperationResult(True, {"returncode": 0, "summary": "Disk repair completed with fsck."}) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch.dict(os.environ, {"TCAPSULE_CLIENT": ""}, clear=False): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + started = self._telemetry_client.emit.call_args_list[0] + self.assertEqual(started.kwargs["entrypoint"], "api") + self.assertEqual(started.kwargs["client"], "api") + + def test_dispatcher_emits_cancelled_telemetry_on_keyboard_interrupt(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("run_fsck") + raise KeyboardInterrupt + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 130) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "cancelled") + finished = self._telemetry_client.emit.call_args_list[-1].kwargs + self.assertEqual(finished["result"], "cancelled") + self.assertEqual(finished["stage"], "run_fsck") + self.assertIn("Cancelled by user", finished["error"]) + + def test_dispatcher_emits_failure_telemetry_on_system_exit(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("run_fsck") + raise SystemExit("Disk repair stopped early during fsck") + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "operation_failed") + finished = self._telemetry_client.emit.call_args_list[-1].kwargs + self.assertEqual(finished["result"], "failure") + self.assertEqual(finished["stage"], "run_fsck") + self.assertIn("Disk repair stopped early during fsck", finished["error"]) + + def test_dispatcher_emits_app_operation_finish_fields_in_telemetry(self) -> None: + collector = CollectingSink() + + def run_deploy(_params, context): + context.stage("verify_runtime_reboot") + context.update_fields( + device_family="netbsd6_samba4", + device_os_version="NetBSD 6.0 (earmv4)", + device_model="TimeCapsule8,119", + device_syap="119", + nbns_enabled=False, + reboot_was_attempted=True, + device_came_back_after_reboot=True, + ) + return service.OperationResult(True, contracts.deploy_result_payload( + payload_dir="/Volumes/dk2/.samba4", + rebooted=True, + reboot_requested=True, + waited=True, + verified=True, + payload_family="netbsd6_samba4", + )) + + with mock.patch.dict(service.OPERATIONS, {"deploy": run_deploy}): + with mock.patch.dict(os.environ, {"TCAPSULE_CLIENT": "macos_gui"}, clear=False): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({"TC_CONFIGURE_ID": "cfg-1"})): + rc = service.run_api_request( + {"operation": "deploy", "params": {"nbns_enabled": False}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(self._telemetry_factory.call_args.kwargs["nbns_enabled"], False) + finished = self._telemetry_client.emit.call_args_list[1].kwargs + self.assertEqual(finished["result"], "success") + self.assertEqual(finished["stage"], "verify_runtime_reboot") + self.assertEqual(finished["device_family"], "netbsd6_samba4") + self.assertEqual(finished["device_os_version"], "NetBSD 6.0 (earmv4)") + self.assertEqual(finished["device_model"], "TimeCapsule8,119") + self.assertEqual(finished["device_syap"], "119") + self.assertEqual(finished["nbns_enabled"], False) + self.assertEqual(finished["reboot_was_attempted"], True) + self.assertEqual(finished["device_came_back_after_reboot"], True) + self.assertEqual(finished["details"]["payload_family"], "netbsd6_samba4") + self.assertEqual(finished["details"]["rebooted"], True) + self.assertEqual(finished["details"]["verified"], True) + + def test_dispatcher_emits_flash_operation_details_in_telemetry(self) -> None: + collector = CollectingSink() + + def run_flash(_params, context): + context.stage("post_write_validation") + context.update_fields( + flash_action="write", + flash_mode="restore", + target_bank="primary", + reboot_after_write=True, + wait_after_reboot=True, + ) + return service.OperationResult(True, contracts.flash_write_payload({ + "backup_dir": "/tmp/flash-backup", + "write_outcome": { + "status": "written", + "mode": "restore", + "target_bank": "primary", + "write_validated": True, + "write_may_have_modified_device": True, + "post_write_action": "ssh_reboot", + "reboot_requested": True, + "rebooted": True, + "waited_after_reboot": True, + }, + })) + + with mock.patch.dict(service.OPERATIONS, {"flash": run_flash}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request( + { + "operation": "flash", + "params": { + "action": "write", + "mode": "restore", + "backup_dir": "/tmp/flash-backup", + "reboot_after_write": True, + "wait_after_reboot": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + finished = self._telemetry_client.emit.call_args_list[-1].kwargs + self.assertEqual(finished["result"], "success") + self.assertEqual(finished["stage"], "post_write_validation") + self.assertEqual(finished["details"]["flash_action"], "write") + self.assertEqual(finished["details"]["flash_mode"], "restore") + self.assertTrue(finished["details"]["backup_dir_provided"]) + self.assertEqual(finished["details"]["write_status"], "written") + self.assertTrue(finished["details"]["write_validated"]) + self.assertEqual(finished["details"]["target_bank"], "primary") + self.assertTrue(finished["details"]["reboot_requested"]) + self.assertTrue(finished["details"]["waited_after_reboot"]) + + def test_dispatcher_emits_confirmation_required_telemetry(self) -> None: + collector = CollectingSink() + + def run_fsck(params, context): + context.stage("select_fsck_volume") + raise service.AppConfirmationRequired(build_confirmation( + operation="fsck", + params=params, + title="Confirm fsck", + message="Run fsck on the selected HFS volume and reboot the device?", + action_title="Run fsck", + risk="destructive", + summary="Filesystem check and repair", + context={"volume": params.get("volume")}, + presentation_id="fsck.reboot", + presentation_values={"volume": params.get("volume")}, + )) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request( + {"operation": "fsck", "params": {"volume": "Data"}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(self._telemetry_client.emit.call_count, 2) + finished_kwargs = self._telemetry_client.emit.call_args_list[1].kwargs + self.assertEqual(finished_kwargs["result"], "confirmation_required") + self.assertIsNone(finished_kwargs["error"]) + self.assertEqual(finished_kwargs["risk"], "destructive") + self.assertEqual(finished_kwargs["details"]["presentation_id"], "fsck.reboot") + self.assertEqual(finished_kwargs["details"]["presentation_values"]["volume"], "Data") + + def test_dispatcher_does_not_emit_readiness_operation_telemetry(self) -> None: + collector = CollectingSink() + self._telemetry_factory.reset_mock() + + rc = service.run_api_request({"operation": "capabilities", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + self._telemetry_factory.assert_not_called() + + def test_app_api_telemetry_tests_do_not_open_network_connections(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("run_fsck") + return service.OperationResult(True, {"returncode": 0, "summary": "Disk repair completed with fsck."}) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + self._telemetry_factory.assert_called_once() + self.assertEqual(self._telemetry_client.emit.call_count, 2) + self._telemetry_urlopen.assert_not_called() + + def test_dispatcher_failure_telemetry_uses_app_operation_context(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("read_mast") + context.config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + context.connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + context.add_debug_fields(mast_candidates=[{"volume": "Data"}]) + raise service.AppOperationError("No writable MaSt volumes were found.", code="remote_error") + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + telemetry_error = self._telemetry_client.emit.call_args_list[-1].kwargs["error"] + self.assertIn("No writable MaSt volumes were found.", telemetry_error) + self.assertIn("Debug context:", telemetry_error) + self.assertIn("command=fsck", telemetry_error) + self.assertIn("stage=read_mast", telemetry_error) + self.assertIn("host=root@10.0.0.2", telemetry_error) + self.assertIn("TC_HOST=root@10.0.0.2", telemetry_error) + self.assertIn("mast_candidates=[{volume:Data}]", telemetry_error) + self.assertNotIn("TC_PASSWORD=pw", telemetry_error) + + def test_dispatcher_unsuccessful_result_telemetry_uses_app_operation_context(self) -> None: + collector = CollectingSink() + + def run_fsck(_params, context): + context.stage("run_fsck") + context.config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + context.connection = SshConnection("root@10.0.0.2", "pw", "") + return service.OperationResult(False, {"error": "Disk repair exited with fsck status 8"}) + + with mock.patch.dict(service.OPERATIONS, {"fsck": run_fsck}): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + result = self.assert_single_terminal_event(collector, "result") + self.assertFalse(result["ok"]) + self.assertNotIn("Debug context:", result["payload"]["error"]) + telemetry_error = self._telemetry_client.emit.call_args_list[-1].kwargs["error"] + self.assertIn("Disk repair exited with fsck status 8", telemetry_error) + self.assertIn("Debug context:", telemetry_error) + self.assertIn("command=fsck", telemetry_error) + self.assertIn("stage=run_fsck", telemetry_error) + self.assertNotIn("TC_PASSWORD=pw", telemetry_error) + + 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.discovery.discover_snapshot_merged_detailed", + return_value=(snapshot, SimpleNamespace()), + ): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + 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 device(s).") + self.assertEqual(self._telemetry_client.emit.call_count, 2) + started = self._telemetry_client.emit.call_args_list[0].kwargs + finished = self._telemetry_client.emit.call_args_list[1].kwargs + self.assertEqual(started["operation"], "discover") + self.assertEqual(started["entrypoint"], "api") + self.assertEqual(started["options"], {"timeout": 0.1}) + self.assertEqual(finished["result"], "success") + self.assertEqual(finished["stage"], "bonjour_discovery") + self.assertEqual(finished["discovery_instance_count"], 1) + self.assertEqual(finished["discovery_resolved_count"], 1) + self.assertEqual(finished["discovery_device_count"], 1) + self.assertEqual(finished["details"]["instance_count"], 1) + self.assertEqual(finished["details"]["resolved_count"], 1) + self.assertEqual(finished["details"]["device_count"], 1) + + 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.discovery.discover_snapshot_merged_detailed", + return_value=(snapshot, SimpleNamespace()), + ): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + 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.discovery.discover_snapshot_merged_detailed") as discover: + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + 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.discovery.discover_snapshot_merged_detailed", + return_value=(snapshot, SimpleNamespace()), + ) as discover: + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(bootstrap_path=Path("/tmp/bootstrap"))): + with mock.patch("timecapsulesmb.app.service.ensure_install_id"): + with mock.patch("timecapsulesmb.app.service.load_optional_env_config", return_value=AppConfig.from_values({})): + 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_canonicalizes_default_ssh_port_before_probe_and_save(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": "root@10.0.0.2:22", + "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_ATA_IDLE_SECONDS=42\n" + "TC_ATA_STANDBY=0\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.assertEqual(values["TC_ATA_IDLE_SECONDS"], "42") + self.assertEqual(values["TC_ATA_STANDBY"], "0") + 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_ata_params_write_drive_timer_settings(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", + "ata_idle_seconds": 0, + "ata_standby": 0, + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_ATA_IDLE_SECONDS"], "0") + self.assertEqual(values["TC_ATA_STANDBY"], "0") + + def test_configure_blank_ata_standby_clears_existing_timer_setting(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + config_path.write_text("TC_HOST=root@10.0.0.2\nTC_ATA_IDLE_SECONDS=300\nTC_ATA_STANDBY=120\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": "goodpw", + "ata_standby": "", + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_ATA_IDLE_SECONDS"], "300") + self.assertEqual(values["TC_ATA_STANDBY"], "") + + def test_configure_requires_confirmation_before_enabling_ssh(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.services.acp_ssh.enable_ssh_with_identity_preflight") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_not_called() + self.assertFalse(config_path.exists()) + details = self.assert_confirmation( + collector, + "configure.enable_ssh_reboot", + {"device_name": "10.0.0.2", "requires_reboot": True}, + ) + self.assertEqual(details["context"]["host"], "root@10.0.0.2") + self.assertNotIn("secret", json.dumps(collector.events)) + + def test_configure_confirmed_ssh_enable_reprobes_and_writes_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + first_collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + }, + }, + first_collector.sink, + ) + confirmation_id = self.assert_confirmation(first_collector, "configure.enable_ssh_reboot")["confirmation_id"] + + confirmed_collector = CollectingSink() + with mock.patch( + "timecapsulesmb.app.ops.configure.probe_connection_state", + side_effect=[unreachable_probed_state(), probed_state()], + ) as probe: + with mock.patch("timecapsulesmb.services.acp_ssh.read_identity", return_value=ACPIdentity(syap=119)) as read_identity: + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh") as enable_ssh: + with mock.patch("timecapsulesmb.services.configure.wait_for_tcp_port_state", return_value=True) as wait_for_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + "confirmation_id": confirmation_id, + }, + }, + confirmed_collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(probe.call_count, 2) + read_identity.assert_called_once_with("10.0.0.2", "secret", timeout=10.0) + enable_ssh.assert_called_once() + wait_for_ssh.assert_called_once_with( + "10.0.0.2", + 22, + expected_state=True, + timeout_seconds=180, + service_name="SSH port", + log=mock.ANY, + ) + self.assertEqual(values["TC_HOST"], "root@10.0.0.2") + stages = [event["stage"] for event in confirmed_collector.events_of_type("stage")] + self.assertLess(stages.index("acp_identity_probe"), stages.index("acp_enable_ssh")) + self.assertNotIn("secret", json.dumps(confirmed_collector.events)) + + def test_configure_enable_ssh_false_fails_without_confirmation(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.services.acp_ssh.enable_ssh_with_identity_preflight") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "secret", + "enable_ssh": False, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_not_called() + self.assertFalse(config_path.exists()) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "remote_error") + self.assertNotEqual(error.get("details", {}).get("presentation_id"), "configure.enable_ssh_reboot") + + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + params = { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "badpw", + } + params["confirmation_id"] = self.confirmation_id_for( + "configure", + params, + { + "host": "root@10.0.0.2", + "device_name": "10.0.0.2", + "requires_reboot": True, + }, + ) + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.services.acp_ssh.read_identity", side_effect=ACPAuthError("bad password")): + rc = service.run_api_request( + { + "operation": "configure", + "params": params, + }, + 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_acp_identity_preflight_connection_failure(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + params = { + "config": str(config_path), + "host": "root@10.0.0.99", + "password": "pw", + } + params["confirmation_id"] = self.confirmation_id_for( + "configure", + params, + { + "host": "root@10.0.0.99", + "device_name": "10.0.0.99", + "requires_reboot": True, + }, + ) + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.services.acp_ssh.read_identity", side_effect=ACPConnectionError("connection failed")): + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_not_called() + self.assertFalse(config_path.exists()) + error = collector.events_of_type("error")[0] + self.assertEqual(error["code"], "remote_error") + self.assertEqual(error["recovery"]["title"], "AirPort not reachable at this address") + self.assertIn("No AirPort ACP service responded", error["message"]) + + 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" + params = { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "pw", + "ssh_wait_timeout": True, + } + params["confirmation_id"] = self.confirmation_id_for( + "configure", + params, + { + "host": "root@10.0.0.2", + "device_name": "10.0.0.2", + "requires_reboot": True, + }, + ) + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh_with_identity_preflight") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_not_called() + 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.common.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.common.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_ignores_legacy_bonjour_timeout_param(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.ops.common.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.common.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.assertNotIn("bonjour_timeout", checks.call_args.kwargs) + + def test_doctor_uses_request_credentials_without_requiring_saved_password(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values( + { + "TC_HOST": "root@10.0.0.2", + "TC_SSH_OPTS": "-o foo", + }, + file_values={ + "TC_HOST": "root@10.0.0.2", + "TC_SSH_OPTS": "-o foo", + }, + ) + + def fake_run_doctor_checks(config_arg, **_kwargs): + self.assertEqual(config_arg.get("TC_PASSWORD"), "keychain-pw") + self.assertFalse(config_arg.has_file_value("TC_PASSWORD")) + return [], False + + with mock.patch("timecapsulesmb.app.ops.common.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.run_doctor_checks", side_effect=fake_run_doctor_checks): + rc = service.run_api_request( + { + "operation": "doctor", + "params": { + "skip_ssh": True, + "credentials": {"password": "keychain-pw"}, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertTrue(result["ok"]) + self.assertNotIn("keychain-pw", json.dumps(collector.events)) + + 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.common.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.common.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_doctor_failure_telemetry_includes_shared_debug_context(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["debug_fields"]["bonjour_expected"] = {"instance_name": "Home"} + kwargs["debug_fields"]["bonjour_zeroconf"] = {"instance_count": 0, "ip_version": "V4Only"} + result = CheckResult("FAIL", "no discovered _smb._tcp instance matched expected device instance 'Home'") + kwargs["on_result"](result) + return [result], True + + with mock.patch("timecapsulesmb.app.ops.common.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.common.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) + finished_kwargs = self._telemetry_client.emit.call_args_list[-1].kwargs + telemetry_error = finished_kwargs["error"] + self.assertIn("Doctor failures:", telemetry_error) + self.assertIn("Discovery context:", telemetry_error) + self.assertIn("Debug context:", telemetry_error) + self.assertIn("command=doctor", telemetry_error) + self.assertIn("stage=run_checks", telemetry_error) + self.assertIn("host=root@10.0.0.2", telemetry_error) + self.assertIn("TC_HOST=root@10.0.0.2", telemetry_error) + self.assertIn("bonjour_zeroconf={instance_count:0,ip_version:V4Only}", telemetry_error) + self.assertNotIn("TC_PASSWORD=pw", telemetry_error) + + payload_error = collector.events_of_type("result")[0]["payload"]["error"] + self.assertIn("Doctor failures:", payload_error) + self.assertNotIn("Debug context:", payload_error) + + 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.common.load_env_config", return_value=AppConfig.from_values({ + "TC_HOST": "root@10.0.0.2", + "TC_PASSWORD": "pw", + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true", + "TC_ANY_PROTOCOL": "true", + "TC_DEBUG_LOGGING": "true", + })): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.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}}, + 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"]["startup_mode"], "reboot_then_verify") + self.assertEqual(result["payload"]["payload_family"], "netbsd6_samba4") + self.assertEqual(result["payload"]["schema_version"], 1) + + def test_deploy_dry_run_no_wait_returns_request_only_plan(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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.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, "no_wait": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + payload = collector.events_of_type("result")[0]["payload"] + self.assertTrue(payload["reboot_required"]) + self.assertFalse(payload["wait_after_reboot"]) + self.assertEqual(payload["reboot_request"]["follow_up"], ["return_after_reboot_request"]) + self.assertEqual(payload["post_deploy_checks"], []) + + def test_deploy_netbsd4_dry_run_no_wait_does_not_plan_activation(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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": True, "no_wait": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["startup_mode"], "reboot_then_activate") + self.assertFalse(payload["wait_after_reboot"]) + self.assertEqual(payload["activation_actions"], []) + self.assertEqual(payload["post_deploy_checks"], []) + + 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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.reboot", + { + "device_name": "Time Capsule", + "requires_reboot": True, + "no_reboot": False, + "no_wait": False, + "startup_mode": "reboot_then_verify", + }, + ) + 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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.netbsd4", + { + "device_name": "Time Capsule", + "netbsd4": True, + "no_reboot": False, + "no_wait": False, + "startup_mode": "reboot_then_activate", + }, + ) + read_mast.assert_not_called() + remote_actions.assert_not_called() + + def test_deploy_no_wait_confirmation_uses_reboot_request_copy(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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_wait": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.reboot_no_wait", + { + "device_name": "Time Capsule", + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + "startup_mode": "reboot_then_verify", + }, + ) + read_mast.assert_not_called() + remote_actions.assert_not_called() + + def test_deploy_netbsd4_no_wait_confirmation_does_not_promise_activation(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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_wait": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + details = self.assert_confirmation( + collector, + "deploy.netbsd4_no_wait", + { + "device_name": "Time Capsule", + "netbsd4": True, + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + "startup_mode": "reboot_then_activate", + }, + ) + self.assertIn("without running Samba activation", details["message"]) + 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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.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_confirmation( + collector, + "deploy.activate_now", + { + "device_name": "Time Capsule", + "netbsd4": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, + ) + self.assertEqual(error["action_title"], "Deploy and start SMB") + read_mast.assert_not_called() + + def test_deploy_no_reboot_no_wait_confirmation_treats_no_wait_as_inapplicable(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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn") as read_mast: + rc = service.run_api_request( + { + "operation": "deploy", + "params": {"dry_run": False, "no_reboot": True, "no_wait": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.activate_now", + { + "device_name": "Time Capsule", + "netbsd4": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, + ) + read_mast.assert_not_called() + + def test_deploy_netbsd4_no_reboot_uses_activate_now_confirmation(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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation( + collector, + "deploy.activate_now", + { + "device_name": "Time Capsule", + "netbsd4": True, + "requires_reboot": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, + ) + read_mast.assert_not_called() + remote_actions.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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.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.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.services.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.services.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.services.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=managed_runtime_probe()): + 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.common.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_rejects_invalid_ata_overrides_before_remote_connection(self) -> None: + for field, value, expected in ( + ("ata_idle_seconds", "bad", "ata_idle_seconds must be an integer"), + ("ata_standby", "bad", "ata_standby must be an integer"), + ): + with self.subTest(field=field): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.ops.common.load_env_config") as load_config: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": True, + field: value, + }, + }, + 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(expected, error["message"]) + + def test_deploy_no_reboot_uploads_and_activates_without_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) + params = { + "dry_run": False, + "no_reboot": True, + "internal_share_use_disk_root": False, + "any_protocol": False, + "debug_logging": False, + "ata_idle_seconds": 0, + "ata_standby": 0, + } + params["confirmation_id"] = self.confirmation_id_for( + "deploy", + params, + { + "host": "root@10.0.0.2", + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "requires_reboot": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.services.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.services.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.services.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.services.deploy.request_reboot_and_wait") as wait: + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=managed_runtime_probe()) as verify_runtime: + with mock.patch("timecapsulesmb.services.deploy.render_flash_runtime_config", return_value="runtime\n") as render_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + upload.assert_called_once() + upload_sources = upload.call_args.kwargs["source_resolver"] + self.assertIn("packaged:boot.sh", upload_sources) + self.assertIn("packaged:manager.sh", upload_sources) + self.assertNotIn("packaged:start-samba.sh", upload_sources) + self.assertNotIn("packaged:watchdog.sh", upload_sources) + self.assertEqual(remote_actions.call_count, 3) + wait.assert_not_called() + verify_runtime.assert_called_once() + render_runtime.assert_called_once() + self.assertEqual(render_runtime.call_args.kwargs["internal_share_use_disk_root"], False) + self.assertEqual(render_runtime.call_args.kwargs["any_protocol"], False) + self.assertEqual(render_runtime.call_args.kwargs["debug_logging"], False) + self.assertEqual(render_runtime.call_args.kwargs["ata_idle_seconds"], 0) + self.assertEqual(render_runtime.call_args.kwargs["ata_standby"], 0) + self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) + self.assertEqual(collector.events_of_type("result")[0]["payload"]["verified"], True) + + def test_deploy_emits_grouped_upload_stages_before_each_upload_group(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) + params = {"dry_run": False, "no_reboot": True} + params["confirmation_id"] = self.confirmation_id_for( + "deploy", + params, + { + "host": "root@10.0.0.2", + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "requires_reboot": False, + "no_reboot": True, + "no_wait": False, + "startup_mode": "activate_now", + }, + ) + + def fake_upload(plan, *, connection, source_resolver, on_uploading=None): + for transfer in plan.uploads: + if on_uploading is not None: + on_uploading(transfer) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.services.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.services.deploy.upload_deployment_payload", side_effect=fake_upload): + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.services.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=managed_runtime_probe()): + rc = service.run_api_request( + { + "operation": "deploy", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + stages = [event["stage"] for event in collector.events_of_type("stage")] + upload_stages = [stage for stage in stages if str(stage).startswith("upload_")] + self.assertEqual( + upload_stages, + [ + "upload_smbd", + "upload_mdns_advertiser", + "upload_nbns_advertiser", + "upload_boot_files", + "upload_runtime_config", + "upload_samba_accounts", + ], + ) + + 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) + params = {"dry_run": False, "no_wait": True} + params["confirmation_id"] = self.confirmation_id_for( + "deploy", + params, + { + "host": "root@10.0.0.2", + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + "startup_mode": "reboot_then_verify", + }, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.services.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.services.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.services.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.services.deploy.request_reboot", side_effect=self.fake_reboot_request) as reboot: + with mock.patch("timecapsulesmb.services.deploy.request_reboot_and_wait") as wait: + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": params, + }, + 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) + finished = self._telemetry_client.emit.call_args_list[-1].kwargs + self.assertEqual(finished["reboot_was_attempted"], True) + self.assertEqual(finished["device_came_back_after_reboot"], False) + + def test_deploy_netbsd4_no_wait_requests_reboot_without_activation(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"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + params = {"dry_run": False, "no_wait": True} + params["confirmation_id"] = self.confirmation_id_for( + "deploy", + params, + { + "host": "root@10.0.0.2", + "payload_family": "netbsd4be_samba4", + "netbsd4": True, + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + "startup_mode": "reboot_then_activate", + }, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.services.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.services.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.services.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.services.deploy.request_reboot", side_effect=self.fake_reboot_request) as reboot: + with mock.patch("timecapsulesmb.services.activation.probe_netbsd4_rc_local_autostart_conn") as autostart_probe: + rc = service.run_api_request( + { + "operation": "deploy", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertFalse(connection.remote_has_scp) + reboot.assert_called_once() + autostart_probe.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) + params = {"dry_run": False, "no_wait": True} + params["confirmation_id"] = self.confirmation_id_for( + "deploy", + params, + { + "host": "root@10.0.0.2", + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + "startup_mode": "reboot_then_verify", + }, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.services.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.services.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.services.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.services.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.services.deploy.request_reboot", side_effect=RebootFlowError("SSH reboot request failed: ssh command failed with rc=255", "request_failed")) as reboot: + with mock.patch("timecapsulesmb.services.deploy.request_reboot_and_wait") as wait: + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": params, + }, + 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_reboot_and_wait_records_lifecycle_fields(self) -> None: + from timecapsulesmb.services.reboot import request_reboot_and_wait + + collector = CollectingSink() + context = AppOperationContext("deploy", collector.sink) + context.update_fields(reboot_was_attempted=False, device_came_back_after_reboot=False) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + reboot = mock.Mock() + wait = mock.Mock(side_effect=[True, True]) + request_reboot_and_wait( + connection, + strategy="ssh_shutdown_then_reboot", + callbacks=context.to_operation_callbacks(), + reboot_no_down_message="device did not go down", + reboot_up_timeout_message="Timed out waiting for SSH after reboot.", + down_timeout_seconds=60, + up_timeout_seconds=240, + request_reboot_func=reboot, + wait_for_ssh_state=wait, + ) + + reboot.assert_called_once() + self.assertEqual([call.kwargs["expected_up"] for call in wait.call_args_list], [False, True]) + self.assertEqual(context.finish_fields["reboot_was_attempted"], True) + self.assertEqual(context.finish_fields["device_came_back_after_reboot"], True) + self.assertEqual(context.diagnostics.debug_fields["reboot_request_strategy"], "ssh_shutdown_then_reboot") + self.assertEqual(context.diagnostics.debug_fields["ssh_reboot_attempted"], True) + self.assertEqual(context.diagnostics.debug_fields["ssh_reboot_succeeded"], True) + + def test_deploy_verify_runtime_failure_adds_runtime_logs_to_error_context(self) -> None: + from timecapsulesmb.app.ops import deploy as deploy_ops + + collector = CollectingSink() + context = AppOperationContext("deploy", collector.sink) + connection = SshConnection("root@169.254.44.9", "pw", "-o foo") + smbd = readiness_result(True, "managed smbd ready", ("PASS:managed smbd ready",)) + mdns = readiness_result( + False, + "managed mDNS takeover probe timed out", + ("FAIL:managed mDNS takeover probe timed out",), + ) + verification = ManagedRuntimeProbeResult( + ready=False, + detail="runtime verification timed out after 200s; managed smbd ready; managed mDNS takeover probe timed out", + smbd=smbd, + mdns=mdns, + extra_steps=(ProbeStepResult("runtime_timeout", "fail", "runtime verification timed out after 200s"),), + ) + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=verification): + with mock.patch( + "timecapsulesmb.services.runtime_verification.read_runtime_log_tails_conn", + return_value={ + "remote_manager_log_tail": "manager: mDNS startup deferred; no usable address has appeared yet", + "remote_mdns_log_tail": "mdns: before interface probe", + }, + ): + with mock.patch( + "timecapsulesmb.services.runtime_verification.read_remote_network_diagnostics_conn", + return_value={ + "remote_network_config": {"ssh_target_host": "169.254.44.9"}, + "remote_network_target_ip_matches": [], + }, + ): + with self.assertRaises(AppOperationError) as raised: + deploy_ops.verify_runtime( + context, + connection, + stage="verify_runtime_activation", + timeout_seconds=200, + failure_message="NetBSD4 activation failed.", + ) + + self.assertEqual(raised.exception.code, "remote_error") + self.assertEqual( + context.diagnostics.debug_fields["remote_manager_log_tail"], + "manager: mDNS startup deferred; no usable address has appeared yet", + ) + self.assertEqual(context.diagnostics.debug_fields["remote_mdns_log_tail"], "mdns: before interface probe") + self.assertEqual(context.diagnostics.debug_fields["runtime_startup_failure"], "network_auto_ip_unavailable") + error = context.diagnostic_error(str(raised.exception)) + self.assertIn("remote_manager_log_tail=manager: mDNS startup deferred; no usable address has appeared yet", error) + self.assertIn("remote_mdns_log_tail=mdns: before interface probe", error) + self.assertIn("remote_network_target_ip_matches=[]", error) + + def test_deploy_request_ssh_reboot_reports_timeout_when_request_error_is_required(self) -> None: + from timecapsulesmb.services.reboot import request_reboot + + collector = CollectingSink() + context = AppOperationContext("deploy", collector.sink) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + with self.assertRaises(RebootFlowError) as raised: + request_reboot( + connection, + strategy="ssh", + callbacks=context.to_operation_callbacks(), + raise_on_request_error=True, + request_reboot_func=mock.Mock(side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot")), + ) + + 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"), + } + params = {"dry_run": False} + params["confirmation_id"] = self.confirmation_id_for( + "deploy", + params, + { + "host": "root@10.0.0.2", + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "requires_reboot": True, + "no_reboot": False, + "no_wait": False, + "startup_mode": "reboot_then_verify", + }, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.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.services.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.services.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): + rc = service.run_api_request( + { + "operation": "deploy", + "params": params, + }, + 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"], []) + self.assertEqual(self._telemetry_factory.call_args.kwargs["nbns_enabled"], True) + finished = self._telemetry_client.emit.call_args_list[-1].kwargs + self.assertEqual(finished["result"], "failure") + self.assertEqual(finished["stage"], "read_mast") + self.assertEqual(finished["device_family"], "netbsd6_samba4") + self.assertEqual(finished["device_os_version"], "NetBSD 6.0 (earmv4)") + self.assertEqual(finished["device_model"], "TimeCapsule8,119") + self.assertEqual(finished["device_syap"], "119") + self.assertEqual(finished["nbns_enabled"], True) + self.assertEqual(finished["reboot_was_attempted"], False) + self.assertEqual(finished["device_came_back_after_reboot"], False) + self.assertEqual(finished["deploy_startup_mode"], "reboot_then_verify") + + def test_activate_requires_explicit_confirmation(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.resolve_validated_managed_target") as resolve_target: + with mock.patch("timecapsulesmb.services.activation.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.assert_confirmation(collector, "activate.netbsd4", {"netbsd4": True}) + resolve_target.assert_not_called() + runtime_probe.assert_not_called() + remote_actions.assert_not_called() + + def test_activate_accepts_confirmation_id(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + params = {} + params["confirmation_id"] = self.confirmation_id_for( + "activate", + params, + { + "host": "root@10.0.0.2", + "netbsd4": True, + }, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.common.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.services.activation.probe_managed_runtime_conn", return_value=managed_runtime_probe(True)): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "activate", "params": params}, + 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.common.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.common.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) + error = self.assert_confirmation( + collector, + "uninstall.reboot", + {"requires_reboot": True, "no_reboot": False, "no_wait": False}, + ) + self.assertEqual( + error["message"], + "Remove managed TimeCapsuleSMB files from the device and reboot it?", + ) + resolve_connection.assert_called_once() + uninstall.assert_not_called() + + def test_uninstall_without_reboot_requires_question_form_confirmation(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.common.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": {"no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_confirmation( + collector, + "uninstall.no_reboot", + {"requires_reboot": False, "no_reboot": True, "no_wait": False}, + ) + self.assertEqual(error["message"], "Remove managed TimeCapsuleSMB files from the device?") + 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.common.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.common.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.services.storage.read_mast_volumes_conn") as read_mast: + 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") + 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.common.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.common.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_dry_run_no_wait_returns_request_only_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.common.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.common.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, "no_wait": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertTrue(payload["reboot_required"]) + self.assertFalse(payload["wait_after_reboot"]) + self.assertEqual(payload["reboot_request"]["follow_up"], ["return_after_reboot_request"]) + self.assertEqual(payload["post_uninstall_checks"], []) + 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")] + params = { + "mount_wait": 13, + "no_wait": True, + } + params["confirmation_id"] = self.confirmation_id_for( + "uninstall", + params, + { + "host": "root@10.0.0.2", + "requires_reboot": True, + "no_reboot": False, + "no_wait": True, + }, + ) + + with mock.patch("timecapsulesmb.app.ops.common.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.common.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.services.storage.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.services.storage.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.maintenance.request_reboot", side_effect=self.fake_reboot_request) as reboot: + with mock.patch("timecapsulesmb.app.ops.maintenance.request_reboot_and_wait") as wait: + with mock.patch("timecapsulesmb.app.ops.maintenance.verify_post_uninstall") as verify: + rc = service.run_api_request( + { + "operation": "uninstall", + "params": params, + }, + 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.assert_confirmation(collector, "fsck.reboot", {"requires_reboot": True, "no_reboot": False}) + resolve_connection.assert_not_called() + + def test_fsck_without_reboot_requires_question_form_confirmation(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config") as load_config: + rc = service.run_api_request( + {"operation": "fsck", "params": {"no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assert_confirmation(collector, "fsck.no_reboot", {"requires_reboot": False, "no_reboot": True}) + load_config.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.services.storage.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.services.storage.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.services.storage.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.services.storage.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 = RepairRunResult( + 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", 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() + request = runner.call_args.args[0] + self.assertIsInstance(request, RepairXattrsRequest) + self.assertEqual(request.path, Path("/Volumes/Data")) + self.assertTrue(request.dry_run) + self.assertFalse(request.approve_repairs) + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["finding_count"], 1) + self.assertEqual(payload["summary"], "Found 1 metadata issue(s), 1 repairable.") + self.assertEqual(payload["summary_text"], "Found 1 metadata issue(s), 1 repairable.") + self.assertEqual(payload["stats"]["scanned"], 1) + self.assertNotIsInstance(payload["summary"], dict) + + def test_repair_xattrs_forwards_service_log_callbacks(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1) + repair_result = RepairRunResult( + returncode=0, + root=Path("/Volumes/Data"), + findings=[], + candidates=[], + summary=summary, + report=None, + ) + + def fake_runner(_request, _config, *, callbacks, **_kwargs): + callbacks.log("scan detail") + 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", 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": "scan 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") 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") 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 = RepairRunResult( + 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", 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) + request = runner.call_args.args[0] + self.assertEqual(request.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") 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_confirmation(collector, "repair_xattrs", {"path": "/Volumes/Data"}) + self.assertEqual(collector.events_of_type("error")[0]["recovery"]["title"], "Repair confirmation required") + runner.assert_not_called() + + def test_repair_xattrs_checks_platform_after_confirmation(self) -> None: + collector = CollectingSink() + params = {"path": "/Volumes/Data", "dry_run": False} + params["confirmation_id"] = self.confirmation_id_for( + "repair-xattrs", + params, + {"path": "/Volumes/Data"}, + ) + + 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") 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["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":"capabilities","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"], "capabilities") + 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":"capabilities","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('["capabilities"]')): + 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.assertEqual(cli_main.COMMANDS["api"].__module__, "timecapsulesmb.cli.api") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_app_deploy_reboot.py b/tests/test_app_deploy_reboot.py new file mode 100644 index 00000000..d0bd9062 --- /dev/null +++ b/tests/test_app_deploy_reboot.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import unittest +from unittest import mock + +from timecapsulesmb.app.context import AppOperationContext +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.integrations.acp import ACPError +from timecapsulesmb.services import reboot as reboot_service +from timecapsulesmb.services.reboot import RebootFlowError +from timecapsulesmb.transport.errors import SshCommandTimeout, SshError +from timecapsulesmb.transport.ssh import SshConnection + + +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] + + +class DeployRebootStrategyTests(unittest.TestCase): + def make_context(self) -> tuple[CollectingSink, AppOperationContext, SshConnection]: + collector = CollectingSink() + context = AppOperationContext("deploy", collector.sink) + connection = SshConnection("root@10.0.0.2", "pw", "-o test") + return collector, context, connection + + def test_acp_reboot_success_does_not_fall_back_to_ssh(self) -> None: + collector, context, connection = self.make_context() + + acp = mock.Mock() + ssh = mock.Mock() + reboot_service.request_reboot( + connection, + strategy="acp_then_ssh", + callbacks=context.to_operation_callbacks(), + request_reboot_func=ssh, + request_acp_reboot=acp, + ) + + acp.assert_called_once_with("10.0.0.2", "pw", timeout=reboot_service.ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + ssh.assert_not_called() + self.assertEqual(context.diagnostics.debug_fields["reboot_request_strategy"], "acp_then_ssh") + self.assertEqual(context.diagnostics.debug_fields["acp_reboot_succeeded"], True) + self.assertEqual(collector.events_of_type("stage")[0]["stage"], "reboot") + self.assertIn("ACP reboot requested.", [event["message"] for event in collector.events_of_type("log")]) + + def test_acp_reboot_failure_falls_back_to_ssh_success(self) -> None: + collector, context, connection = self.make_context() + + ssh = mock.Mock() + reboot_service.request_reboot( + connection, + strategy="acp_then_ssh", + callbacks=context.to_operation_callbacks(), + request_reboot_func=ssh, + request_acp_reboot=mock.Mock(side_effect=ACPError("acp refused")), + ) + + ssh.assert_called_once_with(connection) + self.assertEqual(context.diagnostics.debug_fields["acp_reboot_succeeded"], False) + self.assertIn("acp refused", context.diagnostics.debug_fields["acp_reboot_error"]) + self.assertEqual(context.diagnostics.debug_fields["ssh_reboot_succeeded"], True) + self.assertEqual( + [event["message"] for event in collector.events_of_type("log")], + [ + "ACP reboot request failed; trying SSH reboot request.", + "SSH reboot requested.", + ], + ) + + def test_ssh_timeout_is_logged_when_request_error_is_not_required(self) -> None: + collector, context, connection = self.make_context() + + reboot_service.request_reboot( + connection, + strategy="ssh", + callbacks=context.to_operation_callbacks(), + request_reboot_func=mock.Mock(side_effect=SshCommandTimeout("timeout")), + raise_on_request_error=False, + ) + + self.assertEqual(context.diagnostics.debug_fields["ssh_reboot_succeeded"], False) + self.assertEqual(context.diagnostics.debug_fields["ssh_reboot_timed_out"], True) + self.assertEqual( + collector.events_of_type("log")[0]["message"], + "SSH reboot request timed out; checking whether the device is rebooting...", + ) + + def test_ssh_timeout_can_be_promoted_to_operation_error(self) -> None: + _collector, context, connection = self.make_context() + + with self.assertRaisesRegex(RebootFlowError, "SSH reboot request timed out"): + reboot_service.request_reboot( + connection, + strategy="ssh", + callbacks=context.to_operation_callbacks(), + request_reboot_func=mock.Mock(side_effect=SshCommandTimeout("timeout")), + raise_on_request_error=True, + ) + + self.assertEqual(context.diagnostics.debug_fields["ssh_reboot_timed_out"], True) + + def test_ssh_error_can_be_promoted_to_operation_error(self) -> None: + _collector, context, connection = self.make_context() + + with self.assertRaisesRegex(RebootFlowError, "SSH reboot request failed"): + reboot_service.request_reboot( + connection, + strategy="ssh", + callbacks=context.to_operation_callbacks(), + request_reboot_func=mock.Mock(side_effect=SshError("rc=255")), + raise_on_request_error=True, + ) + + self.assertEqual(context.diagnostics.debug_fields["ssh_reboot_succeeded"], False) + self.assertIn("rc=255", context.diagnostics.debug_fields["ssh_reboot_error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_app_recovery.py b/tests/test_app_recovery.py new file mode 100644 index 00000000..17ed8d65 --- /dev/null +++ b/tests/test_app_recovery.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import unittest + +from timecapsulesmb.app.recovery import recovery_for + + +class AppRecoveryTests(unittest.TestCase): + def test_deploy_reboot_up_timeout_recovery_carries_detailed_guidance(self) -> None: + recovery = recovery_for("deploy", "remote_error", stage="wait_for_reboot_up") + + self.assertEqual(recovery["title"], "Reboot did not finish") + self.assertEqual(recovery["localization_key"], "deploy.remote_error.wait_for_reboot_up") + self.assertEqual(recovery["retryable"], True) + self.assertEqual(recovery["suggested_operation"], "doctor") + self.assertEqual(recovery["action_ids"], ["run_checkup"]) + self.assertIn("payload was uploaded", recovery["message"]) + self.assertIn("4 minute timeout", recovery["message"]) + self.assertEqual( + recovery["actions"], + [ + "Wait a few more minutes.", + "If the device is reachable at a new IP, update TC_HOST or rerun configure.", + "Make sure you are connected to the same network or Wi-Fi as the device.", + ( + "On NetBSD 4 devices, run tcapsule activate once SSH is reachable; deploy did not get far " + "enough to activate Samba after reboot." + ), + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_checks.py b/tests/test_checks.py index 57319ede..435d2026 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -42,6 +42,7 @@ SmbClientTarget, check_authenticated_smb_file_ops_detailed, check_authenticated_smb_listing, + parse_smbclient_disk_shares, try_authenticated_smb_listing, ) from timecapsulesmb.checks.smb_targets import doctor_smb_servers @@ -50,6 +51,7 @@ from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ( DeployedVersionProbeResult, + FLASH_RUNTIME_CONFIG, RemoteInterfaceProbeResult, RemoteNetworkCapabilitiesProbeResult, RUNTIME_RAM_ROOT, @@ -77,8 +79,8 @@ class CheckTests(unittest.TestCase): - def smb_listing_result(self, server: str = "timecapsulesamba4.local") -> CheckResult: - return CheckResult("PASS", "listing ok", {"server": server}) + def smb_listing_result(self, server: str = "timecapsulesamba4.local", disk_shares: list[str] | None = None) -> CheckResult: + return CheckResult("PASS", "listing ok", {"server": server, "disk_shares": ["Data"] if disk_shares is None else disk_shares}) def doctor_config(self, values: dict[str, str], *, exists: bool = True) -> AppConfig: return AppConfig.from_values( @@ -179,6 +181,7 @@ def run_doctor_with_mocks( debug_fields=None, on_result=None, runtime_naming_identity: RuntimeNamingIdentityProbeResult | None = None, + deployed_config_present: bool = True, deployed_version: DeployedVersionProbeResult | None = None, runtime_ram_root_present: bool = True, extra_patches: dict[str, object] | None = None, @@ -252,6 +255,12 @@ def run_doctor_with_mocks( return_value=runtime_naming_identity or self.runtime_identity_from_values(resolved_values), ) ) + mocks.flash_runtime_config_present_conn = stack.enter_context( + mock.patch( + "timecapsulesmb.checks.doctor_steps.flash_runtime_config_present_conn", + return_value=deployed_config_present, + ) + ) mocks.read_deployed_version_conn = stack.enter_context( mock.patch( "timecapsulesmb.checks.doctor_steps.read_deployed_version_conn", @@ -366,6 +375,7 @@ def setUp(self) -> None: return_value=DeployedVersionProbeResult(RELEASE_TAG, CLI_VERSION_CODE, "ok"), ) ) + self._exit_stack.enter_context(mock.patch("timecapsulesmb.checks.doctor_steps.flash_runtime_config_present_conn", return_value=True)) self._exit_stack.enter_context(mock.patch("timecapsulesmb.checks.doctor_steps.runtime_ram_root_present_conn", return_value=True)) self._exit_stack.enter_context( mock.patch( @@ -441,6 +451,40 @@ def test_run_doctor_checks_passes_when_deployed_version_matches_current_cli(self ) ) + def test_run_doctor_checks_passes_when_deployed_config_exists(self) -> None: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + ) + + self.assertTrue( + any( + result.status == "PASS" and result.message == f"deployed payload config {FLASH_RUNTIME_CONFIG} exists" + for result in run.results + ) + ) + + def test_run_doctor_checks_stops_when_deployed_config_is_missing(self) -> None: + debug_fields: dict[str, object] = {} + managed_smbd = mock.Mock() + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + deployed_config_present=False, + debug_fields=debug_fields, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_smbd_conn": managed_smbd}, + ) + + self.assertTrue(run.fatal) + self.assertEqual( + run.results[-1].message, + "deployed payload config not found; please run deploy to install on your device", + ) + self.assertEqual(run.results[-1].details["code"], "runtime_not_installed") + self.assertEqual(debug_fields["deployed_config_present"], False) + run.mocks.read_deployed_version_conn.assert_not_called() + managed_smbd.assert_not_called() + def test_run_doctor_checks_passes_when_runtime_ram_root_exists(self) -> None: run = self.run_doctor_with_mocks( ssh_login=mock.Mock(status="PASS", message="ssh ok"), @@ -483,6 +527,8 @@ def test_run_doctor_checks_stops_when_deployed_version_metadata_is_missing(self) run.results[-1].message, f"deployed payload has no version metadata; current version is {RELEASE_TAG}; please run deploy to update your device", ) + run.mocks.flash_runtime_config_present_conn.assert_called_once() + run.mocks.read_deployed_version_conn.assert_called_once() managed_smbd.assert_not_called() def test_run_doctor_checks_tells_user_to_reboot_when_deployed_version_probe_fails(self) -> None: @@ -799,6 +845,7 @@ def test_run_doctor_checks_resolves_expected_smb_when_browse_misses_instance(sel ), "timecapsulesmb.checks.doctor_steps.resolve_smb_instance": resolve_mock, "timecapsulesmb.checks.doctor_steps.check_bonjour_host_ip": mock.Mock(side_effect=check_bonjour_host_ip), + "timecapsulesmb.core.net.socket.getaddrinfo": mock.Mock(side_effect=OSError("no dns")), "timecapsulesmb.checks.doctor_debug.browse_native_dns_sd": mock.Mock( side_effect=AssertionError("native dns-sd should not decide Bonjour success") ), @@ -1351,6 +1398,8 @@ def test_run_doctor_checks_respects_skip_flags(self) -> None: skip_smb=True, ) run.mocks.check_smb_port.assert_called_once() + run.mocks.flash_runtime_config_present_conn.assert_not_called() + run.mocks.read_deployed_version_conn.assert_not_called() self.assertFalse(run.fatal) self.assertEqual(run.results[0].status, "PASS") self.assertIn("configuration file exists", run.results[0].message) @@ -1712,6 +1761,7 @@ def test_run_doctor_checks_skips_mast_probe_when_ssh_login_fails(self) -> None: self.run_doctor_with_mocks( ssh_login=mock.Mock(status="FAIL", message="ssh failed"), + smb_listing=CheckResult("FAIL", "mock authenticated SMB listing failure"), debug_fields=debug_fields, extra_patches={"timecapsulesmb.checks.doctor_debug.probe_mast_diagnostics_conn": mast_probe_mock}, ) @@ -1744,21 +1794,213 @@ def test_run_doctor_checks_reports_managed_smbd_subchecks(self) -> None: "FAIL:smbd is not bound to required TCP 445 sockets", ), ) - run = self.run_doctor_with_mocks( - ssh_login=mock.Mock(status="PASS", message="ssh ok"), - smb_port=mock.Mock(status="PASS", message="445 ok"), - smb_instance=[], - smb_listing=self.smb_listing_result(), - smb_file_ops=[], - smbd_probe=smbd_probe, - mdns_probe=mock.Mock(ready=True, detail="managed mDNS takeover active"), - run_ssh_stdout="[global]\n xattr_tdb:file = /Volumes/dk2/samba4/private/xattr.tdb\n[Data]\n", - ) + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + smb_instance=[], + smb_listing=self.smb_listing_result(), + smb_file_ops=[], + smbd_probe=smbd_probe, + mdns_probe=mock.Mock(ready=True, detail="managed mDNS takeover active"), + run_ssh_stdout="[global]\n xattr_tdb:file = /Volumes/dk2/samba4/private/xattr.tdb\n[Data]\n", + ) self.assertTrue(run.fatal) + self.assertEqual([call.args[0] for call in sleep_mock.call_args_list], [10, 15]) self.assertTrue(any(result.status == "PASS" and result.message == "managed smbd parent process is running" for result in run.results)) self.assertTrue(any(result.status == "FAIL" and result.message == "smbd is not bound to required TCP 445 sockets" for result in run.results)) self.assertFalse(any(result.message.startswith("managed smbd is not ready") for result in run.results)) + def test_run_doctor_checks_retries_transient_smbd_parent_failure_before_streaming_result(self) -> None: + transient = mock.Mock( + ready=False, + detail="managed smbd parent process is not running", + lines=("FAIL:managed smbd parent process is not running",), + ) + ready = mock.Mock( + ready=True, + detail="managed smbd ready", + lines=("PASS:managed smbd parent process is running", "PASS:smbd bound to required TCP 445 sockets"), + ) + smbd_mock = mock.Mock(side_effect=[transient, ready]) + streamed: list[CheckResult] = [] + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + on_result=streamed.append, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_smbd_conn": smbd_mock}, + ) + + self.assertFalse(run.fatal) + self.assertEqual(smbd_mock.call_count, 2) + sleep_mock.assert_called_once_with(10) + self.assertFalse(any(result.message == "managed smbd parent process is not running" for result in run.results)) + self.assertFalse(any(result.message == "managed smbd parent process is not running" for result in streamed)) + self.assertTrue(any(result.status == "PASS" and result.message == "smbd bound to required TCP 445 sockets" for result in run.results)) + + def test_run_doctor_checks_retries_transient_smbd_tcp_binding_failure(self) -> None: + transient = mock.Mock( + ready=False, + detail="smbd is not bound to required TCP 445 sockets", + lines=( + "PASS:managed smbd parent process is running", + "FAIL:smbd is not bound to required TCP 445 sockets", + ), + ) + ready = mock.Mock( + ready=True, + detail="managed smbd ready", + lines=( + "PASS:managed smbd parent process is running", + "PASS:smbd bound to required TCP 445 sockets", + ), + ) + smbd_mock = mock.Mock(side_effect=[transient, ready]) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_smbd_conn": smbd_mock}, + ) + + self.assertFalse(run.fatal) + self.assertEqual(smbd_mock.call_count, 2) + sleep_mock.assert_called_once_with(10) + self.assertFalse(any(result.message == "smbd is not bound to required TCP 445 sockets" for result in run.results)) + self.assertTrue(any(result.status == "PASS" and result.message == "smbd bound to required TCP 445 sockets" for result in run.results)) + + def test_run_doctor_checks_does_not_retry_structural_smbd_failure_mixed_with_transient_failure(self) -> None: + smbd_probe = mock.Mock( + ready=False, + detail="managed runtime smbd binary missing; smbd is not bound to required TCP 445 sockets", + lines=( + "FAIL:managed runtime smbd binary missing", + "FAIL:smbd is not bound to required TCP 445 sockets", + ), + ) + smbd_mock = mock.Mock(return_value=smbd_probe) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_smbd_conn": smbd_mock}, + ) + + self.assertTrue(run.fatal) + smbd_mock.assert_called_once() + sleep_mock.assert_not_called() + + def test_run_doctor_checks_retries_transient_mdns_process_failure(self) -> None: + transient = mock.Mock( + ready=False, + detail="mdns-advertiser process is not running", + lines=("FAIL:mdns-advertiser process is not running",), + ) + ready = mock.Mock( + ready=True, + detail="managed mDNS takeover active", + lines=("PASS:mdns-advertiser process is running", "PASS:mdns-advertiser bound to required UDP 5353 listeners"), + ) + mdns_mock = mock.Mock(side_effect=[transient, ready]) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_mdns_takeover_conn": mdns_mock}, + ) + + self.assertFalse(run.fatal) + self.assertEqual(mdns_mock.call_count, 2) + sleep_mock.assert_called_once_with(10) + self.assertFalse(any(result.message == "mdns-advertiser process is not running" for result in run.results)) + self.assertTrue(any(result.status == "PASS" and result.message == "mdns-advertiser bound to required UDP 5353 listeners" for result in run.results)) + + def test_run_doctor_checks_retries_transient_mdns_udp_binding_failure(self) -> None: + transient = mock.Mock( + ready=False, + detail="mdns-advertiser is not bound to required UDP 5353 listener", + lines=( + "PASS:mdns-advertiser process is running", + "FAIL:mdns-advertiser is not bound to required UDP 5353 listener", + ), + ) + ready = mock.Mock( + ready=True, + detail="managed mDNS takeover active", + lines=( + "PASS:mdns-advertiser process is running", + "PASS:mdns-advertiser bound to required UDP 5353 listeners", + ), + ) + mdns_mock = mock.Mock(side_effect=[transient, ready]) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_mdns_takeover_conn": mdns_mock}, + ) + + self.assertFalse(run.fatal) + self.assertEqual(mdns_mock.call_count, 2) + sleep_mock.assert_called_once_with(10) + self.assertFalse(any(result.message == "mdns-advertiser is not bound to required UDP 5353 listener" for result in run.results)) + self.assertTrue(any(result.status == "PASS" and result.message == "mdns-advertiser bound to required UDP 5353 listeners" for result in run.results)) + + def test_run_doctor_checks_exhausts_transient_mdns_udp_binding_retries(self) -> None: + mdns_probe = mock.Mock( + ready=False, + detail="mdns-advertiser is not bound to required UDP 5353 listener", + lines=("FAIL:mdns-advertiser is not bound to required UDP 5353 listener",), + ) + mdns_mock = mock.Mock(return_value=mdns_probe) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_mdns_takeover_conn": mdns_mock}, + ) + + self.assertTrue(run.fatal) + self.assertEqual(mdns_mock.call_count, 3) + self.assertEqual([call.args[0] for call in sleep_mock.call_args_list], [10, 15]) + self.assertTrue(any(result.status == "FAIL" and result.message == "mdns-advertiser is not bound to required UDP 5353 listener" for result in run.results)) + + def test_run_doctor_checks_does_not_retry_structural_mdns_failure_mixed_with_transient_failure(self) -> None: + mdns_probe = mock.Mock( + ready=False, + detail="mdns-advertiser binary missing at /mnt/Flash/mdns-advertiser; mdns-advertiser process is not running", + lines=( + "FAIL:mdns-advertiser binary missing at /mnt/Flash/mdns-advertiser", + "FAIL:mdns-advertiser process is not running", + ), + ) + mdns_mock = mock.Mock(return_value=mdns_probe) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + skip_bonjour=True, + skip_smb=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.probe_managed_mdns_takeover_conn": mdns_mock}, + ) + + self.assertTrue(run.fatal) + mdns_mock.assert_called_once() + sleep_mock.assert_not_called() + def test_run_doctor_checks_reports_supported_device_compatibility(self) -> None: run = self.run_doctor_with_mocks( ssh_login=mock.Mock(status="PASS", message="ssh ok"), @@ -1927,7 +2169,6 @@ def test_run_doctor_checks_proxy_target_skips_local_network_checks(self) -> None "admin", "pw", "127.0.0.1", - expected_share_name="Data", port=1445, ) smb_file_ops_mock.assert_called_once_with( @@ -2275,7 +2516,7 @@ def test_run_doctor_checks_passes_bonjour_when_service_record_lacks_embedded_ip_ self.assertIn("resolved _smb._tcp instance 'Home' to home.local:445", pass_messages) self.assertIn("resolved Bonjour host home.local to 10.0.1.1", pass_messages) - def test_run_doctor_checks_passes_expected_share_to_listing(self) -> None: + def test_run_doctor_checks_lists_shares_before_selecting_active_file_ops_share(self) -> None: values = { "TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw", @@ -2306,12 +2547,12 @@ def test_run_doctor_checks_passes_expected_share_to_listing(self) -> None: return_value=self.runtime_identity_from_values(values), ): with mock.patch("timecapsulesmb.device.probe.run_ssh", side_effect=self.run_ssh_with_active_smb_conf()): - run_doctor_checks(self.doctor_config(values), repo_root=REPO_ROOT) + results, fatal = run_doctor_checks(self.doctor_config(values), repo_root=REPO_ROOT) + self.assertFalse(fatal) listing_mock.assert_called_once_with( "admin", "pw", ["timecapsulesamba4.local", "10.0.0.2"], - expected_share_name="Data", port=445, ) file_ops_mock.assert_called_once_with( @@ -2321,6 +2562,116 @@ def test_run_doctor_checks_passes_expected_share_to_listing(self) -> None: "Data", port=445, ) + self.assertTrue(any(result.status == "PASS" and "includes active share 'Data'" in result.message for result in results)) + + def test_run_doctor_checks_fails_when_active_share_missing_from_smb_listing(self) -> None: + listing_mock = mock.Mock(return_value=self.smb_listing_result(disk_shares=["Public"])) + file_ops_mock = mock.Mock(return_value=[mock.Mock(status="PASS", message="file ops ok")]) + + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + skip_bonjour=True, + extra_patches={ + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock, + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_file_ops_detailed": file_ops_mock, + }, + ) + + self.assertTrue(run.fatal) + self.assertTrue( + any( + result.status == "FAIL" + and "authenticated SMB listing did not include any active Samba share" in result.message + and "Data" in result.message + and "Public" in result.message + for result in run.results + ) + ) + file_ops_mock.assert_not_called() + + def test_run_doctor_checks_skip_ssh_uses_listed_smb_share_for_file_ops(self) -> None: + listing_mock = mock.Mock(return_value=self.smb_listing_result("10.0.0.2", disk_shares=["Public"])) + file_ops_mock = mock.Mock(return_value=[mock.Mock(status="PASS", message="file ops ok")]) + + run = self.run_doctor_with_mocks( + skip_ssh=True, + skip_bonjour=True, + extra_patches={ + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock, + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_file_ops_detailed": file_ops_mock, + }, + ) + + self.assertFalse(run.fatal) + self.assertTrue(any(result.status == "INFO" and "active Samba share comparison skipped; SSH check skipped" in result.message for result in run.results)) + listing_mock.assert_called_once_with( + "admin", + "pw", + ["10.0.0.2"], + port=445, + ) + file_ops_mock.assert_called_once_with( + "admin", + "pw", + "10.0.0.2", + "Public", + port=445, + ) + + def test_run_doctor_checks_skip_ssh_fails_when_smb_listing_has_no_disk_shares(self) -> None: + listing_mock = mock.Mock(return_value=self.smb_listing_result(disk_shares=[])) + file_ops_mock = mock.Mock(return_value=[mock.Mock(status="PASS", message="file ops ok")]) + + run = self.run_doctor_with_mocks( + skip_ssh=True, + skip_bonjour=True, + extra_patches={ + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock, + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_file_ops_detailed": file_ops_mock, + }, + ) + + self.assertTrue(run.fatal) + self.assertTrue(any(result.status == "FAIL" and "no disk shares were advertised" in result.message for result in run.results)) + file_ops_mock.assert_not_called() + + def test_run_doctor_checks_ssh_ok_skips_authenticated_smb_when_requested(self) -> None: + listing_mock = mock.Mock(return_value=self.smb_listing_result()) + file_ops_mock = mock.Mock(return_value=[mock.Mock(status="PASS", message="file ops ok")]) + + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + skip_bonjour=True, + skip_smb=True, + extra_patches={ + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock, + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_file_ops_detailed": file_ops_mock, + }, + ) + + self.assertFalse(run.fatal) + listing_mock.assert_not_called() + file_ops_mock.assert_not_called() + + def test_run_doctor_checks_skip_ssh_and_skip_smb_runs_no_authenticated_smb(self) -> None: + listing_mock = mock.Mock(return_value=self.smb_listing_result()) + file_ops_mock = mock.Mock(return_value=[mock.Mock(status="PASS", message="file ops ok")]) + + run = self.run_doctor_with_mocks( + skip_ssh=True, + skip_bonjour=True, + skip_smb=True, + extra_patches={ + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock, + "timecapsulesmb.checks.doctor_steps.check_authenticated_smb_file_ops_detailed": file_ops_mock, + }, + ) + + self.assertFalse(run.fatal) + listing_mock.assert_not_called() + file_ops_mock.assert_not_called() def test_run_doctor_checks_ignores_legacy_mdns_host_label_for_smb_targets(self) -> None: values = { @@ -2384,6 +2735,18 @@ def test_check_authenticated_smb_listing_passes_when_expected_share_present(self self.assertEqual(result.status, "PASS") self.assertIn("listing works", result.message) self.assertEqual(result.details["server"], "server.local") + self.assertEqual(result.details["disk_shares"], ["Data", "Public"]) + + def test_parse_smbclient_disk_shares_uses_machine_listing_types(self) -> None: + output = "\n".join([ + "Disk|Data|Main storage", + "IPC|IPC$|IPC Service", + "Printer|lp|Printer", + "Disk|Archive Data|", + "Disk|Data|Duplicate", + ]) + + self.assertEqual(parse_smbclient_disk_shares(output), ["Data", "Archive Data"]) def test_try_authenticated_smb_listing_falls_back_to_second_server_when_first_times_out(self) -> None: proc = subprocess.CompletedProcess(["smbclient"], 0, "Data\nPublic\n", "") @@ -2457,18 +2820,136 @@ def test_try_authenticated_smb_listing_records_attempt_debug_details(self) -> No self.assertIn("NT_STATUS_IO_TIMEOUT", result.message) self.assertNotIn("secret-password", result.message) + def test_run_doctor_checks_retries_transient_smb_listing_after_shared_delay(self) -> None: + transient = CheckResult( + "FAIL", + "authenticated SMB listing failed after 1 attempt(s): attempt 1 home.local: NT_STATUS_IO_TIMEOUT", + {"attempts": [{"server": "home.local", "outcome": "error", "failure": "NT_STATUS_IO_TIMEOUT"}]}, + ) + listing_mock = mock.Mock(side_effect=[transient, self.smb_listing_result()]) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + skip_bonjour=True, + smb_file_ops=[], + extra_patches={"timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock}, + ) + + self.assertFalse(run.fatal) + self.assertEqual(listing_mock.call_count, 2) + sleep_mock.assert_called_once_with(10) + listing_results = [result for result in run.results if result.message == "listing ok"] + self.assertEqual(len(listing_results), 1) + self.assertEqual(listing_results[0].details["attempts"][0]["next_retry_delay_sec"], 10) + + def test_run_doctor_checks_retries_smb_listing_targets_by_round(self) -> None: + first_round = CheckResult( + "FAIL", + "authenticated SMB listing failed after 2 attempt(s)", + { + "attempts": [ + {"server": "home.local", "outcome": "error", "failure": "NT_STATUS_CONNECTION_REFUSED"}, + {"server": "10.0.1.1", "outcome": "error", "failure": "NT_STATUS_CONNECTION_REFUSED"}, + ] + }, + ) + listing_mock = mock.Mock(side_effect=[first_round, self.smb_listing_result("home.local")]) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + skip_bonjour=True, + smb_file_ops=[], + extra_patches={"timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock}, + ) + + self.assertFalse(run.fatal) + self.assertEqual(listing_mock.call_count, 2) + sleep_mock.assert_called_once_with(10) + listing_result = next(result for result in run.results if result.message == "listing ok") + self.assertEqual([attempt["server"] for attempt in listing_result.details["attempts"]], ["home.local", "10.0.1.1"]) + + def test_run_doctor_checks_exhausts_transient_smb_listing_retries(self) -> None: + failures = [ + CheckResult("FAIL", "listing failed", {"attempts": [{"server": "home.local", "outcome": "error", "failure": "NT_STATUS_IO_TIMEOUT"}]}), + CheckResult("FAIL", "listing failed", {"attempts": [{"server": "home.local", "outcome": "error", "failure": "NT_STATUS_IO_TIMEOUT"}]}), + CheckResult("FAIL", "listing failed", {"attempts": [{"server": "home.local", "outcome": "error", "failure": "NT_STATUS_IO_TIMEOUT"}]}), + ] + listing_mock = mock.Mock(side_effect=failures) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + skip_bonjour=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock}, + ) + + self.assertTrue(run.fatal) + self.assertEqual(listing_mock.call_count, 3) + self.assertEqual([call.args[0] for call in sleep_mock.call_args_list], [10, 15]) + final_listing = next(result for result in run.results if result.message.startswith("authenticated SMB listing failed after 3 attempt(s)")) + attempts = final_listing.details["attempts"] + self.assertEqual(len(attempts), 3) + self.assertEqual(attempts[0]["next_retry_delay_sec"], 10) + self.assertEqual(attempts[1]["next_retry_delay_sec"], 15) + self.assertNotIn("next_retry_delay_sec", attempts[2]) + + def test_run_doctor_checks_does_not_retry_smb_listing_auth_failure(self) -> None: + failure = CheckResult( + "FAIL", + "listing failed", + {"attempts": [{"server": "home.local", "outcome": "error", "failure": "NT_STATUS_LOGON_FAILURE"}]}, + ) + listing_mock = mock.Mock(return_value=failure) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + skip_bonjour=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock}, + ) + + self.assertTrue(run.fatal) + listing_mock.assert_called_once() + sleep_mock.assert_not_called() + + def test_run_doctor_checks_does_not_retry_smb_listing_missing_expected_share(self) -> None: + failure = CheckResult( + "FAIL", + "listing failed", + {"attempts": [{"server": "home.local", "outcome": "missing_expected_share", "expected_share": "Data"}]}, + ) + listing_mock = mock.Mock(return_value=failure) + + with mock.patch("timecapsulesmb.checks.doctor_steps.time.sleep") as sleep_mock: + run = self.run_doctor_with_mocks( + ssh_login=mock.Mock(status="PASS", message="ssh ok"), + smb_port=mock.Mock(status="PASS", message="445 ok"), + skip_bonjour=True, + extra_patches={"timecapsulesmb.checks.doctor_steps.check_authenticated_smb_listing": listing_mock}, + ) + + self.assertTrue(run.fatal) + listing_mock.assert_called_once() + sleep_mock.assert_not_called() + def test_run_doctor_checks_adds_smb_listing_attempts_to_debug_fields(self) -> None: debug_fields: dict[str, object] = {} listing_attempts = [ - {"server": "timecapsulesamba4.local", "outcome": "timeout", "timeout_sec": 30}, - {"server": "10.0.0.2", "outcome": "timeout", "timeout_sec": 30}, + {"server": "timecapsulesamba4.local", "outcome": "error", "failure": "NT_STATUS_LOGON_FAILURE"}, + {"server": "10.0.0.2", "outcome": "error", "failure": "NT_STATUS_LOGON_FAILURE"}, ] run = self.run_doctor_with_mocks( ssh_login=mock.Mock(status="PASS", message="ssh ok"), smb_port=mock.Mock(status="PASS", message="445 ok"), smb_listing=CheckResult( "FAIL", - "authenticated SMB listing failed: timed out via 10.0.0.2", + "authenticated SMB listing failed: NT_STATUS_LOGON_FAILURE", {"attempts": listing_attempts}, ), smb_file_ops=[], @@ -2480,7 +2961,7 @@ def test_run_doctor_checks_adds_smb_listing_attempts_to_debug_fields(self) -> No debug_fields["authenticated_smb_listing_servers"], ["timecapsulesamba4.local", "10.0.0.2"], ) - self.assertEqual(debug_fields["authenticated_smb_listing_expected_share"], "Data") + self.assertEqual(debug_fields["authenticated_smb_listing_active_shares"], ["Data"]) self.assertEqual(debug_fields["authenticated_smb_listing_attempts"], listing_attempts) def test_run_doctor_checks_retries_host_unreachable_smbclient_through_ssh_tunnel(self) -> None: @@ -3025,6 +3506,7 @@ def test_run_doctor_checks_pins_authenticated_smb_to_runtime_addresses(self) -> { "server": "timecapsulesamba4.local", "ip_address": "fd00::2", + "disk_shares": ["Data"], "attempts": [], }, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9de7ee85..ca42219d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,10 @@ from __future__ import annotations import errno +import argparse import io import json +import os import plistlib import socket import subprocess @@ -26,6 +28,7 @@ import timecapsulesmb.cli.main as cli_main_module from timecapsulesmb import apple_firmware +from timecapsulesmb import repair_xattrs as repair_xattrs_domain from timecapsulesmb.basebinary import ( BasebinaryHeader, BasebinaryKey, @@ -51,6 +54,11 @@ from timecapsulesmb.cli import runtime as cli_runtime from timecapsulesmb.cli.main import main from timecapsulesmb.cli.context import CommandContext +from timecapsulesmb.services import flash as flash_service +from timecapsulesmb.services import repair_xattrs as repair_xattrs_service +from timecapsulesmb.services import runtime as service_runtime +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.deploy import DEPLOY_REBOOT_NO_DOWN_MESSAGE from timecapsulesmb.core.config import ( AppConfig, ConfigError, @@ -64,16 +72,12 @@ from timecapsulesmb.core.paths import AppPaths from timecapsulesmb.device.compat import DeviceCompatibility, compatibility_from_probe_result from timecapsulesmb.device.probe import ( - ManagedMdnsTakeoverProbeResult, ManagedRuntimeProbeResult, - ManagedSmbdProbeResult, ProbeResult, + ProbeStepResult, ProbedDeviceState, - RUNTIME_ACTIVATION_STATE_NOT_READY, - RUNTIME_ACTIVATION_STATE_READY, - RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING, + ReadinessProbeResult, RemoteInterfaceProbeResult, - RuntimeActivationProbeResult, ) from timecapsulesmb.device.storage import ( MAST_PROBE_COMMAND, @@ -105,7 +109,7 @@ ) from timecapsulesmb.deploy.verify import VerificationResult from timecapsulesmb.flash_payloads import find_apple_firmware_match -from timecapsulesmb.flash import PATCHED_LOGIN_SCRIPT, STOCK_LOGIN_NETBSD4_DUMMY, sha256_hex +from timecapsulesmb.flash import FlashAnalysisError, PATCHED_LOGIN_SCRIPT, STOCK_LOGIN_NETBSD4_DUMMY, sha256_hex from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError from timecapsulesmb.discovery.bonjour import ( BonjourDiscoverySnapshot, @@ -113,7 +117,7 @@ BonjourServiceInstance, BonjourResolvedService, ) -from timecapsulesmb.cli.version_check import DEFAULT_DOWNLOAD_URL, VERSION_CHECK_URL, VersionCheckResult +from timecapsulesmb.services.version_check import DEFAULT_DOWNLOAD_URL, VERSION_CHECK_URL, VersionCheckResult from timecapsulesmb.cli.util import ANSI_RED, ANSI_RESET from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG from timecapsulesmb.integrations.acp import ACPAuthError, ACPConnectionError @@ -125,6 +129,18 @@ def make_test_gzip_member(data: bytes) -> bytes: return compressor.compress(data) + compressor.flush() +def readiness_result(ready: bool, detail: str, lines: tuple[str, ...]) -> ReadinessProbeResult: + steps = [] + for index, line in enumerate(lines): + if line.startswith("PASS:"): + steps.append(ProbeStepResult(f"test_{index}", "pass", line.removeprefix("PASS:"))) + elif line.startswith("FAIL:"): + steps.append(ProbeStepResult(f"test_{index}", "fail", line.removeprefix("FAIL:"))) + else: + steps.append(ProbeStepResult(f"test_{index}", "fail", line)) + return ReadinessProbeResult(ready=ready, detail=detail, steps=tuple(steps)) + + class FastFakeZopfliGzipForCli: @staticmethod def compress(data: bytes, **_kwargs) -> bytes: @@ -215,6 +231,14 @@ def set_stage(self, stage: str) -> None: def add_debug_fields(self, **_fields: object) -> None: pass + def to_operation_callbacks(self) -> OperationCallbacks: + return OperationCallbacks( + set_stage=self.set_stage, + log=print, + add_debug_fields=self.add_debug_fields, + update_fields=self.update_fields, + ) + def set_error(self, message: str) -> None: self.error_lines = [line.rstrip() for line in message.splitlines() if line.strip()] @@ -252,7 +276,12 @@ def confirm_or_fail( noninteractive_message: str, eof_default: bool | None = None, interrupt_default: bool | None = None, + allow_prompt: bool = True, ) -> bool | None: + if not allow_prompt: + print(noninteractive_message) + self.fail_with_error(noninteractive_message) + return None try: return cli_runtime.confirm( prompt_text, @@ -302,36 +331,20 @@ def _patch_mast_volume_flow( mounted = mounted_volumes if mounted_volumes is not None else (self._mast_volume("dk2"),) read = read_volumes if read_volumes is not None else mounted return SimpleNamespace( - read_mast_volumes_conn=stack.enter_context(mock.patch("timecapsulesmb.cli.context.read_mast_volumes_conn", return_value=read)), - mounted_mast_volumes_conn=stack.enter_context(mock.patch("timecapsulesmb.cli.context.mounted_mast_volumes_conn", return_value=mounted)), + read_mast_volumes_conn=stack.enter_context(mock.patch("timecapsulesmb.services.storage.read_mast_volumes_conn", return_value=read)), + mounted_mast_volumes_conn=stack.enter_context(mock.patch("timecapsulesmb.services.storage.mounted_mast_volumes_conn", return_value=mounted)), ) def managed_runtime_probe(self, ready: bool) -> ManagedRuntimeProbeResult: status = "PASS" if ready else "FAIL" detail = "managed runtime is ready" if ready else "managed runtime is not ready" - smbd = ManagedSmbdProbeResult(ready, detail, (f"{status}:managed smbd ready",)) - mdns = ManagedMdnsTakeoverProbeResult(ready, detail, (f"{status}:managed mDNS takeover active",)) + smbd = readiness_result(ready, detail, (f"{status}:managed smbd ready",)) + mdns = readiness_result(ready, detail, (f"{status}:managed mDNS takeover active",)) return ManagedRuntimeProbeResult( ready=ready, detail=detail, smbd=smbd, mdns=mdns, - lines=smbd.lines + mdns.lines, - ) - - def runtime_activation_probe(self, state: str) -> RuntimeActivationProbeResult: - if state == RUNTIME_ACTIVATION_STATE_READY: - runtime = self.managed_runtime_probe(True) - return RuntimeActivationProbeResult(state=state, detail=runtime.detail, runtime=runtime) - if state == RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING: - return RuntimeActivationProbeResult( - state=state, - detail="managed runtime startup script is running", - ) - return RuntimeActivationProbeResult( - state=RUNTIME_ACTIVATION_STATE_NOT_READY, - detail="managed runtime startup script is not running; managed runtime is not ready", - runtime=self.managed_runtime_probe(False), ) def setUp(self) -> None: @@ -355,36 +368,30 @@ def setUp(self) -> None: self._exit_stack.enter_context(mock.patch(target, return_value=self._telemetry_client)) self._exit_stack.enter_context( mock.patch( - "timecapsulesmb.cli.runtime.probe_remote_interface_conn", + "timecapsulesmb.services.runtime.probe_remote_interface_conn", return_value=RemoteInterfaceProbeResult(iface="bridge0", exists=True, detail="interface bridge0 exists"), ) ) - self._exit_stack.enter_context( - mock.patch( - "timecapsulesmb.cli.runtime.read_interface_ipv4_addrs_conn", - return_value=("192.168.1.217",), - ) - ) self._exit_stack.enter_context(mock.patch("timecapsulesmb.device.probe.tcp_open", return_value=False)) self._exit_stack.enter_context(mock.patch("timecapsulesmb.cli.configure.missing_required_python_module", return_value=None)) - def fake_configure_acp_probe(_connection, command_context, **_kwargs): - command_context.add_debug_fields( + def fake_configure_acp_probe(_connection, *, callbacks=None, **_kwargs): + callbacks.add_debug_fields( configure_acp_enable_attempted=True, configure_acp_enable_succeeded=True, ssh_initially_reachable=False, ) - command_context.update_fields(ssh_final_reachable=True) + callbacks.update_fields(ssh_final_reachable=True) return self.make_probe_state(self.make_probe_result_netbsd6()) self._configure_acp_probe_mock = self._exit_stack.enter_context( mock.patch( - "timecapsulesmb.cli.configure.enable_ssh_and_reprobe_for_configure", + "timecapsulesmb.services.configure.enable_ssh_and_reprobe", side_effect=fake_configure_acp_probe, ) ) self._exit_stack.enter_context( mock.patch( - "timecapsulesmb.cli.flows.acp_reboot", + "timecapsulesmb.services.reboot.acp_reboot", side_effect=ACPConnectionError("ACP unavailable in tests"), ) ) @@ -466,6 +473,25 @@ def flash_bank_checksum(self, bank: bytes) -> int: return checksum self.fail("synthetic flash bank footer not found") + def make_flash_inputs( + self, + primary: bytes, + secondary: bytes, + *, + cks1: int | None = None, + cks2: int | None = None, + syap: str = "113", + live_login: bytes = STOCK_LOGIN_NETBSD4_DUMMY, + ) -> cli_flash.FlashInputs: + return cli_flash.FlashInputs( + primary=primary, + secondary=secondary, + cks1=self.flash_bank_checksum(primary) if cks1 is None else cks1, + cks2=self.flash_bank_checksum(secondary) if cks2 is None else cks2, + syap=syap, + live_login=live_login, + ) + def flash_bank_end_offset(self, bank: bytes) -> int: for offset in range(max(0, len(bank) - 4096), len(bank) - 7): checksum, end_offset = struct.unpack(">II", bank[offset : offset + 8]) @@ -732,7 +758,7 @@ def run_configure_cli( mocks = SimpleNamespace() raised = None - def capture_write_env(_path, values): + def capture_write_env(_path, values, **_kwargs): written_values.update(values) with ExitStack() as stack: @@ -778,7 +804,7 @@ def capture_write_env(_path, values): mocks.confirm = stack.enter_context(mock.patch("timecapsulesmb.cli.configure.confirm", return_value=confirm)) mocks.write_env_file = stack.enter_context( mock.patch( - "timecapsulesmb.cli.configure.write_env_file", + "timecapsulesmb.cli.configure.write_configure_env_file", side_effect=write_side_effect if write_side_effect is not None else capture_write_env, ) ) @@ -840,7 +866,7 @@ def run_deploy_cli( select_payload_home_side_effect=None, payload_verification: PayloadVerificationResult | None = None, payload_verification_side_effect=None, - activation_probe=None, + login_autostart_enabled: bool = False, verify_runtime=None, reboot_side_effect=None, wait_side_effect=None, @@ -869,38 +895,52 @@ def run_deploy_cli( ) if command_context is not None: mocks.command_context = stack.enter_context(mock.patch("timecapsulesmb.cli.deploy.CommandContext", return_value=command_context)) - mocks.validate_artifacts = stack.enter_context(mock.patch("timecapsulesmb.cli.deploy.validate_artifacts", return_value=artifacts)) + mocks.validate_artifacts = stack.enter_context(mock.patch("timecapsulesmb.services.deploy.validate_artifacts", return_value=artifacts)) mocks.wait_for_mast_volumes_conn = stack.enter_context( - mock.patch("timecapsulesmb.cli.context.wait_for_mast_volumes_conn", return_value=mast_discovery) + mock.patch("timecapsulesmb.services.storage.wait_for_mast_volumes_conn", return_value=mast_discovery) ) if select_payload_home_side_effect is None: mocks.select_payload_home_with_diagnostics_conn = stack.enter_context( mock.patch( - "timecapsulesmb.cli.context.select_payload_home_with_diagnostics_conn", + "timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", return_value=payload_home_selection, ) ) else: mocks.select_payload_home_with_diagnostics_conn = stack.enter_context( mock.patch( - "timecapsulesmb.cli.context.select_payload_home_with_diagnostics_conn", + "timecapsulesmb.services.deploy.select_payload_home_with_diagnostics_conn", side_effect=select_payload_home_side_effect, ) ) + deploy_compatibility = compatibility or self.make_supported_compatibility() + deploy_probe_state = SimpleNamespace( + compatibility=deploy_compatibility, + probe_result=SimpleNamespace(error=None, airport_model=None, airport_syap=None), + ) + mocks.resolve_validated_managed_target = stack.enter_context( + mock.patch( + "timecapsulesmb.cli.context.CommandContext.resolve_validated_managed_target", + return_value=SimpleNamespace( + connection=SshConnection("root@10.0.0.2", "pw", "-o foo"), + probe_state=deploy_probe_state, + ), + ) + ) mocks.require_compatibility = stack.enter_context( mock.patch( "timecapsulesmb.cli.context.CommandContext.require_compatibility", - return_value=compatibility or self.make_supported_compatibility(), + return_value=deploy_compatibility, ) ) if patch_actions: - mocks.run_remote_actions = stack.enter_context(mock.patch("timecapsulesmb.cli.deploy.run_remote_actions")) + mocks.run_remote_actions = stack.enter_context(mock.patch("timecapsulesmb.services.deploy.run_remote_actions")) if patch_upload: mocks.upload_deployment_payload = stack.enter_context( - mock.patch("timecapsulesmb.cli.deploy.upload_deployment_payload", side_effect=upload_side_effect) + mock.patch("timecapsulesmb.services.deploy.upload_deployment_payload", side_effect=upload_side_effect) ) mocks.flush_remote_filesystem_writes = stack.enter_context( - mock.patch("timecapsulesmb.cli.deploy.flush_remote_filesystem_writes") + mock.patch("timecapsulesmb.services.deploy.flush_remote_filesystem_writes") ) payload_verification_patch_kwargs = ( {"side_effect": payload_verification_side_effect} @@ -909,34 +949,42 @@ def run_deploy_cli( ) mocks.verify_payload_home_conn = stack.enter_context( mock.patch( - "timecapsulesmb.cli.deploy.verify_payload_home_conn", + "timecapsulesmb.services.deploy.verify_payload_home_conn", **payload_verification_patch_kwargs, ) ) mocks.verify_managed_runtime = stack.enter_context( mock.patch( - "timecapsulesmb.cli.flows.verify_managed_runtime", + "timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=verify_runtime or self.managed_runtime_probe(True), ) ) - mocks.probe_runtime_activation_state_conn = stack.enter_context( + mocks.probe_netbsd4_rc_local_autostart_conn = stack.enter_context( mock.patch( - "timecapsulesmb.cli.flows.probe_runtime_activation_state_conn", - return_value=activation_probe or self.runtime_activation_probe(RUNTIME_ACTIVATION_STATE_NOT_READY), + "timecapsulesmb.services.activation.probe_netbsd4_rc_local_autostart_conn", + return_value=SimpleNamespace( + enabled=login_autostart_enabled, + detail=( + "/etc/rc.d/LOGIN invokes /mnt/Flash/rc.local" + if login_autostart_enabled + else "/etc/rc.d/LOGIN does not invoke /mnt/Flash/rc.local" + ), + login_size=128, + ), ) ) mocks.remote_request_reboot = stack.enter_context( - mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=reboot_side_effect) + mock.patch("timecapsulesmb.services.reboot.remote_request_reboot", side_effect=reboot_side_effect) ) mocks.acp_reboot = stack.enter_context( mock.patch( - "timecapsulesmb.cli.flows.acp_reboot", + "timecapsulesmb.services.reboot.acp_reboot", side_effect=AssertionError("deploy should not request ACP reboot"), ) ) if wait_side_effect is not None: mocks.wait_for_ssh_state_conn = stack.enter_context( - mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=wait_side_effect) + mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", side_effect=wait_side_effect) ) if input_side_effect is not None: mocks.input = stack.enter_context(mock.patch("builtins.input", side_effect=input_side_effect)) @@ -1218,8 +1266,8 @@ def test_optional_env_config_uses_missing_config_when_env_is_absent(self) -> Non state_dir=Path(tmp), package_root=SRC_ROOT / "timecapsulesmb", ) - with mock.patch("timecapsulesmb.cli.runtime.resolve_app_paths", return_value=app_paths): - config = cli_runtime.load_optional_env_config() + with mock.patch("timecapsulesmb.services.runtime.resolve_app_paths", return_value=app_paths): + config = service_runtime.load_optional_env_config() self.assertFalse(config.exists) self.assertEqual(config.path, env_path) @@ -1235,8 +1283,8 @@ def test_optional_env_config_reads_env_when_present(self) -> None: state_dir=Path(tmp), package_root=SRC_ROOT / "timecapsulesmb", ) - with mock.patch("timecapsulesmb.cli.runtime.resolve_app_paths", return_value=app_paths): - config = cli_runtime.load_optional_env_config() + with mock.patch("timecapsulesmb.services.runtime.resolve_app_paths", return_value=app_paths): + config = service_runtime.load_optional_env_config() self.assertTrue(config.exists) self.assertEqual(config.path, env_path) @@ -1258,6 +1306,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_service.RepairRunResult( + returncode=0, + root=Path("/Volumes/Data"), + findings=[mock.Mock()], + candidates=[mock.Mock()], + summary=repair_xattrs_domain.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_service", 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"], "Found 1 metadata issue(s), 1 repairable.") + self.assertEqual(events[-1]["payload"]["summary_text"], "Found 1 metadata 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): @@ -1323,6 +1405,68 @@ def test_bootstrap_returns_error_when_requirements_missing(self) -> None: self.assertEqual(finished["requirements_present"], False) self.assertIn("stage=validate_requirements", finished["error"]) + def test_bootstrap_telemetry_error_includes_command_stderr(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + requirements = root / "requirements.txt" + requirements.write_text("zeroconf\n") + venv = root / ".venv" + command_stderr = "The virtual environment was not created successfully because ensurepip is not available.\n" + failed = subprocess.CompletedProcess(["/usr/bin/python3", "-m", "venv", str(venv)], 1, "", command_stderr) + + with mock.patch("timecapsulesmb.cli.bootstrap.REPO_ROOT", root): + with mock.patch("timecapsulesmb.cli.bootstrap.REQUIREMENTS", requirements): + with mock.patch("timecapsulesmb.cli.bootstrap.VENVDIR", venv): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): + with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="Linux"): + with mock.patch("timecapsulesmb.cli.bootstrap.subprocess.run", return_value=failed): + rc = bootstrap.main(["--python", "/usr/bin/python3"]) + + self.assertEqual(rc, 1) + finished = self.telemetry_payload("bootstrap_finished") + error = finished["error"] + self.assertIn("Command failed with exit code 1", error) + self.assertIn("stderr:", error) + self.assertIn("ensurepip is not available", error) + self.assertIn("Debug context:", error) + self.assertIn("stage=ensure_venv", error) + + def test_bootstrap_telemetry_error_uses_stdout_when_stderr_empty(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + requirements = root / "requirements.txt" + requirements.write_text("zeroconf\n") + venv = root / ".venv" + failed = subprocess.CompletedProcess(["python3", "-m", "venv", str(venv)], 1, "stdout failure\n", "") + + with mock.patch("timecapsulesmb.cli.bootstrap.REPO_ROOT", root): + with mock.patch("timecapsulesmb.cli.bootstrap.REQUIREMENTS", requirements): + with mock.patch("timecapsulesmb.cli.bootstrap.VENVDIR", venv): + with mock.patch("timecapsulesmb.cli.bootstrap.ensure_install_id"): + with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="Linux"): + with mock.patch("timecapsulesmb.cli.bootstrap.subprocess.run", return_value=failed): + rc = bootstrap.main(["--python", "python3"]) + + self.assertEqual(rc, 1) + error = self.telemetry_payload("bootstrap_finished")["error"] + self.assertIn("stdout:", error) + self.assertIn("stdout failure", error) + self.assertIn("stage=ensure_venv", error) + + def test_bootstrap_command_error_output_is_truncated(self) -> None: + message = bootstrap._format_command_error( + bootstrap.BootstrapCommandError( + ["python3", "-m", "venv", ".venv"], + 1, + "", + "x" * (bootstrap.COMMAND_OUTPUT_ERROR_LIMIT + 7), + ) + ) + + self.assertIn("stderr:", message) + self.assertIn("...", message) + self.assertLess(len(message), bootstrap.COMMAND_OUTPUT_ERROR_LIMIT + 200) + def test_bootstrap_install_python_requirements_repairs_venv_without_pip(self) -> None: output = io.StringIO() venv_python = Path("/tmp/tcapsule-venv/bin/python") @@ -1411,7 +1555,10 @@ def test_bootstrap_fails_when_homebrew_missing_for_required_host_tools_on_macos( with redirect_stdout(output): bootstrap.install_required_host_tools() text = output.getvalue() - self.assertIn("Homebrew is required", text) + self.assertIn("Install Homebrew", text) + self.assertIn("or install these macOS packages manually: sshpass, samba", text) + self.assertIn("Then rerun './tcapsule bootstrap'.", text) + self.assertIn("Missing host tools: sshpass, smbclient", text) self.assertIn("https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh", text) self.assertIn("\033[31m", text) @@ -1508,6 +1655,28 @@ def test_bootstrap_prints_manual_install_when_linux_host_tool_install_fails(self self.assertIn("sudo apt-get update && sudo apt-get install -y sshpass smbclient", text) self.assertIn("\033[31m", text) + def test_bootstrap_host_tool_install_error_keeps_command_stderr(self) -> None: + output = io.StringIO() + command_error = bootstrap.BootstrapCommandError( + ["sudo", "/usr/bin/apt-get", "update"], + 100, + "", + "apt repository failure\n", + ) + + with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="Linux"): + with mock.patch("timecapsulesmb.cli.bootstrap.find_command", side_effect=lambda name: "/usr/bin/apt-get" if name == "apt-get" else None): + with mock.patch("timecapsulesmb.cli.bootstrap.run", side_effect=command_error): + with self.assertRaises(bootstrap.BootstrapError) as raised: + with redirect_stdout(output): + bootstrap.install_required_host_tools() + + self.assertIn("Failed to install missing host tools automatically", output.getvalue()) + message = str(raised.exception) + self.assertIn("Command failed with exit code 100", message) + self.assertIn("stderr:", message) + self.assertIn("apt repository failure", message) + def test_bootstrap_fails_when_linux_package_manager_missing_for_required_host_tools(self) -> None: output = io.StringIO() with mock.patch("timecapsulesmb.cli.bootstrap.current_platform_label", return_value="Linux"): @@ -1641,6 +1810,80 @@ def test_configure_hidden_ata_args_write_drive_settings(self) -> None: self.assertEqual(result.values["TC_ATA_IDLE_SECONDS"], "0") self.assertEqual(result.values["TC_ATA_STANDBY"], "0") + def test_configure_no_input_uses_explicit_host_and_password_env_without_prompts(self) -> None: + with mock.patch.dict(os.environ, {"TCAPSULE_TEST_PASSWORD": "pw"}): + result = self.run_configure_cli( + [ + "--no-input", + "--host", + "root@10.0.0.2", + "--password-env", + "TCAPSULE_TEST_PASSWORD", + ], + probe_state=self.make_probe_state(self.make_probe_result_netbsd6()), + extra_patches={ + "timecapsulesmb.cli.configure.prompt": mock.Mock(side_effect=AssertionError("configure --no-input should not prompt")), + "builtins.input": mock.Mock(side_effect=AssertionError("configure --no-input should not call input")), + "timecapsulesmb.cli.configure.getpass.getpass": mock.Mock(side_effect=AssertionError("configure --no-input should not call getpass")), + }, + ) + + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_HOST"], "root@10.0.0.2") + self.assertEqual(result.values["TC_PASSWORD"], "pw") + result.mocks.discover_snapshot_merged_detailed.assert_not_called() + + def test_configure_no_input_requires_password_before_probe_or_write(self) -> None: + result = self.run_configure_cli( + ["--no-input", "--host", "root@10.0.0.2"], + probe_state=self.make_probe_state(self.make_probe_result_netbsd6()), + extra_patches={ + "timecapsulesmb.cli.configure.prompt": mock.Mock(side_effect=AssertionError("configure --no-input should not prompt")), + "timecapsulesmb.cli.configure.getpass.getpass": mock.Mock(side_effect=AssertionError("configure --no-input should not call getpass")), + }, + ) + + self.assertEqual(result.rc, 1) + self.assertIn("configure --no-input requires a device password", result.text) + result.mocks.probe_connection_state.assert_not_called() + result.mocks.write_env_file.assert_not_called() + + def test_configure_no_input_requires_explicit_ssh_enable_when_ssh_is_closed(self) -> None: + with mock.patch.dict(os.environ, {"TCAPSULE_TEST_PASSWORD": "pw"}): + result = self.run_configure_cli( + ["--no-input", "--host", "root@10.0.0.2", "--password-env", "TCAPSULE_TEST_PASSWORD"], + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + extra_patches={ + "timecapsulesmb.cli.configure.prompt": mock.Mock(side_effect=AssertionError("configure --no-input should not prompt")), + }, + ) + + self.assertEqual(result.rc, 1) + self.assertIn("use --enable-ssh --yes to enable SSH via ACP", result.text) + self._configure_acp_probe_mock.assert_not_called() + result.mocks.write_env_file.assert_not_called() + + def test_configure_no_input_json_outputs_machine_readable_summary(self) -> None: + with mock.patch.dict(os.environ, {"TCAPSULE_TEST_PASSWORD": "pw"}): + result = self.run_configure_cli( + [ + "--no-input", + "--json", + "--host", + "root@10.0.0.2", + "--password-env", + "TCAPSULE_TEST_PASSWORD", + ], + probe_state=self.make_probe_state(self.make_probe_result_netbsd6()), + ) + + payload = json.loads(result.text) + self.assertEqual(result.rc, 0) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["host"], "root@10.0.0.2") + self.assertEqual(payload["device_syap"], "119") + self.assertNotIn("TC_PASSWORD", payload) + def test_configure_preserves_existing_ata_settings(self) -> None: result = self.run_configure_cli( [], @@ -3982,13 +4225,37 @@ 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"} 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_ssh_mock: - with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", return_value=True): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh_with_identity_preflight") as enable_ssh_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.runtime_service.wait_for_tcp_port_state", return_value=True): with redirect_stdout(output): rc = set_ssh.main([]) self.assertEqual(rc, 0) @@ -4000,12 +4267,70 @@ 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_with_identity_preflight") 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_with_identity_preflight") 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_with_identity_preflight") as enable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.runtime_service.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"} 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", side_effect=RuntimeError("ACP failed")): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh_with_identity_preflight", side_effect=RuntimeError("ACP failed")): with redirect_stdout(output): rc = set_ssh.main([]) self.assertEqual(rc, 1) @@ -4015,17 +4340,36 @@ def test_set_ssh_enable_exception_emits_failure_stage(self) -> None: finished = self.telemetry_payload("set_ssh_finished") self.assertEqual(finished["result"], "failure") self.assertEqual(finished["set_ssh_action"], "enable_ssh") - self.assertIn("stage=enable_ssh", finished["error"]) + self.assertIn("stage=probe_ssh", finished["error"]) self.assertIn(message, finished["error"]) self.assertNotIn(ANSI_RED, finished["error"]) + def test_set_ssh_enable_stops_when_identity_preflight_cannot_connect(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.99", "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.services.acp_ssh.read_identity", side_effect=ACPConnectionError("connection failed")): + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh") as enable_mock: + with redirect_stdout(output): + rc = set_ssh.main([]) + + self.assertEqual(rc, 1) + enable_mock.assert_not_called() + rendered = output.getvalue() + self.assertIn(f"{ANSI_RED}Failed to read AirPort identity via ACP:{ANSI_RESET}", rendered) + self.assertIn("connection failed", rendered) + finished = self.telemetry_payload("set_ssh_finished") + self.assertIn("stage=acp_identity_probe", finished["error"]) + self.assertIn("Failed to read AirPort identity via ACP: connection failed", finished["error"]) + def test_set_ssh_enable_failure_reports_acp_error_without_bootstrap_guidance(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} error = "ACP command failed with error_code -0x1234 (likely wrong AirPort admin password)" 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", side_effect=RuntimeError(error)): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh_with_identity_preflight", side_effect=RuntimeError(error)): with redirect_stdout(output): rc = set_ssh.main([]) @@ -4060,6 +4404,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"} @@ -4067,12 +4429,12 @@ def test_set_ssh_disable_fails_when_ssh_never_goes_down(self) -> None: with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): with mock.patch("builtins.input", return_value="y"): with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh"): - with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", return_value=False) as wait_port_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.runtime_service.wait_for_tcp_port_state", return_value=False) as wait_port_mock: with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_device_up") as wait_up_mock: with redirect_stdout(output): rc = set_ssh.main([]) self.assertEqual(rc, 1) - wait_port_mock.assert_called_once_with("10.0.0.2", 22, expected_state=False, service_name="SSH port") + wait_port_mock.assert_called_once_with("10.0.0.2", 22, expected_state=False, log=print, service_name="SSH port") wait_up_mock.assert_not_called() self.assertIn("SSH did not close after disable/reboot request; disable could not be verified.", output.getvalue()) finished = self.telemetry_payload("set_ssh_finished") @@ -4090,12 +4452,12 @@ def test_set_ssh_disable_fails_when_device_does_not_come_back(self) -> None: with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): with mock.patch("builtins.input", return_value="y"): with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh"): - with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", return_value=True) as wait_port_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.runtime_service.wait_for_tcp_port_state", return_value=True) as wait_port_mock: with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_device_up", return_value=False) as wait_up_mock: with redirect_stdout(output): rc = set_ssh.main([]) self.assertEqual(rc, 1) - wait_port_mock.assert_called_once_with("10.0.0.2", 22, expected_state=False, service_name="SSH port") + wait_port_mock.assert_called_once_with("10.0.0.2", 22, expected_state=False, log=print, service_name="SSH port") wait_up_mock.assert_called_once_with("10.0.0.2") self.assertIn("Device went down after disable request but did not come back within timeout.", output.getvalue()) finished = self.telemetry_payload("set_ssh_finished") @@ -4112,7 +4474,7 @@ def test_set_ssh_disable_fails_when_ssh_reopens(self) -> None: with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): with mock.patch("builtins.input", return_value="y"): with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_ssh_mock: - with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", side_effect=[True, False]): + with mock.patch("timecapsulesmb.cli.set_ssh.runtime_service.wait_for_tcp_port_state", side_effect=[True, False]): with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_device_up", return_value=True): with redirect_stdout(output): rc = set_ssh.main([]) @@ -4140,7 +4502,7 @@ def test_set_ssh_disable_flow_confirms_ssh_disabled(self) -> None: with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): with mock.patch("builtins.input", return_value="y"): with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh"): - with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", side_effect=[True, True]): + with mock.patch("timecapsulesmb.cli.set_ssh.runtime_service.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([]) @@ -4154,6 +4516,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.runtime_service.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") @@ -4166,6 +4543,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_does_not_pass_legacy_bonjour_timeout_to_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([]) + self.assertEqual(rc, 0) + self.assertNotIn("bonjour_timeout", checks_mock.call_args.kwargs) + def test_doctor_ensures_install_id_before_telemetry(self) -> None: output = io.StringIO() fake_result = doctor.CheckResult("PASS", "ok") @@ -4203,6 +4590,20 @@ def test_deploy_dry_run_prints_mast_payload_placeholder(self) -> None: result.mocks.wait_for_mast_volumes_conn.assert_not_called() result.mocks.select_payload_home_with_diagnostics_conn.assert_not_called() + def test_deploy_no_input_requires_yes_before_remote_mutation(self) -> None: + result = self.run_deploy_cli( + ["--no-input"], + patch_actions=True, + patch_upload=True, + ) + + self.assertEqual(result.rc, 1) + self.assertIn("Running `deploy` with reboot in non-interactive mode requires `--yes`", result.text) + result.mocks.validate_artifacts.assert_not_called() + result.mocks.wait_for_mast_volumes_conn.assert_not_called() + result.mocks.run_remote_actions.assert_not_called() + result.mocks.upload_deployment_payload.assert_not_called() + def test_deploy_dry_run_json_outputs_modern_multivolume_plan(self) -> None: values = self.make_valid_env() result = self.run_deploy_cli(["--dry-run", "--json"], values=values) @@ -4237,11 +4638,17 @@ def test_deploy_dry_run_json_outputs_modern_multivolume_plan(self) -> None: [ "ssh_goes_down_after_reboot", "ssh_returns_after_reboot", + "managed_runtime_smbd_binary_present", "managed_runtime_smb_conf_present", + "active_smb_conf_passdb_ram", + "active_smb_conf_username_map_ram", + "active_smb_conf_xattr_tdb_persistent", + "managed_share_volumes_mounted", + "managed_runtime_manager_process", "managed_smbd_parent_process", "managed_smbd_bound_445", "managed_mdns_takeover_ready", - "authenticated_smb_listing", + "managed_mdns_settle_healthy", ], ) @@ -4346,6 +4753,7 @@ def fake_upload(_plan, *, connection, source_resolver, on_uploaded=None): 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) @@ -4372,6 +4780,67 @@ def fake_upload(_plan, *, connection, source_resolver, on_uploaded=None): finished = self.telemetry_payload("deploy_finished") self.assertFalse(finished["nbns_enabled"]) + def test_deploy_debug_logging_arg_writes_enabled_flash_config(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver, on_uploaded=None): + captured["flash_config"] = source_resolver[GENERATED_FLASH_CONFIG_SOURCE].read_text() + + result = self.run_deploy_cli( + ["--debug-logging", "--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=1\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", captured["flash_config"]) + + def test_deploy_leaves_debug_logging_disabled_without_arg(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver, on_uploaded=None): + 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=0\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=0\n", captured["flash_config"]) + + def test_deploy_dry_run_no_wait_json_outputs_request_only_plan(self) -> None: + result = self.run_deploy_cli(["--dry-run", "--json", "--no-wait"], values=self.make_valid_env()) + self.assertEqual(result.rc, 0) + payload = json.loads(result.text) + self.assertTrue(payload["reboot_required"]) + self.assertFalse(payload["wait_after_reboot"]) + self.assertEqual(payload["reboot_request"]["follow_up"], ["return_after_reboot_request"]) + self.assertEqual(payload["activation_actions"], []) + self.assertEqual(payload["post_deploy_checks"], []) + + def test_deploy_netbsd4_dry_run_no_wait_json_outputs_request_only_plan(self) -> None: + result = self.run_deploy_cli( + ["--dry-run", "--json", "--no-wait"], + artifacts=[("smbd-netbsd4le", True, "ok")], + compatibility=self.make_supported_netbsd4_compatibility(), + ) + self.assertEqual(result.rc, 0) + payload = json.loads(result.text) + self.assertEqual(payload["startup_mode"], DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE) + self.assertTrue(payload["reboot_required"]) + self.assertFalse(payload["wait_after_reboot"]) + self.assertEqual(payload["reboot_request"]["follow_up"], ["return_after_reboot_request"]) + self.assertEqual(payload["activation_actions"], []) + self.assertEqual(payload["post_deploy_checks"], []) + def test_deploy_rejects_removed_install_nbns_flag(self) -> None: stderr = io.StringIO() with redirect_stderr(stderr): @@ -4459,6 +4928,65 @@ def test_deploy_no_reboot_activates_after_upload_phase(self) -> None: ], ) + def test_deploy_no_reboot_no_wait_treats_no_wait_as_inapplicable(self) -> None: + result = self.run_deploy_cli( + ["--no-reboot", "--no-wait"], + artifacts=[("smbd", True, "ok"), ("mdns", True, "ok")], + patch_actions=True, + patch_upload=True, + reboot_side_effect=AssertionError("deploy --no-reboot should not request a reboot"), + ) + + self.assertEqual(result.rc, 0) + result.mocks.remote_request_reboot.assert_not_called() + result.mocks.verify_managed_runtime.assert_called_once() + self.assertIn("Starting deployed runtime without reboot.", result.text) + self.assertIn("Runtime activation complete.", result.text) + self.assertNotIn("not waiting for the device", result.text) + + def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + artifacts=[("smbd", True, "ok"), ("mdns", True, "ok")], + patch_actions=True, + patch_upload=True, + wait_side_effect=AssertionError("deploy --no-wait should not wait for SSH"), + verify_runtime=AssertionError("deploy --no-wait should not verify runtime"), + ) + + 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.assertEqual(result.mocks.run_remote_actions.call_count, 2) + self.assertIn("Requesting reboot...", result.text) + self.assertIn("Reboot requested; not waiting for the device to go down or come back.", result.text) + self.assertIn("Post-reboot runtime verification skipped.", result.text) + finished = self.telemetry_payload("deploy_finished") + self.assertTrue(finished["reboot_was_attempted"]) + self.assertFalse(finished["device_came_back_after_reboot"]) + + def test_deploy_netbsd4_no_wait_requests_reboot_without_activation(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + values=self.make_valid_env(TC_PAYLOAD_DIR_NAME="samba4"), + artifacts=[("smbd-netbsd4le", True, "ok")], + compatibility=self.make_supported_netbsd4_compatibility(), + patch_actions=True, + patch_upload=True, + wait_side_effect=AssertionError("deploy --no-wait should not wait for SSH"), + verify_runtime=AssertionError("deploy --no-wait should not verify runtime"), + ) + + 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.assertEqual(result.mocks.run_remote_actions.call_count, 2) + self.assertNotIn("Activating deployed runtime after reboot.", result.text) + self.assertNotIn("NetBSD4 activation complete.", result.text) + self.assertIn("Post-reboot runtime verification skipped.", result.text) + def test_deploy_payload_verification_failure_aborts_before_reboot(self) -> None: result = self.run_deploy_cli( ["--yes"], @@ -4530,7 +5058,7 @@ def test_deploy_reboot_timeout_returns_failure(self) -> None: self.assertEqual(result.rc, 1) self.assertIn("SSH reboot request timed out; checking whether the device is rebooting...", result.text) - self.assertIn(deploy.REBOOT_NO_DOWN_MESSAGE, result.text) + self.assertIn(DEPLOY_REBOOT_NO_DOWN_MESSAGE, result.text) result.mocks.remote_request_reboot.assert_called_once() result.mocks.acp_reboot.assert_not_called() result.mocks.verify_managed_runtime.assert_not_called() @@ -4561,6 +5089,16 @@ def test_deploy_netbsd4_dry_run_json_outputs_activation_plan(self) -> None: self.assertTrue(payload["reboot_required"]) self.assertEqual(payload["startup_mode"], DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE) self.assertEqual(payload["reboot_request"]["strategy"], "ssh_shutdown_then_reboot") + self.assertEqual( + payload["runtime_startup"]["post_reboot_probe"], + { + "kind": "netbsd4_rc_local_autostart", + "path": "/etc/rc.d/LOGIN", + "marker": "/mnt/Flash/rc.local", + "if_present": ["skip_post_reboot_start_actions", "verify_managed_runtime"], + "if_missing": ["run_post_reboot_start_actions", "verify_managed_runtime"], + }, + ) self.assertEqual( [action["kind"] for action in payload["activation_actions"]], ["run_script"], @@ -4574,10 +5112,17 @@ def test_deploy_netbsd4_dry_run_json_outputs_activation_plan(self) -> None: [ "ssh_goes_down_after_reboot", "ssh_returns_after_reboot", + "managed_runtime_smbd_binary_present", "managed_runtime_smb_conf_present", + "active_smb_conf_passdb_ram", + "active_smb_conf_username_map_ram", + "active_smb_conf_xattr_tdb_persistent", + "managed_share_volumes_mounted", + "managed_runtime_manager_process", "managed_smbd_parent_process", "managed_smbd_bound_445", "managed_mdns_takeover_ready", + "managed_mdns_settle_healthy", ], ) @@ -4634,7 +5179,7 @@ def test_deploy_netbsd6_leaves_scp_capability_probe_enabled(self) -> None: upload_connection = result.mocks.upload_deployment_payload.call_args.kwargs["connection"] self.assertIsNone(upload_connection.remote_has_scp) - def test_deploy_netbsd4_yes_waits_when_flash_boot_already_started_runtime(self) -> None: + def test_deploy_netbsd4_yes_waits_when_firmware_autostarts_runtime(self) -> None: result = self.run_deploy_cli( ["--yes"], values=self.make_valid_env(TC_PAYLOAD_DIR_NAME="samba4"), @@ -4642,7 +5187,7 @@ def test_deploy_netbsd4_yes_waits_when_flash_boot_already_started_runtime(self) compatibility=self.make_supported_netbsd4_compatibility(), patch_actions=True, patch_upload=True, - activation_probe=self.runtime_activation_probe(RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING), + login_autostart_enabled=True, verify_runtime=self.managed_runtime_probe(True), wait_side_effect=[True, True], ) @@ -4651,7 +5196,8 @@ def test_deploy_netbsd4_yes_waits_when_flash_boot_already_started_runtime(self) self.assertEqual(result.mocks.run_remote_actions.call_count, 2) result.mocks.remote_request_reboot.assert_called_once() result.mocks.verify_managed_runtime.assert_called_once() - self.assertIn("startup is already in progress after reboot", result.text) + self.assertIn("/etc/rc.d/LOGIN invokes /mnt/Flash/rc.local", result.text) + self.assertIn("NetBSD4 firmware autostart is enabled", result.text) self.assertNotIn("Activating deployed runtime after reboot.", result.text) self.assertIn("NetBSD4 activation complete.", result.text) @@ -4720,7 +5266,7 @@ def test_activate_dry_run_prints_netbsd4_activation_plan(self) -> None: self.assertNotIn("/usr/bin/pkill '^nbns-advertiser$' >/dev/null 2>&1 || true", text) self.assertIn("/usr/bin/pkill '^wcifsfs$' >/dev/null 2>&1 || true", text) self.assertIn("/bin/sh /mnt/Flash/rc.local", text) - self.assertIn("skip rc.local if NetBSD4 payload is already healthy", text) + self.assertIn("skip rc.local if the NetBSD4 payload is already healthy", text) self.assertIn("managed runtime smb.conf is present", text) self.assertIn("managed smbd parent process is running", text) self.assertIn("smbd is bound to required TCP 445 sockets", text) @@ -4753,14 +5299,13 @@ def test_activate_rejects_non_netbsd4_device(self) -> None: def test_managed_target_does_not_probe_runtime_interface(self) -> None: config = self.make_app_config(self.make_valid_env()) - with mock.patch("timecapsulesmb.cli.runtime.probe_remote_interface_conn", side_effect=AssertionError("interface should not be probed")): - with mock.patch("timecapsulesmb.cli.runtime.read_interface_ipv4_addrs_conn", side_effect=AssertionError("interface IPv4 should not be read")): - target = cli_runtime.resolve_validated_managed_target( - config, - command_name="deploy", - profile="deploy", - include_probe=False, - ) + with mock.patch("timecapsulesmb.services.runtime.probe_remote_interface_conn", side_effect=AssertionError("interface should not be probed")): + target = service_runtime.resolve_validated_managed_target( + config, + command_name="deploy", + profile="deploy", + include_probe=False, + ) self.assertEqual(target.connection.host, config.require("TC_HOST")) self.assertIsNone(target.interface_probe) @@ -4770,11 +5315,11 @@ def test_managed_target_rejects_hostname_that_resolves_link_local(self) -> None: addrinfo = [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.44.9", 0))] with mock.patch("timecapsulesmb.core.net.socket.getaddrinfo", return_value=addrinfo): with mock.patch( - "timecapsulesmb.cli.runtime.probe_remote_interface_conn", + "timecapsulesmb.services.runtime.probe_remote_interface_conn", side_effect=AssertionError("should fail before SSH probing"), ): with self.assertRaises(ConfigError) as ctx: - cli_runtime.resolve_validated_managed_target( + service_runtime.resolve_validated_managed_target( config, command_name="deploy", profile="deploy", @@ -4792,22 +5337,26 @@ def test_managed_target_allows_proxied_hostname_that_resolves_link_local(self) - ) with mock.patch("timecapsulesmb.core.net.socket.getaddrinfo", side_effect=AssertionError("should not resolve")): with mock.patch( - "timecapsulesmb.cli.runtime.probe_remote_interface_conn", + "timecapsulesmb.services.runtime.probe_remote_interface_conn", return_value=RemoteInterfaceProbeResult("bridge0", True, "interface bridge0 exists"), ): - with mock.patch( - "timecapsulesmb.cli.runtime.read_interface_ipv4_addrs_conn", - return_value=("10.0.0.2",), - ): - target = cli_runtime.resolve_validated_managed_target( - config, - command_name="deploy", - profile="deploy", - include_probe=False, - ) + target = service_runtime.resolve_validated_managed_target( + config, + command_name="deploy", + profile="deploy", + include_probe=False, + ) self.assertEqual(target.connection.host, "root@capsule.local") + def test_resolve_env_connection_no_input_fails_instead_of_prompting_for_password(self) -> None: + config = self.make_app_config({"TC_HOST": "root@10.0.0.2"}) + with mock.patch("getpass.getpass", side_effect=AssertionError("non-interactive callers must not prompt")): + with self.assertRaises(ConfigError) as ctx: + service_runtime.resolve_env_connection(config, allow_password_prompt=False) + + self.assertIn("TC_PASSWORD is required when --no-input is used.", str(ctx.exception)) + def test_activate_prompt_decline_cancels_before_remote_actions(self) -> None: output = io.StringIO() command_context = FakeCommandContext(compatibility=self.make_supported_netbsd4_compatibility()) @@ -4847,17 +5396,32 @@ def test_activate_prompt_eof_reports_non_interactive_error(self) -> None: self.assertEqual(command_context.finish.call_args.kwargs["result"], "failure") self.assertEqual(command_context.finish.call_args.kwargs["error"], message) + def test_activate_no_input_requires_yes_without_reading_stdin(self) -> None: + output = io.StringIO() + command_context = FakeCommandContext(compatibility=self.make_supported_netbsd4_compatibility()) + 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 mock.patch("builtins.input", side_effect=AssertionError("activate --no-input must not prompt")) as input_mock: + with mock.patch("timecapsulesmb.cli.activate.run_remote_actions") as actions_mock: + with mock.patch("timecapsulesmb.cli.activate.CommandContext", return_value=command_context): + with redirect_stdout(output): + rc = activate.main(["--no-input"]) + + self.assertEqual(rc, 1) + input_mock.assert_not_called() + actions_mock.assert_not_called() + self.assertIn("Running `activate` in non-interactive mode requires `--yes`", output.getvalue()) + self.assertEqual(command_context.finish.call_args.kwargs["result"], "failure") + def test_activate_yes_runs_idempotent_actions_and_verifies(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 mock.patch( - "timecapsulesmb.cli.flows.probe_runtime_activation_state_conn", - return_value=self.runtime_activation_probe(RUNTIME_ACTIVATION_STATE_NOT_READY), - ): + with mock.patch("timecapsulesmb.services.activation.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(False)): with mock.patch("timecapsulesmb.cli.activate.run_remote_actions") as actions_mock: - with mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=self.managed_runtime_probe(True)) as verify_mock: + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(True)) as verify_mock: with redirect_stdout(output): rc = activate.main(["--yes"]) self.assertEqual(rc, 0) @@ -4873,7 +5437,7 @@ def test_activate_yes_runs_idempotent_actions_and_verifies(self) -> None: ) self.assertEqual(actions_mock.call_args.kwargs, {}) self.assertEqual(verify_mock.call_args.args[0].host, "root@10.0.0.2") - self.assertEqual(verify_mock.call_args.kwargs["timeout_seconds"], 180) + self.assertEqual(verify_mock.call_args.kwargs["timeout_seconds"], 200) self.assertIn("without file transfer", output.getvalue()) def test_main_registers_flash_command(self) -> None: @@ -4882,8 +5446,8 @@ def test_main_registers_flash_command(self) -> None: def test_flash_live_login_read_uses_binary_capture(self) -> None: connection = SshConnection("root@10.0.0.2", "pw", "-o foo") payload = b"#!/bin/sh\n\xff" - with mock.patch("timecapsulesmb.cli.flash.run_ssh_capture_bytes", return_value=payload) as capture_mock: - self.assertEqual(cli_flash.read_live_login(connection), payload) + with mock.patch("timecapsulesmb.services.flash.run_ssh_capture_bytes", return_value=payload) as capture_mock: + self.assertEqual(flash_service.read_live_login(connection), payload) capture_mock.assert_called_once_with( connection, "/bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null", @@ -4899,9 +5463,9 @@ def test_flash_target_resolution_uses_connection_only_config(self) -> None: "TC_AIRPORT_SYAP": "not-a-syap", "TC_MDNS_DEVICE_MODEL": "not-a-model", }) - with mock.patch("timecapsulesmb.cli.runtime.probe_remote_interface_conn", side_effect=AssertionError("flash should not probe TC_NET_IFACE")): - with mock.patch("timecapsulesmb.cli.runtime.probe_connection_state", side_effect=AssertionError("flash target resolution should not probe the device")): - target = cli_runtime.resolve_validated_managed_target( + with mock.patch("timecapsulesmb.services.runtime.probe_remote_interface_conn", side_effect=AssertionError("flash should not probe TC_NET_IFACE")): + with mock.patch("timecapsulesmb.services.runtime.probe_connection_state", side_effect=AssertionError("flash target resolution should not probe the device")): + target = service_runtime.resolve_validated_managed_target( config, command_name="flash", profile="flash", @@ -4943,15 +5507,8 @@ def test_flash_read_only_saves_banks_and_manifest(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=config): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--backup-dir", str(backup_dir)]) @@ -4989,9 +5546,9 @@ def test_flash_read_acp_error_is_reported_without_traceback(self) -> None: with tempfile.TemporaryDirectory() as tmp: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=[primary, secondary]) as dump_mock: - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=ACPAuthError("ACP command failed with error_code -0x10")): - with mock.patch("timecapsulesmb.cli.flash.read_live_login", side_effect=AssertionError("LOGIN should not be read after ACP failure")) as login_mock: + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=[primary, secondary]) as dump_mock: + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=ACPAuthError("ACP command failed with error_code -0x10")): + with mock.patch("timecapsulesmb.services.flash.read_live_login", side_effect=AssertionError("LOGIN should not be read after ACP failure")) as login_mock: with redirect_stdout(output): rc = cli_flash.main([ "--read-only", @@ -5019,11 +5576,11 @@ def test_flash_read_ssh_error_is_reported_without_traceback(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.dump_remote_bank", + "timecapsulesmb.services.flash.dump_remote_bank", side_effect=[primary, SshError("ssh command failed with rc=255")], ) as dump_mock: - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=AssertionError("ACP should not be read after SSH failure")) as acp_mock: - with mock.patch("timecapsulesmb.cli.flash.read_live_login", side_effect=AssertionError("LOGIN should not be read after SSH failure")) as login_mock: + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=AssertionError("ACP should not be read after SSH failure")) as acp_mock: + with mock.patch("timecapsulesmb.services.flash.read_live_login", side_effect=AssertionError("LOGIN should not be read after SSH failure")) as login_mock: with redirect_stdout(output): rc = cli_flash.main([ "--read-only", @@ -5056,14 +5613,11 @@ def test_flash_restore_inspection_error_is_reported_without_system_exit(self) -> with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs( primary, corrupt_secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, + cks2=self.flash_bank_checksum(secondary), ), ): with redirect_stdout(output): @@ -5094,15 +5648,8 @@ def test_flash_refuses_when_probed_syap_is_missing(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env(TC_AIRPORT_SYAP="113"))): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - None, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + side_effect=FlashAnalysisError("syAP is missing"), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--backup-dir", str(Path(tmp) / "backup")]) @@ -5128,15 +5675,8 @@ def test_flash_uses_probed_zero_syap_without_falling_back_to_config(self) -> Non ): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 0, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary, syap="0"), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--backup-dir", str(backup_dir)]) @@ -5159,15 +5699,8 @@ def test_flash_read_only_leaves_inactive_secondary_unmodified_when_it_fits(self) with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--backup-dir", str(backup_dir)]) @@ -5194,15 +5727,8 @@ def test_flash_read_only_saves_no_patch_when_secondary_is_active(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--backup-dir", str(backup_dir)]) @@ -5235,15 +5761,8 @@ def test_flash_read_only_saves_no_patch_when_active_bank_is_unknown(self) -> Non with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--backup-dir", str(backup_dir)]) @@ -5279,17 +5798,10 @@ def test_flash_patch_targets_primary_when_both_banks_are_active_candidates(self) with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with mock.patch("builtins.input", return_value="n"): with redirect_stdout(output): rc = cli_flash.main([ @@ -5325,17 +5837,10 @@ def test_flash_patch_refuses_when_no_active_candidates_pass(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main(["--patch", "--yes", "--backup-dir", str(backup_dir)]) manifest = json.loads((backup_dir / "manifest.json").read_text()) @@ -5360,17 +5865,10 @@ def test_flash_patch_refuses_when_only_secondary_is_active_candidate(self) -> No with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main(["--patch", "--yes", "--backup-dir", str(backup_dir)]) manifest = json.loads((backup_dir / "manifest.json").read_text()) @@ -5397,17 +5895,14 @@ def test_flash_patch_force_bypasses_invalid_secondary_backup_and_targets_primary with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs( primary, corrupt_secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, + cks2=self.flash_bank_checksum(secondary), ), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with mock.patch("builtins.input", return_value="n"): with redirect_stdout(output): rc = cli_flash.main([ @@ -5441,17 +5936,10 @@ def test_flash_patch_force_bypasses_secondary_only_candidate_and_targets_primary with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with mock.patch("builtins.input", return_value="n"): with redirect_stdout(output): rc = cli_flash.main([ @@ -5485,15 +5973,8 @@ def test_flash_read_only_json_outputs_manifest(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--json", "--backup-dir", str(Path(tmp) / "backup")]) @@ -5536,7 +6017,7 @@ def test_flash_patch_missing_zopfli_fails_before_config_or_device_reads(self) -> with mock.patch("timecapsulesmb.cli.flash.ensure_install_id") as ensure_mock: with mock.patch("timecapsulesmb.cli.flash.load_env_config") as load_mock: with mock.patch("timecapsulesmb.cli.flash.CommandContext") as context_mock: - with mock.patch("timecapsulesmb.cli.flash.read_flash_inputs") as read_mock: + with mock.patch("timecapsulesmb.services.flash.read_flash_inputs") as read_mock: with redirect_stdout(output): with self.assertRaises(SystemExit) as raised: cli_flash.main(["--patch"]) @@ -5560,15 +6041,8 @@ def test_flash_read_only_does_not_require_zopfli(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with redirect_stdout(output): rc = cli_flash.main(["--read-only", "--backup-dir", str(Path(tmp) / "backup")]) @@ -5589,15 +6063,8 @@ def test_flash_write_prompt_decline_cancels_without_write(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with mock.patch("builtins.input", return_value="n"): with redirect_stdout(output): @@ -5897,18 +6364,11 @@ def test_flash_write_refuses_unsupported_firmware_key_before_acp(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): with mock.patch("timecapsulesmb.flash_payloads.resolve_firmware_template_candidates", return_value=[unsupported_template]): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -5944,7 +6404,7 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) self.assertEqual(name, "cks1") return self.flash_bank_checksum(fake_readback(None, "")) - def fake_readback(_conn: object, _dev: str) -> bytes: + def fake_readback(_conn: object, _dev: str, **_kwargs: object) -> bytes: reparsed = parse_nested_basebinary(written["payload"]) end_offset = self.flash_bank_end_offset(primary) rebuilt = bytearray(primary) @@ -5965,20 +6425,13 @@ def fake_readback(_conn: object, _dev: str) -> bytes: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=fake_readback): - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=fake_get_property): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=fake_readback): + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=fake_get_property): + with mock.patch("timecapsulesmb.services.reboot.remote_request_reboot") as reboot_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6024,7 +6477,7 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) self.assertEqual(name, "cks1") return self.flash_bank_checksum(fake_readback(None, "")) - def fake_readback(_conn: object, _dev: str) -> bytes: + def fake_readback(_conn: object, _dev: str, **_kwargs: object) -> bytes: reparsed = parse_nested_basebinary(written["payload"]) end_offset = self.flash_bank_end_offset(primary) rebuilt = bytearray(primary) @@ -6045,19 +6498,12 @@ def fake_readback(_conn: object, _dev: str) -> bytes: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", side_effect=fake_flash): - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=fake_readback): - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=fake_get_property): + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", side_effect=fake_flash): + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=fake_readback): + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=fake_get_property): with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6091,18 +6537,11 @@ def test_flash_patch_noops_when_active_bank_is_already_patched(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - patched_primary, - secondary, - self.flash_bank_checksum(patched_primary), - self.flash_bank_checksum(secondary), - 113, - PATCHED_LOGIN_SCRIPT, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(patched_primary, secondary, live_login=PATCHED_LOGIN_SCRIPT), ): with mock.patch("timecapsulesmb.flash_payloads.resolve_firmware_template_candidates", side_effect=AssertionError("no template needed")): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6153,7 +6592,7 @@ def fake_flash(_host: str, _password: str, bank_name: str, payload: bytes, **_kw written["payload"] = payload return SimpleNamespace(command=0x03, reply_body=b"") - def fake_readback(_conn: object, _dev: str) -> bytes: + def fake_readback(_conn: object, _dev: str, **_kwargs: object) -> bytes: reparsed = parse_nested_basebinary(written["payload"]) end_offset = self.flash_bank_end_offset(patched_primary) rebuilt = bytearray(patched_primary) @@ -6177,20 +6616,13 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - patched_primary, - secondary, - self.flash_bank_checksum(patched_primary), - self.flash_bank_checksum(secondary), - 113, - PATCHED_LOGIN_SCRIPT, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(patched_primary, secondary, live_login=PATCHED_LOGIN_SCRIPT), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=fake_readback): - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=fake_get_property): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=fake_readback): + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=fake_get_property): + with mock.patch("timecapsulesmb.services.reboot.remote_request_reboot") as reboot_mock: with redirect_stdout(output): rc = cli_flash.main([ "--restore", @@ -6237,7 +6669,7 @@ def fake_flash(_host: str, _password: str, bank_name: str, payload: bytes, **_kw written["payload"] = payload return SimpleNamespace(command=0x03, reply_body=b"") - def fake_readback(_conn: object, _dev: str) -> bytes: + def fake_readback(_conn: object, _dev: str, **_kwargs: object) -> bytes: reparsed = parse_nested_basebinary(written["payload"]) end_offset = self.flash_bank_end_offset(patched_primary) rebuilt = bytearray(patched_primary) @@ -6261,22 +6693,15 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - patched_primary, - secondary, - self.flash_bank_checksum(patched_primary), - self.flash_bank_checksum(secondary), - 113, - PATCHED_LOGIN_SCRIPT, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(patched_primary, secondary, live_login=PATCHED_LOGIN_SCRIPT), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", side_effect=fake_flash): - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=fake_readback): - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=fake_get_property): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as ssh_reboot_mock: - with mock.patch("timecapsulesmb.cli.flows.acp_reboot", side_effect=AssertionError("flash should not request ACP reboot")) as acp_reboot_mock: - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", side_effect=fake_flash): + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=fake_readback): + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=fake_get_property): + with mock.patch("timecapsulesmb.services.reboot.remote_request_reboot") as ssh_reboot_mock: + with mock.patch("timecapsulesmb.services.reboot.acp_reboot", side_effect=AssertionError("flash should not request ACP reboot")) as acp_reboot_mock: + with mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: with redirect_stdout(output): rc = cli_flash.main([ "--restore", @@ -6301,8 +6726,51 @@ 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.services.reboot.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.services.reboot.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 redirect_stdout(output): + rc = cli_flash._finish_write( + command_context, + args=args, + operation="restore", + target=target, + log=None, + ) + + self.assertEqual(rc, 1) + reboot_mock.assert_called_once() + observe_mock.assert_not_called() + self.assertIn("ssh command failed with rc=255", output.getvalue()) + 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() @@ -6317,17 +6785,10 @@ def test_flash_restore_noops_when_active_bank_already_matches_apple(self) -> Non with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--restore", @@ -6363,17 +6824,10 @@ def test_flash_check_apple_reports_match_without_zopfli_or_write(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--check-apple", @@ -6403,17 +6857,10 @@ def test_flash_download_only_validates_firmware_without_write(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--download-only", @@ -6445,17 +6892,10 @@ def test_flash_download_only_reports_mismatch_without_write(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - patched_primary, - secondary, - self.flash_bank_checksum(patched_primary), - self.flash_bank_checksum(secondary), - 113, - PATCHED_LOGIN_SCRIPT, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(patched_primary, secondary, live_login=PATCHED_LOGIN_SCRIPT), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--download-only", @@ -6486,17 +6926,10 @@ def test_flash_restore_refuses_wrong_product_template_before_acp(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--restore", @@ -6526,17 +6959,10 @@ def test_flash_restore_refuses_non_stock_template_before_acp(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - stock_primary, - secondary, - self.flash_bank_checksum(stock_primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(stock_primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--restore", @@ -6573,19 +6999,12 @@ def test_flash_write_readback_sha_mismatch_fails_before_reboot(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", return_value=SimpleNamespace(command=0x03, reply_body=b"")): - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", return_value=primary): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", return_value=SimpleNamespace(command=0x03, reply_body=b"")): + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", return_value=primary): + with mock.patch("timecapsulesmb.services.reboot.remote_request_reboot") as reboot_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6617,7 +7036,7 @@ def fake_flash(_host: str, _password: str, bank_name: str, payload: bytes, **_kw written["payload"] = payload return SimpleNamespace(command=0x03, reply_body=b"") - def fake_readback(_conn: object, _dev: str) -> bytes: + def fake_readback(_conn: object, _dev: str, **_kwargs: object) -> bytes: reparsed = parse_nested_basebinary(written["payload"]) end_offset = self.flash_bank_end_offset(primary) rebuilt = bytearray(primary) @@ -6639,20 +7058,13 @@ def fake_readback(_conn: object, _dev: str) -> bytes: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=fake_readback): - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=AssertionError("ACP checksum should not be read after full-bank mismatch")) as acp_mock: - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=fake_readback): + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=AssertionError("ACP checksum should not be read after full-bank mismatch")) as acp_mock: + with mock.patch("timecapsulesmb.services.reboot.remote_request_reboot") as reboot_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6686,20 +7098,13 @@ def test_flash_write_readback_ssh_error_is_reported_without_traceback(self) -> N with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", return_value=SimpleNamespace(command=0x03, reply_body=b"")) as flash_mock: - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=SshError("ssh command failed with rc=255")): - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=AssertionError("ACP checksum should not be read after read-back failure")) as acp_mock: - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", return_value=SimpleNamespace(command=0x03, reply_body=b"")) as flash_mock: + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=SshError("ssh command failed with rc=255")): + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=AssertionError("ACP checksum should not be read after read-back failure")) as acp_mock: + with mock.patch("timecapsulesmb.services.reboot.remote_request_reboot") as reboot_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6736,17 +7141,10 @@ def test_flash_write_acp_error_is_reported_to_telemetry_without_traceback(self) with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", side_effect=ACPAuthError("ACP command failed with error_code -0x14")): + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", side_effect=ACPAuthError("ACP command failed with error_code -0x14")): with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6778,7 +7176,7 @@ def fake_flash(_host: str, _password: str, bank_name: str, payload: bytes, **_kw written["payload"] = payload return SimpleNamespace(command=0x03, reply_body=b"") - def fake_readback(_conn: object, _dev: str) -> bytes: + def fake_readback(_conn: object, _dev: str, **_kwargs: object) -> bytes: reparsed = parse_nested_basebinary(written["payload"]) end_offset = self.flash_bank_end_offset(primary) rebuilt = bytearray(primary) @@ -6802,20 +7200,13 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - STOCK_LOGIN_NETBSD4_DUMMY, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: - with mock.patch("timecapsulesmb.cli.flash.dump_remote_bank", side_effect=fake_readback): - with mock.patch("timecapsulesmb.cli.flash.get_property_int", side_effect=fake_get_property): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank", side_effect=fake_flash) as flash_mock: + with mock.patch("timecapsulesmb.services.flash.dump_remote_bank", side_effect=fake_readback): + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=fake_get_property): + with mock.patch("timecapsulesmb.services.reboot.remote_request_reboot") as reboot_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6849,17 +7240,10 @@ def test_flash_write_unknown_login_includes_live_login_in_error(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - unknown_login, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary, live_login=unknown_login), ): - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6896,18 +7280,11 @@ def test_flash_patch_write_readiness_fails_before_prompt(self) -> None: with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): with mock.patch( - "timecapsulesmb.cli.flash.read_flash_inputs", - return_value=( - primary, - secondary, - self.flash_bank_checksum(primary), - self.flash_bank_checksum(secondary), - 113, - unknown_login, - ), + "timecapsulesmb.services.flash.read_flash_inputs", + return_value=self.make_flash_inputs(primary, secondary, live_login=unknown_login), ): - with mock.patch("timecapsulesmb.cli.context.runtime.confirm", side_effect=AssertionError("confirm should not be called")) as confirm_mock: - with mock.patch("timecapsulesmb.cli.flash.flash_firmware_bank") as flash_mock: + with mock.patch("timecapsulesmb.cli.context.cli_runtime.confirm", side_effect=AssertionError("confirm should not be called")) as confirm_mock: + with mock.patch("timecapsulesmb.services.flash.flash_firmware_bank") as flash_mock: with redirect_stdout(output): rc = cli_flash.main([ "--patch", @@ -6930,11 +7307,13 @@ def test_flash_rejects_non_netbsd4_before_dumping_banks(self) -> None: with self.flash_zopfli_available(): with mock.patch("timecapsulesmb.cli.flash.load_env_config", return_value=self.make_app_config(self.make_valid_env())): with mock.patch("timecapsulesmb.cli.flash.CommandContext", return_value=command_context): - with mock.patch("timecapsulesmb.cli.flash.read_flash_inputs") as read_mock: + with mock.patch("timecapsulesmb.services.flash.read_flash_inputs") as read_mock: with self.assertRaises(SystemExit) as raised: cli_flash.main(["--read-only"]) - self.assertIn("flash is only supported for NetBSD4", str(raised.exception)) + message = str(raised.exception) + self.assertIn("flash is only supported for NetBSD4", message) + self.assertIn("https://github.com/jamesyc/TimeCapsuleSMB/issues/160", message) read_mock.assert_not_called() def test_activate_skips_rc_local_when_payload_is_already_healthy(self) -> None: @@ -6942,12 +7321,9 @@ def test_activate_skips_rc_local_when_payload_is_already_healthy(self) -> None: 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 mock.patch( - "timecapsulesmb.cli.flows.probe_runtime_activation_state_conn", - return_value=self.runtime_activation_probe(RUNTIME_ACTIVATION_STATE_READY), - ): + with mock.patch("timecapsulesmb.services.activation.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(True)): with mock.patch("timecapsulesmb.cli.activate.run_remote_actions") as actions_mock: - with mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime") as verify_mock: + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn") as verify_mock: with redirect_stdout(output): rc = activate.main(["--yes"]) self.assertEqual(rc, 0) @@ -6955,39 +7331,46 @@ def test_activate_skips_rc_local_when_payload_is_already_healthy(self) -> None: verify_mock.assert_not_called() self.assertIn("already active; skipping rc.local", output.getvalue()) - def test_activate_skips_rc_local_when_startup_script_is_running(self) -> None: + def test_activate_returns_nonzero_when_verification_fails(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 mock.patch( - "timecapsulesmb.cli.flows.probe_runtime_activation_state_conn", - return_value=self.runtime_activation_probe(RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING), - ): - with mock.patch("timecapsulesmb.cli.activate.run_remote_actions") as actions_mock: - with mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=self.managed_runtime_probe(True)) as verify_mock: + with mock.patch("timecapsulesmb.services.activation.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(False)): + with mock.patch("timecapsulesmb.cli.activate.run_remote_actions"): + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(False)): with redirect_stdout(output): rc = activate.main(["--yes"]) - self.assertEqual(rc, 0) - actions_mock.assert_not_called() - verify_mock.assert_called_once() - self.assertIn("startup is already in progress", output.getvalue()) + self.assertEqual(rc, 1) + self.assertIn("NetBSD4 activation failed.", output.getvalue()) - def test_activate_returns_nonzero_when_verification_fails(self) -> None: + 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 mock.patch( - "timecapsulesmb.cli.flows.probe_runtime_activation_state_conn", - return_value=self.runtime_activation_probe(RUNTIME_ACTIVATION_STATE_NOT_READY), - ): - with mock.patch("timecapsulesmb.cli.activate.run_remote_actions"): - with mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=self.managed_runtime_probe(False)): - with redirect_stdout(output): - rc = activate.main(["--yes"]) - self.assertEqual(rc, 1) - self.assertIn("NetBSD4 activation failed.", output.getvalue()) + 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"])) + self.assertEqual( + payload["pre_activation_probe"], + { + "kind": "managed_runtime_ready", + "if_ready": ["skip_activation_actions"], + "if_not_ready": ["run_activation_actions", "verify_managed_runtime"], + }, + ) + + 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() @@ -7039,6 +7422,26 @@ def test_uninstall_dry_run_no_reboot_matches_no_reboot_execution_path(self) -> N self.assertIn("Post-uninstall checks:\n none", text) self.assertNotIn("SSH returns after reboot", text) + def test_uninstall_dry_run_no_wait_matches_no_wait_execution_path(self) -> None: + output = io.StringIO() + values = { + "TC_HOST": "root@10.0.0.2", + "TC_PASSWORD": "pw", + "TC_SSH_OPTS": "-o foo", + } + 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") + with redirect_stdout(output): + rc = uninstall.main(["--dry-run", "--no-wait"]) + + self.assertEqual(rc, 0) + text = output.getvalue() + self.assertIn("Reboot:\n yes", text) + self.assertIn("follow-up: return immediately after reboot request", text) + self.assertIn("Post-uninstall checks:\n none", text) + self.assertNotIn("wait for SSH down, then SSH up", text) + def test_uninstall_validates_only_host_and_ignores_legacy_payload_dir(self) -> None: values = { "TC_HOST": "root@10.0.0.2", @@ -7105,6 +7508,26 @@ def test_uninstall_json_outputs_plan(self) -> None: ], ) + def test_uninstall_no_wait_json_outputs_request_only_plan(self) -> None: + output = io.StringIO() + values = { + "TC_HOST": "root@10.0.0.2", + "TC_PASSWORD": "pw", + "TC_SSH_OPTS": "-o foo", + } + 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") + with redirect_stdout(output): + rc = uninstall.main(["--dry-run", "--json", "--no-wait"]) + + self.assertEqual(rc, 0) + payload = json.loads(output.getvalue()) + self.assertTrue(payload["reboot_required"]) + self.assertFalse(payload["wait_after_reboot"]) + self.assertEqual(payload["reboot_request"]["follow_up"], ["return_after_reboot_request"]) + self.assertEqual(payload["post_uninstall_checks"], []) + def test_uninstall_yes_reboots_and_verifies(self) -> None: output = io.StringIO() values = { @@ -7117,8 +7540,8 @@ def test_uninstall_yes_reboots_and_verifies(self) -> None: 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") uninstall_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) - run_ssh_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", side_effect=[True, True])) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.remote_request_reboot")) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", side_effect=[True, True])) verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall", return_value=VerificationResult(True, ()))) with redirect_stdout(output): rc = uninstall.main(["--yes"]) @@ -7137,6 +7560,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.services.reboot.remote_request_reboot")) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.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.services.reboot.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) + ) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.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", "--no-wait"]) + + self.assertEqual(rc, 1) + self.assertIn("ssh command failed with rc=255", output.getvalue()) + 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() @@ -7146,11 +7612,11 @@ def test_uninstall_reboot_request_timeout_continues_when_device_reboots(self) -> stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) stack.enter_context( mock.patch( - "timecapsulesmb.cli.flows.remote_request_reboot", + "timecapsulesmb.services.reboot.remote_request_reboot", side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), ) ) - wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True])) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", side_effect=[True, True])) verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall", return_value=VerificationResult(True, ()))) with redirect_stdout(output): rc = uninstall.main(["--yes"]) @@ -7177,11 +7643,11 @@ def test_uninstall_reboot_request_timeout_fails_when_device_never_goes_down(self stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) stack.enter_context( mock.patch( - "timecapsulesmb.cli.flows.remote_request_reboot", + "timecapsulesmb.services.reboot.remote_request_reboot", side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), ) ) - wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", return_value=False)) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", return_value=False)) verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) with redirect_stdout(output): rc = uninstall.main(["--yes"]) @@ -7212,7 +7678,7 @@ def test_uninstall_no_reboot_skips_reboot_and_returns_success(self) -> None: 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") uninstall_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) - run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.remote_request_reboot")) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.remote_request_reboot")) verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) with redirect_stdout(output): rc = uninstall.main(["--no-reboot"]) @@ -7266,7 +7732,7 @@ def fake_input(prompt: str) -> str: return_value=SimpleNamespace(model="AirPort7,120", syap="120"), ) ) - run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.remote_request_reboot")) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.remote_request_reboot")) with redirect_stdout(output): rc = uninstall.main([]) self.assertEqual(rc, 0) @@ -7289,8 +7755,8 @@ def test_uninstall_verify_failure_emits_failure_stage(self) -> None: 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")) - stack.enter_context(mock.patch("timecapsulesmb.cli.flows.remote_request_reboot")) - stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True])) + stack.enter_context(mock.patch("timecapsulesmb.services.reboot.remote_request_reboot")) + stack.enter_context(mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", side_effect=[True, True])) stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall", return_value=VerificationResult(False, ()))) with redirect_stdout(output): rc = uninstall.main(["--yes"]) @@ -7311,7 +7777,7 @@ def test_fsck_yes_reboots_and_waits_by_default(self) -> None: 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"),)) run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh", return_value=run_result)) - wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True])) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", side_effect=[True, True])) with redirect_stdout(output): rc = fsck.main(["--yes"]) self.assertEqual(rc, 0) @@ -7458,6 +7924,21 @@ def test_fsck_no_mounted_hfs_volumes_exits_with_clear_message(self) -> None: run_ssh_mock.assert_not_called() self.assertNotIn("MaSt", str(ctx.exception)) + def test_fsck_no_input_requires_yes_before_mounting_volumes(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"),)) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh")) + with redirect_stdout(output): + rc = fsck.main(["--no-input"]) + + self.assertEqual(rc, 1) + self.assertIn("Running `fsck` in non-interactive mode requires `--yes`", output.getvalue()) + mast_mocks.mounted_mast_volumes_conn.assert_not_called() + run_ssh_mock.assert_not_called() + def test_fsck_prompts_for_volume_when_multiple_hfs_volumes_are_mounted(self) -> None: output = io.StringIO() values = self.make_valid_env() @@ -7529,7 +8010,7 @@ def test_fsck_reboot_no_down_emits_failure_stage(self) -> None: 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"),)) stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh", return_value=run_result)) - wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", return_value=False)) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", return_value=False)) with redirect_stdout(output): rc = fsck.main(["--yes"]) self.assertEqual(rc, 1) @@ -7549,7 +8030,7 @@ def test_fsck_reboot_timeout_emits_failure_stage(self) -> None: 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"),)) stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh", return_value=run_result)) - stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, False])) + stack.enter_context(mock.patch("timecapsulesmb.services.reboot.wait_for_ssh_state_conn", side_effect=[True, False])) with redirect_stdout(output): rc = fsck.main(["--yes"]) self.assertEqual(rc, 1) diff --git a/tests/test_cli_context.py b/tests/test_cli_context.py index 20b3d670..76e74d30 100644 --- a/tests/test_cli_context.py +++ b/tests/test_cli_context.py @@ -17,13 +17,6 @@ from timecapsulesmb.cli.runtime import NonInteractivePromptError from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbedDeviceState, ProbeResult -from timecapsulesmb.device.storage import ( - MaStDiscoveryResult, - MaStVolume, - PayloadCandidateCheck, - PayloadHome, - PayloadHomeSelection, -) from timecapsulesmb.transport.ssh import SshConnection @@ -34,17 +27,6 @@ def make_context(self) -> CommandContext: def make_connection(self) -> SshConnection: return SshConnection("root@10.0.0.2", "pw", "-o foo") - def make_volume(self, partition_device: str = "dk2") -> MaStVolume: - return MaStVolume( - "wd0", - partition_device, - f"/Volumes/{partition_device}", - "Data", - "12345678-1234-1234-1234-123456789012", - True, - "hfs", - ) - def make_supported_compatibility(self) -> DeviceCompatibility: return DeviceCompatibility( os_name="NetBSD", @@ -77,7 +59,7 @@ def make_probe_state(self, compatibility: DeviceCompatibility | None = None) -> def test_confirm_or_fail_returns_prompt_result(self) -> None: context = self.make_context() - with mock.patch("timecapsulesmb.cli.context.runtime.confirm", return_value=True) as confirm_mock: + with mock.patch("timecapsulesmb.cli.context.cli_runtime.confirm", return_value=True) as confirm_mock: result = context.confirm_or_fail("Continue?", default=False, noninteractive_message="no stdin") self.assertTrue(result) @@ -95,7 +77,7 @@ def test_confirm_or_fail_records_noninteractive_failure(self) -> None: context = self.make_context() output = io.StringIO() with mock.patch( - "timecapsulesmb.cli.context.runtime.confirm", + "timecapsulesmb.cli.context.cli_runtime.confirm", side_effect=NonInteractivePromptError("no stdin"), ): with redirect_stdout(output): @@ -106,6 +88,28 @@ def test_confirm_or_fail_records_noninteractive_failure(self) -> None: self.assertEqual(context.error_lines, ["no stdin"]) self.assertIn("no stdin", output.getvalue()) + def test_to_operation_callbacks_updates_command_context(self) -> None: + context = self.make_context() + + with mock.patch("builtins.print") as print_mock: + callbacks = context.to_operation_callbacks() + callbacks.set_stage("reboot") + callbacks.update_fields(reboot_was_attempted=True) + callbacks.add_debug_fields(reboot_request_strategy="ssh") + callbacks.log("reboot requested") + + self.assertEqual(context.debug_stage, "reboot") + self.assertEqual(context.finish_fields["reboot_was_attempted"], True) + self.assertEqual(context.debug_fields["reboot_request_strategy"], "ssh") + print_mock.assert_called_once_with("reboot requested") + + def test_to_operation_callbacks_updates_context(self) -> None: + context = self.make_context() + + context.to_operation_callbacks().set_stage("reboot") + + self.assertEqual(context.debug_stage, "reboot") + def test_require_compatibility_uses_probe_state_without_runtime_reexport(self) -> None: context = self.make_context() context.connection = self.make_connection() @@ -118,64 +122,6 @@ def test_require_compatibility_uses_probe_state_without_runtime_reexport(self) - self.assertEqual(context.finish_fields["device_model"], "TimeCapsule8,119") self.assertEqual(context.finish_fields["device_os_version"], "NetBSD 6.0 (evbarm)") - def test_mount_mast_volumes_reads_then_mounts_and_records_debug(self) -> None: - context = self.make_context() - connection = self.make_connection() - read_volume = self.make_volume("dk2") - mounted_volume = self.make_volume("dk3") - - with mock.patch("timecapsulesmb.cli.context.read_mast_volumes_conn", return_value=(read_volume,)) as read_mock: - with mock.patch( - "timecapsulesmb.cli.context.mounted_mast_volumes_conn", - return_value=(mounted_volume,), - ) as mount_mock: - result = context.mount_mast_volumes(connection, wait_seconds=12, mount_stage="mount_hfs_volumes") - - self.assertEqual(result, (mounted_volume,)) - read_mock.assert_called_once_with(connection) - mount_mock.assert_called_once_with(connection, (read_volume,), wait_seconds=12) - self.assertEqual(context.debug_stage, "mount_hfs_volumes") - self.assertEqual(context.debug_fields["mast_volume_count"], 1) - self.assertEqual(context.debug_fields["mast_mounted_volume_count"], 1) - - def test_wait_and_select_payload_home_record_storage_diagnostics(self) -> None: - context = self.make_context() - connection = self.make_connection() - volume = self.make_volume("dk2") - discovery = MaStDiscoveryResult((volume,), 3, "MaSt=valid") - selection = PayloadHomeSelection( - PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), - (PayloadCandidateCheck(volume, True, True),), - ) - - with mock.patch("timecapsulesmb.cli.context.wait_for_mast_volumes_conn", return_value=discovery) as wait_mock: - result = context.wait_for_mast_volumes(connection, attempts=10, delay_seconds=3) - with mock.patch( - "timecapsulesmb.cli.context.select_payload_home_with_diagnostics_conn", - return_value=selection, - ) as select_mock: - selected = context.select_payload_home(connection, result.volumes, ".samba4", wait_seconds=7) - - wait_mock.assert_called_once_with(connection, attempts=10, delay_seconds=3) - select_mock.assert_called_once_with(connection, (volume,), ".samba4", wait_seconds=7) - self.assertEqual(selected.payload_home, PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4")) - self.assertEqual(context.debug_fields["mast_read_attempts"], 3) - self.assertIn("mast_candidate_checks", context.debug_fields) - self.assertNotIn("mast_acp_output", context.debug_fields) - - def test_wait_for_mast_volumes_records_raw_acp_output_when_empty(self) -> None: - context = self.make_context() - connection = self.make_connection() - raw_output = "MaSt=[]" - discovery = MaStDiscoveryResult((), 10, raw_output) - - with mock.patch("timecapsulesmb.cli.context.wait_for_mast_volumes_conn", return_value=discovery): - result = context.wait_for_mast_volumes(connection, attempts=10, delay_seconds=3) - - self.assertEqual(result.volumes, ()) - self.assertEqual(context.debug_fields["mast_acp_output_chars"], len(raw_output)) - self.assertEqual(context.debug_fields["mast_acp_output"], raw_output) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli_flows.py b/tests/test_cli_flows.py index a577a4a4..ac0212eb 100644 --- a/tests/test_cli_flows.py +++ b/tests/test_cli_flows.py @@ -14,27 +14,38 @@ sys.path.insert(0, str(SRC_ROOT)) from timecapsulesmb.cli.flows import ( - ACP_REBOOT_REQUEST_TIMEOUT_SECONDS, - DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, - REBOOT_UP_TIMEOUT_MESSAGE, - SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE, - observe_reboot_cycle, - request_deploy_reboot_and_wait, - request_reboot_and_wait, - request_ssh_reboot, wait_for_device_up, - wait_for_tcp_port_state, verify_managed_runtime_flow, ) from timecapsulesmb.device.probe import ( - ManagedMdnsTakeoverProbeResult, ManagedRuntimeProbeResult, - ManagedSmbdProbeResult, + ProbeStepResult, + ReadinessProbeResult, ) from timecapsulesmb.integrations.acp import ACPConnectionError +from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE +from timecapsulesmb.services.reboot import RebootFlowError, observe_reboot_cycle, request_reboot, request_reboot_and_wait +from timecapsulesmb.services.reboot import ACP_REBOOT_REQUEST_TIMEOUT_SECONDS, SSH_SHUTDOWN_REBOOT_PROGRESS_MESSAGE +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.runtime import wait_for_tcp_port_state from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError +REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." + + +def readiness_result(ready: bool, detail: str, lines: tuple[str, ...]) -> ReadinessProbeResult: + steps = [] + for index, line in enumerate(lines): + if line.startswith("PASS:"): + steps.append(ProbeStepResult(f"test_{index}", "pass", line.removeprefix("PASS:"))) + elif line.startswith("FAIL:"): + steps.append(ProbeStepResult(f"test_{index}", "fail", line.removeprefix("FAIL:"))) + else: + steps.append(ProbeStepResult(f"test_{index}", "fail", line)) + return ReadinessProbeResult(ready=ready, detail=detail, steps=tuple(steps)) + + class FakeCommandContext: def __init__(self) -> None: self.stages: list[str] = [] @@ -55,6 +66,14 @@ def add_debug_fields(self, **fields: object) -> None: if value is not None: self.debug_fields[key] = value + def to_operation_callbacks(self) -> OperationCallbacks: + return OperationCallbacks( + set_stage=self.set_stage, + log=print, + add_debug_fields=self.add_debug_fields, + update_fields=self.update_fields, + ) + def fail_with_error(self, message: str) -> None: self.error = message @@ -63,29 +82,45 @@ class CliFlowTests(unittest.TestCase): def make_connection(self) -> SshConnection: return SshConnection("root@10.0.0.2", "pw", "-o foo") + def reboot_callbacks(self, command_context: FakeCommandContext): + return command_context.to_operation_callbacks() + + def request_reboot_and_wait_default(self, command_context: FakeCommandContext, **kwargs) -> None: + request_reboot_and_wait( + self.make_connection(), + strategy=kwargs.pop("strategy", "acp_then_ssh"), + callbacks=self.reboot_callbacks(command_context), + down_timeout_seconds=kwargs.pop("down_timeout_seconds", 60), + up_timeout_seconds=kwargs.pop("up_timeout_seconds", 240), + reboot_no_down_message=kwargs.pop("reboot_no_down_message", "did not go down"), + reboot_up_timeout_message=kwargs.pop("reboot_up_timeout_message", REBOOT_UP_TIMEOUT_MESSAGE), + **kwargs, + ) + def managed_runtime_probe(self, ready: bool) -> ManagedRuntimeProbeResult: status = "PASS" if ready else "FAIL" detail = "managed runtime is ready" if ready else "managed runtime is not ready" - smbd = ManagedSmbdProbeResult(ready, detail, (f"{status}:managed smbd ready",)) - mdns = ManagedMdnsTakeoverProbeResult(ready, detail, (f"{status}:managed mDNS takeover active",)) + smbd = readiness_result(ready, detail, (f"{status}:managed smbd ready",)) + mdns = readiness_result(ready, detail, (f"{status}:managed mDNS takeover active",)) return ManagedRuntimeProbeResult( ready=ready, detail=detail, smbd=smbd, mdns=mdns, - lines=smbd.lines + mdns.lines, ) def test_wait_for_tcp_port_state_checks_before_sleeping(self) -> None: - with mock.patch("timecapsulesmb.cli.flows.tcp_open", return_value=True) as tcp_open_mock: - with mock.patch("timecapsulesmb.cli.flows.time.sleep") as sleep_mock: + with mock.patch("timecapsulesmb.services.runtime.time.sleep") as sleep_mock: + tcp_open_mock = mock.Mock(return_value=True) + with redirect_stdout(io.StringIO()): ok = wait_for_tcp_port_state( "10.0.0.2", 22, expected_state=True, timeout_seconds=30, interval_seconds=5, - verbose=False, + log=print, + tcp_open_func=tcp_open_mock, ) self.assertTrue(ok) @@ -109,18 +144,19 @@ def test_wait_for_device_up_checks_before_sleeping(self) -> None: def test_observe_reboot_cycle_succeeds_without_requesting_reboot(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: - with redirect_stdout(output): - ok = observe_reboot_cycle( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - down_timeout_seconds=90, - up_timeout_seconds=420, - ) + reboot_mock = mock.Mock() + wait_mock = mock.Mock(side_effect=[True, True]) + with redirect_stdout(output): + observe_reboot_cycle( + self.make_connection(), + callbacks=self.reboot_callbacks(command_context), + reboot_no_down_message="did not go down", + reboot_up_timeout_message=REBOOT_UP_TIMEOUT_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + wait_for_ssh_state=wait_mock, + ) - self.assertTrue(ok) reboot_mock.assert_not_called() self.assertEqual(wait_mock.call_args_list[0].kwargs, {"expected_up": False, "timeout_seconds": 90}) self.assertEqual(wait_mock.call_args_list[1].kwargs, {"expected_up": True, "timeout_seconds": 420}) @@ -130,17 +166,17 @@ def test_observe_reboot_cycle_succeeds_without_requesting_reboot(self) -> None: def test_request_reboot_and_wait_succeeds_after_acp_reboot_request(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch("timecapsulesmb.cli.flows.acp_reboot") as acp_reboot_mock: - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as ssh_reboot_mock: - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + acp_reboot_mock = mock.Mock() + ssh_reboot_mock = mock.Mock() + wait_mock = mock.Mock(side_effect=[True, True]) + with redirect_stdout(output): + self.request_reboot_and_wait_default( + command_context, + request_acp_reboot=acp_reboot_mock, + request_reboot_func=ssh_reboot_mock, + wait_for_ssh_state=wait_mock, + ) - self.assertTrue(ok) acp_reboot_mock.assert_called_once_with("10.0.0.2", "pw", timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) ssh_reboot_mock.assert_not_called() self.assertEqual(wait_mock.call_args_list[0].kwargs, {"expected_up": False, "timeout_seconds": 60}) @@ -154,21 +190,23 @@ def test_request_reboot_and_wait_succeeds_after_acp_reboot_request(self) -> None self.assertIn("ACP reboot requested.", output.getvalue()) self.assertIn("Waiting for the device to go down...", output.getvalue()) - def test_request_deploy_reboot_and_wait_uses_ssh_reboot_request(self) -> None: + def test_request_reboot_and_wait_can_use_ssh_reboot_request_only(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: - with mock.patch("timecapsulesmb.cli.flows.acp_reboot", side_effect=AssertionError("deploy should not use ACP reboot")): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: - with redirect_stdout(output): - ok = request_deploy_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + reboot_mock = mock.Mock() + acp_reboot_mock = mock.Mock(side_effect=AssertionError("SSH-only strategy should not use ACP reboot")) + wait_mock = mock.Mock(side_effect=[True, True]) + with redirect_stdout(output): + self.request_reboot_and_wait_default( + command_context, + strategy="ssh_shutdown_then_reboot", + request_reboot_func=reboot_mock, + request_acp_reboot=acp_reboot_mock, + wait_for_ssh_state=wait_mock, + ) - self.assertTrue(ok) reboot_mock.assert_called_once() + acp_reboot_mock.assert_not_called() self.assertEqual(wait_mock.call_args_list[0].kwargs, {"expected_up": False, "timeout_seconds": 60}) self.assertEqual(wait_mock.call_args_list[1].kwargs, {"expected_up": True, "timeout_seconds": 240}) self.assertEqual(command_context.finish_fields["reboot_was_attempted"], True) @@ -182,20 +220,17 @@ def test_request_deploy_reboot_and_wait_uses_ssh_reboot_request(self) -> None: def test_request_reboot_and_wait_uses_ssh_fallback_when_acp_fails(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch( - "timecapsulesmb.cli.flows.acp_reboot", - side_effect=ACPConnectionError("ACP timed out"), - ): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + reboot_mock = mock.Mock() + acp_reboot_mock = mock.Mock(side_effect=ACPConnectionError("ACP timed out")) + wait_mock = mock.Mock(side_effect=[True, True]) + with redirect_stdout(output): + self.request_reboot_and_wait_default( + command_context, + request_acp_reboot=acp_reboot_mock, + request_reboot_func=reboot_mock, + wait_for_ssh_state=wait_mock, + ) - self.assertTrue(ok) reboot_mock.assert_called_once() self.assertEqual(wait_mock.call_args_list[0].kwargs, {"expected_up": False, "timeout_seconds": 60}) self.assertEqual(wait_mock.call_args_list[1].kwargs, {"expected_up": True, "timeout_seconds": 240}) @@ -210,23 +245,15 @@ def test_request_reboot_and_wait_uses_ssh_fallback_when_acp_fails(self) -> None: def test_request_reboot_and_wait_observes_device_state_after_ssh_fallback_timeout(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch( - "timecapsulesmb.cli.flows.acp_reboot", - side_effect=ACPConnectionError("ACP timed out"), - ): - with mock.patch( - "timecapsulesmb.cli.flows.remote_request_reboot", - side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), - ): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + wait_mock = mock.Mock(side_effect=[True, True]) + with redirect_stdout(output): + self.request_reboot_and_wait_default( + command_context, + request_acp_reboot=mock.Mock(side_effect=ACPConnectionError("ACP timed out")), + request_reboot_func=mock.Mock(side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot")), + wait_for_ssh_state=wait_mock, + ) - self.assertTrue(ok) self.assertEqual(wait_mock.call_count, 2) 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") @@ -236,20 +263,15 @@ def test_request_reboot_and_wait_observes_device_state_after_ssh_fallback_timeou def test_request_reboot_and_wait_observes_device_state_after_ssh_fallback_error(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch( - "timecapsulesmb.cli.flows.acp_reboot", - side_effect=ACPConnectionError("ACP timed out"), - ): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh failed")): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, True]) as wait_mock: - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + wait_mock = mock.Mock(side_effect=[True, True]) + with redirect_stdout(output): + self.request_reboot_and_wait_default( + command_context, + request_acp_reboot=mock.Mock(side_effect=ACPConnectionError("ACP timed out")), + request_reboot_func=mock.Mock(side_effect=SshError("ssh failed")), + wait_for_ssh_state=wait_mock, + ) - self.assertTrue(ok) self.assertEqual(wait_mock.call_count, 2) self.assertEqual(command_context.debug_fields["ssh_reboot_succeeded"], False) self.assertEqual(command_context.debug_fields["ssh_reboot_error"], "ssh failed") @@ -260,9 +282,15 @@ def test_request_ssh_reboot_uses_ssh_only_strategy_and_progress_log(self) -> Non command_context = FakeCommandContext() messages: list[str] = [] output = io.StringIO() - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: - with redirect_stdout(output): - request_ssh_reboot(self.make_connection(), command_context, log=messages.append) + reboot_mock = mock.Mock() + with redirect_stdout(output): + request_reboot( + self.make_connection(), + strategy="ssh", + callbacks=self.reboot_callbacks(command_context), + progress_log=messages.append, + request_reboot_func=reboot_mock, + ) reboot_mock.assert_called_once() self.assertEqual(command_context.stages, ["reboot"]) @@ -276,12 +304,13 @@ def test_request_ssh_reboot_uses_ssh_only_strategy_and_progress_log(self) -> Non def test_request_ssh_reboot_records_timeout_without_raising(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch( - "timecapsulesmb.cli.flows.remote_request_reboot", - side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), - ): - with redirect_stdout(output): - request_ssh_reboot(self.make_connection(), command_context) + with redirect_stdout(output): + request_reboot( + self.make_connection(), + strategy="ssh", + callbacks=self.reboot_callbacks(command_context), + request_reboot_func=mock.Mock(side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot")), + ) self.assertEqual(command_context.debug_fields["reboot_request_strategy"], "ssh") self.assertEqual(command_context.debug_fields["ssh_reboot_succeeded"], False) @@ -289,121 +318,129 @@ 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 self.assertRaisesRegex(RebootFlowError, "SSH reboot request timed out"): + request_reboot( + self.make_connection(), + strategy="ssh", + callbacks=self.reboot_callbacks(command_context), + raise_on_request_error=True, + request_reboot_func=mock.Mock(side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot")), + ) + + 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() - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh failed")): - with redirect_stdout(output): - request_ssh_reboot(self.make_connection(), command_context) + with redirect_stdout(output): + request_reboot( + self.make_connection(), + strategy="ssh", + callbacks=self.reboot_callbacks(command_context), + request_reboot_func=mock.Mock(side_effect=SshError("ssh failed")), + ) 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") 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 self.assertRaisesRegex(RebootFlowError, "SSH reboot request failed"): + request_reboot( + self.make_connection(), + strategy="ssh", + callbacks=self.reboot_callbacks(command_context), + raise_on_request_error=True, + request_reboot_func=mock.Mock(side_effect=SshError("ssh failed")), + ) + + 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() - with mock.patch("timecapsulesmb.cli.flows.acp_reboot"): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", return_value=False) as wait_mock: - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + wait_mock = mock.Mock(return_value=False) + with self.assertRaisesRegex(RebootFlowError, "did not go down") as raised: + self.request_reboot_and_wait_default( + command_context, + request_acp_reboot=mock.Mock(), + wait_for_ssh_state=wait_mock, + ) - self.assertFalse(ok) wait_mock.assert_called_once() - self.assertEqual(command_context.error, "did not go down") + self.assertEqual(raised.exception.reason, "did_not_go_down") self.assertNotIn("device_came_back_after_reboot", command_context.finish_fields) - self.assertIn("did not go down", output.getvalue()) def test_request_reboot_and_wait_fails_after_ssh_fallback_timeout_when_device_never_goes_down(self) -> None: command_context = FakeCommandContext() - output = io.StringIO() - with mock.patch( - "timecapsulesmb.cli.flows.acp_reboot", - side_effect=ACPConnectionError("ACP timed out"), - ): - with mock.patch( - "timecapsulesmb.cli.flows.remote_request_reboot", - side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot"), - ): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", return_value=False) as wait_mock: - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="clear reboot failure", - ) + wait_mock = mock.Mock(return_value=False) + with self.assertRaisesRegex(RebootFlowError, "clear reboot failure") as raised: + self.request_reboot_and_wait_default( + command_context, + reboot_no_down_message="clear reboot failure", + request_acp_reboot=mock.Mock(side_effect=ACPConnectionError("ACP timed out")), + request_reboot_func=mock.Mock(side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: reboot")), + wait_for_ssh_state=wait_mock, + ) - self.assertFalse(ok) wait_mock.assert_called_once() - self.assertEqual(command_context.error, "clear reboot failure") + self.assertEqual(raised.exception.reason, "did_not_go_down") self.assertNotIn("device_came_back_after_reboot", command_context.finish_fields) - self.assertIn("clear reboot failure", output.getvalue()) def test_request_reboot_and_wait_fails_when_device_never_goes_down_after_all_request_errors(self) -> None: command_context = FakeCommandContext() output = io.StringIO() - with mock.patch( - "timecapsulesmb.cli.flows.acp_reboot", - side_effect=ACPConnectionError("ACP timed out"), - ): - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh failed")): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", return_value=False) as wait_mock: - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + wait_mock = mock.Mock(return_value=False) + with redirect_stdout(output): + with self.assertRaisesRegex(RebootFlowError, "did not go down") as raised: + self.request_reboot_and_wait_default( + command_context, + request_acp_reboot=mock.Mock(side_effect=ACPConnectionError("ACP timed out")), + request_reboot_func=mock.Mock(side_effect=SshError("ssh failed")), + wait_for_ssh_state=wait_mock, + ) - self.assertFalse(ok) wait_mock.assert_called_once() - self.assertEqual(command_context.error, "did not go down") + self.assertEqual(raised.exception.reason, "did_not_go_down") self.assertIn("SSH reboot request failed; checking whether the device is rebooting anyway...", output.getvalue()) def test_request_reboot_and_wait_fails_when_ssh_does_not_return(self) -> None: command_context = FakeCommandContext() - output = io.StringIO() - with mock.patch("timecapsulesmb.cli.flows.acp_reboot"): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, False]): - with redirect_stdout(output): - ok = request_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + with self.assertRaisesRegex(RebootFlowError, REBOOT_UP_TIMEOUT_MESSAGE) as raised: + self.request_reboot_and_wait_default( + command_context, + request_acp_reboot=mock.Mock(), + wait_for_ssh_state=mock.Mock(side_effect=[True, False]), + ) - self.assertFalse(ok) - self.assertEqual(command_context.error, REBOOT_UP_TIMEOUT_MESSAGE) - self.assertIn(REBOOT_UP_TIMEOUT_MESSAGE, output.getvalue()) + self.assertEqual(raised.exception.reason, "did_not_come_back_up") - def test_request_deploy_reboot_and_wait_fails_with_deploy_guidance_when_ssh_does_not_return(self) -> None: + def test_request_reboot_and_wait_uses_caller_timeout_message_when_ssh_does_not_return(self) -> None: command_context = FakeCommandContext() - output = io.StringIO() - with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot"): - with mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn", side_effect=[True, False]): - with redirect_stdout(output): - ok = request_deploy_reboot_and_wait( - self.make_connection(), - command_context, - reboot_no_down_message="did not go down", - ) + with self.assertRaisesRegex(RebootFlowError, "Timed out waiting for SSH after reboot") as raised: + self.request_reboot_and_wait_default( + command_context, + strategy="ssh_shutdown_then_reboot", + request_reboot_func=mock.Mock(), + wait_for_ssh_state=mock.Mock(side_effect=[True, False]), + reboot_up_timeout_message=DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, + ) - self.assertFalse(ok) - self.assertEqual(command_context.error, DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE) - self.assertIn("The payload was uploaded and the reboot request succeeded", output.getvalue()) - self.assertIn("same network/wifi", output.getvalue()) - self.assertIn("tcapsule activate", output.getvalue()) + self.assertEqual(str(raised.exception), DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE) def test_verify_managed_runtime_flow_succeeds_when_runtime_ready(self) -> None: command_context = FakeCommandContext() with ( - mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=self.managed_runtime_probe(True)) as verify_mock, - mock.patch("timecapsulesmb.cli.flows.read_runtime_log_tails_conn") as log_tail_mock, + mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(True)) as verify_mock, + mock.patch("timecapsulesmb.services.runtime_verification.read_runtime_log_tails_conn") as log_tail_mock, ): ok = verify_managed_runtime_flow( self.make_connection(), @@ -424,9 +461,9 @@ def test_verify_managed_runtime_flow_fails_when_runtime_not_ready(self) -> None: command_context = FakeCommandContext() output = io.StringIO() with ( - mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=self.managed_runtime_probe(False)), + mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(False)), mock.patch( - "timecapsulesmb.cli.flows.read_runtime_log_tails_conn", + "timecapsulesmb.services.runtime_verification.read_runtime_log_tails_conn", return_value={ "remote_rc_local_log_tail": "rc log", "remote_mdns_log_tail": "mdns log", @@ -454,15 +491,15 @@ def test_verify_managed_runtime_flow_collects_network_diagnostics_after_auto_ip_ command_context = FakeCommandContext() output = io.StringIO() with ( - mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=self.managed_runtime_probe(False)), + mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(False)), mock.patch( - "timecapsulesmb.cli.flows.read_runtime_log_tails_conn", + "timecapsulesmb.services.runtime_verification.read_runtime_log_tails_conn", return_value={ "remote_manager_log_tail": "manager: mDNS startup deferred; no usable address has appeared yet", }, ), mock.patch( - "timecapsulesmb.cli.flows.read_remote_network_diagnostics_conn", + "timecapsulesmb.services.runtime_verification.read_remote_network_diagnostics_conn", return_value={ "remote_network_config": {"ssh_target_host": "169.254.44.9"}, "remote_network_target_ip_matches": [], @@ -490,8 +527,8 @@ def test_verify_managed_runtime_flow_keeps_original_failure_when_log_tail_fails( command_context = FakeCommandContext() output = io.StringIO() with ( - mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=self.managed_runtime_probe(False)), - mock.patch("timecapsulesmb.cli.flows.read_runtime_log_tails_conn", side_effect=RuntimeError("tail failed")), + mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=self.managed_runtime_probe(False)), + mock.patch("timecapsulesmb.services.runtime_verification.read_runtime_log_tails_conn", side_effect=RuntimeError("tail failed")), ): with redirect_stdout(output): ok = verify_managed_runtime_flow( @@ -509,23 +546,23 @@ def test_verify_managed_runtime_flow_keeps_original_failure_when_log_tail_fails( def test_verify_managed_runtime_flow_includes_runtime_timeout_detail(self) -> None: command_context = FakeCommandContext() - smbd = ManagedSmbdProbeResult(False, "managed smbd readiness probe timed out", ("FAIL:managed smbd readiness probe timed out",)) - mdns = ManagedMdnsTakeoverProbeResult(False, "managed mDNS takeover probe timed out", ("FAIL:managed mDNS takeover probe timed out",)) + smbd = readiness_result(False, "managed smbd readiness probe timed out", ("FAIL:managed smbd readiness probe timed out",)) + mdns = readiness_result(False, "managed mDNS takeover probe timed out", ("FAIL:managed mDNS takeover probe timed out",)) result = ManagedRuntimeProbeResult( ready=False, - detail="runtime verification timed out after 180s; managed smbd readiness probe timed out; managed mDNS takeover probe timed out", + detail="runtime verification timed out after 200s; managed smbd readiness probe timed out; managed mDNS takeover probe timed out", smbd=smbd, mdns=mdns, - lines=smbd.lines + mdns.lines + ("FAIL:runtime verification timed out after 180s",), + extra_steps=(ProbeStepResult("runtime_timeout", "fail", "runtime verification timed out after 200s"),), ) output = io.StringIO() - with mock.patch("timecapsulesmb.cli.flows.verify_managed_runtime", return_value=result): + with mock.patch("timecapsulesmb.services.runtime_verification.probe_managed_runtime_conn", return_value=result): with redirect_stdout(output): ok = verify_managed_runtime_flow( self.make_connection(), command_context, stage="verify_runtime", - timeout_seconds=180, + timeout_seconds=200, heading="Checking runtime", failure_message="NetBSD4 activation failed.", ) @@ -533,9 +570,9 @@ def test_verify_managed_runtime_flow_includes_runtime_timeout_detail(self) -> No self.assertFalse(ok) self.assertEqual( command_context.error, - "NetBSD4 activation failed. runtime verification timed out after 180s; managed smbd readiness probe timed out; managed mDNS takeover probe timed out", + "NetBSD4 activation failed. runtime verification timed out after 200s; managed smbd readiness probe timed out; managed mDNS takeover probe timed out", ) - self.assertIn("failed: runtime verification timed out after 180s", output.getvalue()) + self.assertIn("failed: runtime verification timed out after 200s", output.getvalue()) if __name__ == "__main__": diff --git a/tests/test_config.py b/tests/test_config.py index 7a0a61bc..84913e41 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ import shlex import tempfile import unittest +from unittest import mock from pathlib import Path import sys @@ -19,11 +20,11 @@ ConfigValidationError, build_mdns_device_model_txt, DEFAULTS, - extract_host, load_app_config, parse_bool, parse_env_file, parse_env_value, + preserved_env_file_values, require_valid_app_config, render_env_text, validate_app_config, @@ -34,6 +35,7 @@ validate_ssh_target, write_env_file, ) +from timecapsulesmb.core.net import endpoint_host from timecapsulesmb.core.paths import manifest_artifact_paths, resolve_app_paths, resolve_distribution_root @@ -120,7 +122,7 @@ def test_resolve_distribution_root_prefers_start_project_markers(self) -> None: artifact_path.parent.mkdir(parents=True, exist_ok=True) artifact_path.write_bytes(b"payload") self.assertEqual(resolve_distribution_root(nested), root) - self.assertEqual(resolve_app_paths(nested).env_path, root / ".env") + self.assertEqual(resolve_app_paths(nested).config_path, root / ".env") def test_render_env_text_contains_config_keys(self) -> None: values = dict(DEFAULTS) @@ -139,10 +141,46 @@ 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_ATA_IDLE_SECONDS=300", rendered) self.assertIn("TC_ATA_STANDBY=''", 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) @@ -235,6 +273,19 @@ def test_write_env_file_round_trips_configure_id(self) -> None: reparsed = parse_env_file(path) self.assertEqual(reparsed["TC_CONFIGURE_ID"], "12345678-1234-1234-1234-123456789012") + def test_write_env_file_is_atomic_when_replace_fails(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.5" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + path.write_text("TC_HOST='root@10.0.0.2'\n") + with mock.patch("timecapsulesmb.core.config.os.replace", side_effect=OSError("replace failed")): + with self.assertRaisesRegex(OSError, "replace failed"): + write_env_file(path, values) + + self.assertEqual(parse_env_file(path)["TC_HOST"], "root@10.0.0.2") + self.assertEqual(list(Path(tmp).glob(".env.*.tmp")), []) + def test_parse_env_value_falls_back_for_unbalanced_quotes(self) -> None: self.assertEqual(parse_env_value("'unterminated"), "unterminated") @@ -288,9 +339,9 @@ def test_render_env_text_falls_back_to_default_ssh_opts_when_missing(self) -> No rendered = render_env_text(values) self.assertIn(f"TC_SSH_OPTS={shlex.quote(DEFAULTS['TC_SSH_OPTS'])}", rendered) - def test_extract_host_removes_user_prefix(self) -> None: - self.assertEqual(extract_host("root@10.0.0.5"), "10.0.0.5") - self.assertEqual(extract_host("10.0.0.5"), "10.0.0.5") + def test_endpoint_host_removes_user_prefix(self) -> None: + self.assertEqual(endpoint_host("root@10.0.0.5"), "10.0.0.5") + self.assertEqual(endpoint_host("10.0.0.5"), "10.0.0.5") def test_build_mdns_device_model_txt(self) -> None: self.assertEqual(build_mdns_device_model_txt("TimeCapsule"), "model=TimeCapsule") @@ -326,10 +377,13 @@ def test_validate_mdns_device_model_rejects_unsupported_values(self) -> None: def test_validate_ssh_target_accepts_user_at_host_targets(self) -> None: self.assertIsNone(validate_ssh_target("root@10.0.0.2", "Device SSH target")) + self.assertIsNone(validate_ssh_target("root@10.0.0.2:22", "Device SSH target")) self.assertIsNone(validate_ssh_target("root@127.0.0.1", "Device SSH target")) self.assertIsNone(validate_ssh_target("root@localhost", "Device SSH target")) self.assertIsNone(validate_ssh_target("root@timecapsule.local", "Device SSH target")) + self.assertIsNone(validate_ssh_target("root@timecapsule.local:22", "Device SSH target")) self.assertIsNone(validate_ssh_target("admin_user@wan.example.com", "Device SSH target")) + self.assertIsNone(validate_ssh_target("root@[fd00::2]:22", "Device SSH target")) def test_validate_ssh_target_rejects_bare_or_unsafe_targets(self) -> None: self.assertEqual( @@ -343,6 +397,14 @@ def test_validate_ssh_target_rejects_bare_or_unsafe_targets(self) -> None: validate_ssh_target("root;reboot@10.0.0.2", "Device SSH target"), "Device SSH target username may contain only letters, numbers, dots, underscores, and hyphens.", ) + self.assertEqual( + validate_ssh_target("root@10.0.0.2:2222", "Device SSH target"), + "Device SSH target only supports the default SSH port 22. Set custom SSH ports in TC_SSH_OPTS.", + ) + self.assertEqual( + validate_ssh_target("root@timecapsule.local:ssh", "Device SSH target"), + "Device SSH target port must be numeric.", + ) self.assertEqual( validate_ssh_target("root@169.254.44.9", "Device SSH target"), "Device SSH target host must not be a link-local address. " @@ -382,11 +444,22 @@ def test_validate_app_config_uses_profiles(self) -> None: 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") + values["TC_DEBUG_LOGGING"] = "false" values["TC_ATA_IDLE_SECONDS"] = "-1" 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_ATA_IDLE_SECONDS") + values["TC_ATA_IDLE_SECONDS"] = "" + 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_ATA_IDLE_SECONDS") def test_flash_profile_ignores_deploy_only_settings(self) -> None: values = dict(DEFAULTS) @@ -399,13 +472,24 @@ 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" values["TC_ATA_IDLE_SECONDS"] = "bad" values["TC_ATA_STANDBY"] = "bad" config = AppConfig.from_values(values, file_values=values) self.assertEqual(validate_app_config(config, profile="flash"), []) - def test_flash_profile_requires_password(self) -> None: + def test_flash_profile_accepts_request_scoped_password(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.2" + values["TC_PASSWORD"] = "pw" + file_values = dict(values) + file_values.pop("TC_PASSWORD", None) + config = AppConfig.from_values(values, file_values=file_values) + + self.assertEqual(validate_app_config(config, profile="flash"), []) + + def test_flash_profile_still_requires_effective_password(self) -> None: values = dict(DEFAULTS) values["TC_HOST"] = "root@10.0.0.2" file_values = dict(values) @@ -417,6 +501,28 @@ def test_flash_profile_requires_password(self) -> None: self.assertEqual(errors[0].kind, "missing_key") self.assertEqual(errors[0].key, "TC_PASSWORD") + def test_doctor_profile_accepts_request_scoped_password(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.2" + values["TC_PASSWORD"] = "pw" + file_values = dict(values) + file_values.pop("TC_PASSWORD", None) + config = AppConfig.from_values(values, file_values=file_values) + + self.assertEqual(validate_app_config(config, profile="doctor"), []) + + def test_doctor_profile_still_requires_effective_password(self) -> None: + values = dict(DEFAULTS) + values["TC_HOST"] = "root@10.0.0.2" + file_values = dict(values) + file_values.pop("TC_PASSWORD", None) + config = AppConfig.from_values(values, file_values=file_values) + + errors = validate_app_config(config, profile="doctor") + + self.assertEqual(errors[0].kind, "missing_key") + self.assertEqual(errors[0].key, "TC_PASSWORD") + def test_validate_app_config_ignores_stale_device_model_syap_pair(self) -> None: values = dict(DEFAULTS) values["TC_HOST"] = "root@10.0.0.2" diff --git a/tests/test_configure_service.py b/tests/test_configure_service.py new file mode 100644 index 00000000..8edb8aa7 --- /dev/null +++ b/tests/test_configure_service.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Mapping +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.device.compat import compatibility_from_probe_result +from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState +from timecapsulesmb.integrations.acp import ACPAuthError, ACPConnectionError, ACPIdentity +from timecapsulesmb.services.configure import ( + ConfigureFlowError, + ConfigureFlowHooks, + ConfigureFlowRequest, + enable_ssh_and_reprobe, + run_configure_flow, +) +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.transport.ssh import SshConnection + + +class ConfigureServiceTests(unittest.TestCase): + def make_connection(self) -> SshConnection: + return SshConnection("root@10.0.0.2", "pw", "-o foo") + + def make_probe_state(self) -> ProbedDeviceState: + probe_result = ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=True, + error=None, + os_name="NetBSD", + os_release="6.0", + arch="evbarm", + elf_endianness="little", + airport_model="TimeCapsule8,119", + airport_syap="119", + ) + return ProbedDeviceState( + probe_result=probe_result, + compatibility=compatibility_from_probe_result(probe_result), + ) + + def make_auth_failed_probe_state(self) -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=False, + error="SSH authentication failed.", + os_name=None, + os_release=None, + arch=None, + elf_endianness=None, + ), + compatibility=None, + ) + + def make_unsupported_probe_state(self) -> ProbedDeviceState: + probe_result = ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=True, + error=None, + os_name="NetBSD", + os_release="5.0", + arch="evbarm", + elf_endianness="little", + ) + return ProbedDeviceState( + probe_result=probe_result, + compatibility=compatibility_from_probe_result(probe_result), + ) + + def callbacks(self) -> tuple[OperationCallbacks, list[str], list[str], list[dict[str, object]], list[dict[str, object]]]: + stages: list[str] = [] + logs: list[str] = [] + debug_fields: list[dict[str, object]] = [] + update_fields: list[dict[str, object]] = [] + return ( + OperationCallbacks( + set_stage=stages.append, + log=logs.append, + add_debug_fields=lambda **fields: debug_fields.append(fields), + update_fields=lambda **fields: update_fields.append(fields), + ), + stages, + logs, + debug_fields, + update_fields, + ) + + def test_enable_ssh_and_reprobe_enables_waits_and_reprobes(self) -> None: + connection = self.make_connection() + probe_state = self.make_probe_state() + callbacks, stages, logs, debug_fields, update_fields = self.callbacks() + with mock.patch("timecapsulesmb.services.acp_ssh.read_identity", return_value=ACPIdentity(syap=119)) as read_identity: + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh") as enable_ssh: + with mock.patch("timecapsulesmb.services.configure.wait_for_tcp_port_state", return_value=True) as wait: + with mock.patch("timecapsulesmb.services.configure.probe_connection_state", return_value=probe_state) as probe: + result = enable_ssh_and_reprobe(connection, timeout_seconds=12, callbacks=callbacks) + + self.assertIs(result, probe_state) + read_identity.assert_called_once_with("10.0.0.2", "pw", timeout=10.0) + enable_ssh.assert_called_once_with("10.0.0.2", "pw", reboot_device=True, log=callbacks.log, timeout=10.0) + wait.assert_called_once_with( + "10.0.0.2", + 22, + expected_state=True, + timeout_seconds=12, + service_name="SSH port", + log=callbacks.log, + ) + probe.assert_called_once_with(connection) + self.assertEqual(stages, ["acp_identity_probe", "acp_enable_ssh", "wait_for_ssh_after_acp", "ssh_probe_after_acp"]) + self.assertEqual( + debug_fields, + [ + {"configure_acp_enable_attempted": True, "ssh_initially_reachable": False}, + {"acp_identity_probe_attempted": True}, + {"acp_identity_probe_succeeded": True, "acp_identity_syap": "119"}, + {"acp_ssh_enable_attempted": True}, + {"acp_ssh_enable_succeeded": True}, + {"configure_acp_enable_succeeded": True}, + ], + ) + self.assertEqual(update_fields, [{"device_syap": "119"}, {"ssh_final_reachable": True}]) + self.assertIn("Attempting to enable SSH", logs[0]) + + def test_enable_ssh_and_reprobe_records_auth_failure_and_propagates(self) -> None: + callbacks, _stages, _logs, debug_fields, update_fields = self.callbacks() + with mock.patch("timecapsulesmb.services.acp_ssh.read_identity", side_effect=ACPAuthError("bad password")): + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh") as enable_ssh: + with mock.patch("timecapsulesmb.services.configure.wait_for_tcp_port_state") as wait: + with mock.patch("timecapsulesmb.services.configure.probe_connection_state") as probe: + with self.assertRaises(ACPAuthError): + enable_ssh_and_reprobe(self.make_connection(), callbacks=callbacks) + + enable_ssh.assert_not_called() + wait.assert_not_called() + probe.assert_not_called() + self.assertEqual( + debug_fields[-1], + { + "configure_acp_enable_succeeded": False, + "configure_retry_reason": "acp_authentication_failed", + }, + ) + self.assertEqual(update_fields, []) + + def test_enable_ssh_and_reprobe_records_generic_acp_failure_and_propagates(self) -> None: + callbacks, _stages, _logs, debug_fields, update_fields = self.callbacks() + with mock.patch("timecapsulesmb.services.acp_ssh.read_identity", side_effect=ACPConnectionError("connection failed")): + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh") as enable_ssh: + with mock.patch("timecapsulesmb.services.configure.wait_for_tcp_port_state") as wait: + with mock.patch("timecapsulesmb.services.configure.probe_connection_state") as probe: + with self.assertRaises(ACPConnectionError): + enable_ssh_and_reprobe(self.make_connection(), callbacks=callbacks) + + enable_ssh.assert_not_called() + wait.assert_not_called() + probe.assert_not_called() + self.assertEqual(debug_fields[-1], {"configure_acp_enable_succeeded": False}) + self.assertEqual(update_fields, []) + + def test_enable_ssh_and_reprobe_returns_none_when_ssh_does_not_open(self) -> None: + callbacks, stages, _logs, _debug_fields, update_fields = self.callbacks() + with mock.patch("timecapsulesmb.services.acp_ssh.read_identity", return_value=ACPIdentity(syap=119)): + with mock.patch("timecapsulesmb.services.acp_ssh.enable_ssh"): + with mock.patch("timecapsulesmb.services.configure.wait_for_tcp_port_state", return_value=False): + with mock.patch("timecapsulesmb.services.configure.probe_connection_state") as probe: + result = enable_ssh_and_reprobe(self.make_connection(), callbacks=callbacks) + + self.assertIsNone(result) + probe.assert_not_called() + self.assertEqual(stages, ["acp_identity_probe", "acp_enable_ssh", "wait_for_ssh_after_acp"]) + self.assertEqual(update_fields, [{"device_syap": "119"}, {"ssh_final_reachable": False}]) + + def test_run_configure_flow_probes_writes_identity_and_reports_context(self) -> None: + probe_state = self.make_probe_state() + written: dict[str, str] = {} + callbacks, stages, _logs, debug_fields, update_fields = self.callbacks() + seen_probe_states: list[ProbedDeviceState] = [] + + def write_env(_path: Path, values: Mapping[str, str]) -> None: + written.update(values) + + with tempfile.TemporaryDirectory() as tmp: + env_path = Path(tmp) / ".env" + result = run_configure_flow( + ConfigureFlowRequest( + existing={}, + env_path=env_path, + host="root@10.0.0.2", + password="pw", + ssh_opts="-o foo", + configure_id="config-id", + persist_password=False, + probe=mock.Mock(return_value=probe_state), + write_env=write_env, + ), + callbacks=callbacks, + hooks=ConfigureFlowHooks(after_probe=lambda _connection, state: seen_probe_states.append(state)), + ) + + self.assertIs(result.probe_state, probe_state) + self.assertEqual(seen_probe_states, [probe_state]) + self.assertEqual(result.identity.syap, "119") + self.assertEqual(result.identity.model, "TimeCapsule8,119") + self.assertEqual(written["TC_HOST"], "root@10.0.0.2") + self.assertNotIn("TC_PASSWORD", written) + self.assertEqual(stages, ["ssh_probe", "write_env"]) + self.assertIn({"ssh_final_reachable": True}, debug_fields) + self.assertIn({"ssh_final_reachable": True}, update_fields) + self.assertIn({"configure_id": "config-id", "device_syap": "119", "device_model": "TimeCapsule8,119"}, update_fields) + + def test_run_configure_flow_can_save_reachable_target_without_authentication(self) -> None: + probe_state = self.make_auth_failed_probe_state() + written: dict[str, str] = {} + + with tempfile.TemporaryDirectory() as tmp: + result = run_configure_flow( + ConfigureFlowRequest( + existing={}, + env_path=Path(tmp) / ".env", + host="root@10.0.0.2", + password="badpw", + ssh_opts="-o foo", + configure_id="config-id", + persist_password=True, + discovered_airport_syap="119", + probe=mock.Mock(return_value=probe_state), + write_env=lambda _path, values: written.update(values), + ), + hooks=ConfigureFlowHooks(save_without_authentication=lambda _state: True), + ) + + self.assertIs(result.probe_state, probe_state) + self.assertEqual(written["TC_PASSWORD"], "badpw") + self.assertEqual(written["TC_AIRPORT_SYAP"], "119") + self.assertEqual(written["TC_MDNS_DEVICE_MODEL"], "TimeCapsule8,119") + + def test_run_configure_flow_rejects_unsupported_compatible_probe(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ConfigureFlowError) as raised: + run_configure_flow( + ConfigureFlowRequest( + existing={}, + env_path=Path(tmp) / ".env", + host="root@10.0.0.2", + password="pw", + ssh_opts="-o foo", + configure_id="config-id", + persist_password=True, + probe=mock.Mock(return_value=self.make_unsupported_probe_state()), + ) + ) + + self.assertEqual(raised.exception.code, "unsupported_device") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deploy_modules.py b/tests/test_deploy_modules.py index 34a26415..ac642014 100644 --- a/tests/test_deploy_modules.py +++ b/tests/test_deploy_modules.py @@ -34,9 +34,6 @@ StopManagerAction, StopProcessAction, StopWatchdogAction, - ensure_volume_mounted_action, - install_permissions_action, - prepare_dirs_action, remote_action_to_jsonable, render_remote_action, ) @@ -60,6 +57,7 @@ DEFAULT_APPLE_MOUNT_WAIT_SECONDS, DEPLOY_STARTUP_ACTIVATE_NOW, DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, + DEPLOY_STARTUP_REBOOT_THEN_VERIFY, FLASH_TEXT_UPLOAD_TIMEOUT_SECONDS, GENERATED_FLASH_CONFIG_SOURCE, GENERATED_SMBPASSWD_SOURCE, @@ -81,10 +79,8 @@ ) from timecapsulesmb.deploy.verify import ( VerificationResult, - managed_runtime_ready, render_managed_runtime_verification, render_post_uninstall_verification, - verify_managed_runtime, verify_post_uninstall, ) from timecapsulesmb.core.config import AppConfig @@ -94,21 +90,17 @@ render_watchdog_process_present, ) from timecapsulesmb.device.probe import ( - ManagedMdnsTakeoverProbeResult, ManagedRuntimeProbeResult, - ManagedSmbdProbeResult, - RUNTIME_ACTIVATION_STATE_NOT_READY, - RUNTIME_ACTIVATION_STATE_READY, - RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING, + ProbeStepResult, + ReadinessProbeResult, SMBD_STATUS_HELPERS, - RuntimeStartupScriptsProbeResult, + RcLocalAutostartProbeResult, derive_runtime_naming_identity, extract_airport_identity_from_acp_output, extract_airport_identity_from_text, probe_remote_runtime_naming_identity_conn, probe_device_conn, - probe_runtime_activation_state_conn, - probe_runtime_startup_scripts_conn, + probe_netbsd4_rc_local_autostart_conn, probe_managed_runtime_conn, probe_managed_mdns_takeover_conn, probe_managed_smbd_conn, @@ -116,9 +108,30 @@ wait_for_ssh_state_conn, ) from timecapsulesmb.device.storage import MaStVolume, PayloadHome, mounted_mast_volumes_conn +from timecapsulesmb.services.activation import ActivationDecision, decide_manual_activation, decide_netbsd4_post_reboot_activation +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.deploy import ( + DeployArtifactPaths, + DeployCompletionMessages, + DeployPayloadContext, + PreparedDeployPlan, + complete_deployment_after_upload, +) from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError +def readiness_result(ready: bool, detail: str, lines: tuple[str, ...]) -> ReadinessProbeResult: + steps = [] + for index, line in enumerate(lines): + if line.startswith("PASS:"): + steps.append(ProbeStepResult(f"test_{index}", "pass", line.removeprefix("PASS:"))) + elif line.startswith("FAIL:"): + steps.append(ProbeStepResult(f"test_{index}", "fail", line.removeprefix("FAIL:"))) + else: + steps.append(ProbeStepResult(f"test_{index}", "fail", line)) + return ReadinessProbeResult(ready=ready, detail=detail, steps=tuple(steps)) + + class DeployModuleTests(unittest.TestCase): _mdns_binary_tmpdir: tempfile.TemporaryDirectory[str] | None = None _mdns_binary_path: Path | None = None @@ -158,6 +171,58 @@ def _mast_volume( "hfs", ) + def _prepared_deploy_plan( + self, + *, + startup_mode=DEPLOY_STARTUP_ACTIVATE_NOW, + payload_family: str = "netbsd6_samba4", + is_netbsd4: bool = False, + wait_after_reboot: bool = True, + ) -> PreparedDeployPlan: + payload_home = self._payload_home() + plan = build_deployment_plan( + "root@10.0.0.2", + payload_home, + Path("bin/smbd"), + Path("bin/mdns"), + Path("bin/nbns"), + startup_mode=startup_mode, + wait_after_reboot=wait_after_reboot, + ) + return PreparedDeployPlan( + payload_context=DeployPayloadContext( + compatibility=mock.Mock(), + payload_family=payload_family, + is_netbsd4=is_netbsd4, + startup_mode=startup_mode, + ), + artifacts=DeployArtifactPaths( + smbd=Path("bin/smbd"), + mdns_advertiser=Path("bin/mdns"), + nbns_advertiser=Path("bin/nbns"), + ), + payload_home=payload_home, + plan=plan, + ) + + def _operation_callbacks(self): + stages: list[str] = [] + logs: list[str] = [] + debug_fields: dict[str, object] = {} + finish_fields: dict[str, object] = {} + return ( + OperationCallbacks( + set_stage=stages.append, + log=logs.append, + add_debug_fields=debug_fields.update, + update_fields=finish_fields.update, + ), + stages, + logs, + debug_fields, + finish_fields, + ) + def _extract_shell_function(self, source: str, name: str) -> str: marker = f"{name}()" start = source.index(marker) @@ -6659,15 +6724,15 @@ def test_mounted_mast_volumes_mounts_each_volume_and_returns_successes(self) -> internal = self._mast_volume("dk2", name="Internal", builtin=True) external = self._mast_volume("dk5", disk_device="sd0", name="External", builtin=False) - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", side_effect=[True, False]) as mount_mock: + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", side_effect=[True, False]) as mount_mock: mounted = mounted_mast_volumes_conn(connection, (internal, external), wait_seconds=17) self.assertEqual(mounted, (internal,)) self.assertEqual( mount_mock.call_args_list, [ - mock.call(connection, internal, wait_seconds=17), - mock.call(connection, external, wait_seconds=17), + mock.call(connection, internal.volume_root, internal.device_path, wait_seconds=17), + mock.call(connection, external.volume_root, external.device_path, wait_seconds=17), ], ) @@ -6676,7 +6741,7 @@ def test_mounted_mast_volumes_returns_empty_when_no_volume_mounts(self) -> None: internal = self._mast_volume("dk2", name="Internal", builtin=True) external = self._mast_volume("dk5", disk_device="sd0", name="External", builtin=False) - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", return_value=False): + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", return_value=False): mounted = mounted_mast_volumes_conn(connection, (internal, external), wait_seconds=30) self.assertEqual(mounted, ()) @@ -6723,11 +6788,13 @@ def test_upload_deployment_payload_uploads_all_expected_files(self) -> None: with mock.patch("timecapsulesmb.deploy.executor.run_scp") as scp_mock: with mock.patch("timecapsulesmb.deploy.executor.run_ssh") as ssh_mock: with mock.patch("timecapsulesmb.deploy.executor.ensure_volume_root_mounted_conn", return_value=True) as mount_mock: + uploading = [] uploaded = [] upload_deployment_payload( plan, connection=connection, source_resolver=source_resolver, + on_uploading=uploading.append, on_uploaded=uploaded.append, ) self.assertEqual(scp_mock.call_count, 12) @@ -6775,6 +6842,7 @@ def test_upload_deployment_payload_uploads_all_expected_files(self) -> None: text_upload_timeouts = [call.kwargs.get("timeout") for call in scp_mock.call_args_list[4:]] self.assertEqual(text_upload_timeouts, [FLASH_TEXT_UPLOAD_TIMEOUT_SECONDS] * 6 + [None] * 2) self.assertEqual(ssh_mock.call_count, 14) + self.assertEqual(uploading, plan.uploads) self.assertEqual(uploaded, plan.uploads) def test_upload_deployment_payload_consumes_plan_uploads_directly(self) -> None: @@ -6832,19 +6900,15 @@ def test_upload_flash_file_uploads_tmp_then_installs_with_rename_and_cleanup(sel self.assertIn("mv -f /mnt/Flash/.mdns-advertiser.tmp /mnt/Flash/mdns-advertiser", ssh_commands[1]) self.assertIn("rm -f /mnt/Flash/.mdns-advertiser.tmp", ssh_commands[1]) - def test_verify_managed_runtime_passes_when_runtime_probe_succeeds(self) -> None: - result = ManagedRuntimeProbeResult( + def test_render_managed_runtime_verification_passes_when_runtime_probe_succeeds(self) -> None: + verification = ManagedRuntimeProbeResult( ready=True, detail="managed runtime is ready", - smbd=ManagedSmbdProbeResult(True, "managed smbd ready", ("PASS:managed smbd ready",)), - mdns=ManagedMdnsTakeoverProbeResult(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)), - lines=("PASS:managed smbd ready", "PASS:managed mDNS takeover active"), + smbd=readiness_result(True, "managed smbd ready", ("PASS:managed smbd ready",)), + mdns=readiness_result(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)), ) - with mock.patch("timecapsulesmb.deploy.verify.probe_managed_runtime_conn", return_value=result): - verification = verify_managed_runtime(SshConnection("host", "pw", "-o foo")) - self.assertIs(verification, result) - self.assertTrue(managed_runtime_ready(verification)) + self.assertTrue(verification.ready) self.assertEqual( render_managed_runtime_verification(verification, heading="NetBSD4 activation verification:"), [ @@ -6854,19 +6918,15 @@ def test_verify_managed_runtime_passes_when_runtime_probe_succeeds(self) -> None ], ) - def test_verify_managed_runtime_fails_when_runtime_probe_fails(self) -> None: - result = ManagedRuntimeProbeResult( + def test_render_managed_runtime_verification_fails_when_runtime_probe_fails(self) -> None: + verification = ManagedRuntimeProbeResult( ready=False, detail="managed runtime is not ready", - smbd=ManagedSmbdProbeResult(False, "managed smbd is not ready", ("FAIL:managed smbd is not ready",)), - mdns=ManagedMdnsTakeoverProbeResult(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)), - lines=("FAIL:managed smbd is not ready", "PASS:managed mDNS takeover active"), + smbd=readiness_result(False, "managed smbd is not ready", ("FAIL:managed smbd is not ready",)), + mdns=readiness_result(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)), ) - with mock.patch("timecapsulesmb.deploy.verify.probe_managed_runtime_conn", return_value=result): - verification = verify_managed_runtime(SshConnection("host", "pw", "-o foo")) - self.assertIs(verification, result) - self.assertFalse(managed_runtime_ready(verification)) + self.assertFalse(verification.ready) self.assertEqual( render_managed_runtime_verification(verification, heading="NetBSD4 activation verification:"), [ @@ -6979,36 +7039,6 @@ def test_probe_status_helpers_do_not_count_probe_shell_body_as_manager(self) -> self.assertIn("manager=0", result.stdout) self.assertIn("self=1", result.stdout) - def test_probe_status_helpers_detect_runtime_startup_scripts(self) -> None: - script = ( - SMBD_STATUS_HELPERS - + r''' -rc_local="101 1 S 0:00.00 sh /bin/sh /mnt/Flash/rc.local" -boot="102 1 S 0:00.00 sh /bin/sh /mnt/Flash/boot.sh" -start_samba="103 1 S 0:00.00 sh /bin/sh /mnt/Flash/start-samba.sh" -zombie_boot="104 1 Z 0:00.00 sh /bin/sh /mnt/Flash/boot.sh" -self_match=$(cat <<'EOF' -3308 11745 S 0:00.01 sh /bin/sh -c probe=/mnt/Flash/boot.sh -11745 11677 Ss 0:00.01 sh sh -c /bin/sh -c 'probe=/mnt/Flash/rc.local' -EOF -) -runtime_startup_script_present "$rc_local"; echo "rc-local=$?" -runtime_startup_script_present "$boot"; echo "boot=$?" -runtime_startup_script_present "$start_samba"; echo "start-samba=$?" -runtime_startup_script_present "$zombie_boot"; echo "zombie=$?" -runtime_startup_script_present "$self_match"; echo "self=$?" -''' - ) - - result = subprocess.run(["/bin/sh", "-c", script], check=False, text=True, capture_output=True) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("rc-local=0", result.stdout) - self.assertIn("boot=0", result.stdout) - self.assertIn("start-samba=1", result.stdout) - self.assertIn("zombie=1", result.stdout) - self.assertIn("self=1", result.stdout) - def test_smbd_status_helpers_pass_only_with_live_ram_auth_mount_and_manager(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) @@ -7377,126 +7407,310 @@ def test_probe_managed_smbd_returns_detail_when_probe_times_out(self) -> None: self.assertEqual(result.detail, "managed smbd readiness probe timed out") self.assertEqual(result.lines, ("FAIL:managed smbd readiness probe timed out",)) - def test_probe_managed_mdns_takeover_single_shot_checks_process_binding_and_apple_responder(self) -> None: + def test_probe_managed_mdns_takeover_uses_timed_subprobes(self) -> None: + ps_out = "123 1 S 0:00 mdns-advertiser /mnt/Flash/mdns-advertiser\n" + fstat_out = "root mdns-advertiser 123 4* internet dgram udp *:5353\n" with mock.patch( "timecapsulesmb.device.probe.run_ssh", - return_value=mock.Mock(returncode=0, stdout=""), + side_effect=[ + mock.Mock(returncode=0, stdout="/mnt/Flash/mdns-advertiser\n", stderr=""), + mock.Mock(returncode=0, stdout=ps_out, stderr=""), + mock.Mock(returncode=0, stdout="ipv4\n", stderr=""), + mock.Mock(returncode=0, stdout=fstat_out, stderr=""), + ], ) as run_ssh_mock: - self.assertTrue(probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=45).ready) - remote_command = run_ssh_mock.call_args.args[1] - self.assertIn("capture_ps_out()", remote_command) - self.assertIn("mdns_process_present()", remote_command) - self.assertIn("apple_mdns_present()", remote_command) - self.assertIn('capture_fstat_for_ucomm "$ps_out" mdns-advertiser', remote_command) - self.assertIn('/usr/bin/fstat -p "$1"', remote_command) - self.assertIn("mdns_bound_5353()", remote_command) - self.assertIn("--print-mdns-socket-families", remote_command) - self.assertNotIn("--check-auto-ip", remote_command) - self.assertNotIn('out="$(fstat 2>&1)"', remote_command) - self.assertNotIn("max_attempts", remote_command) - self.assertNotIn("sleep 5", remote_command) + result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=60) + + self.assertTrue(result.ready) + self.assertEqual([call.kwargs["timeout"] for call in run_ssh_mock.call_args_list], [8, 12, 24, 16]) + remote_commands = [call.args[1] for call in run_ssh_mock.call_args_list] + self.assertIn("[ ! -e \"$RUNTIME_MDNS_BIN\" ]", remote_commands[0]) + self.assertIn("ps axww", remote_commands[1]) + self.assertIn("--print-mdns-socket-families", remote_commands[2]) + self.assertIn("/usr/bin/fstat -p 123", remote_commands[3]) + self.assertIn("PASS:mdns-advertiser bound to required UDP 5353 listeners", result.lines) + self.assertIn("PASS:Apple mDNSResponder is stopped", result.lines) + + def test_probe_managed_mdns_takeover_retries_binary_probe_timeout_with_min_timeout(self) -> None: + ps_out = "123 1 S 0:00 mdns-advertiser /mnt/Flash/mdns-advertiser\n" + fstat_out = "root mdns-advertiser 123 4* internet dgram udp *:5353\n" + with mock.patch( + "timecapsulesmb.device.probe.run_ssh", + side_effect=[ + SshCommandTimeout("Timed out waiting for ssh command to finish: binary"), + mock.Mock(returncode=0, stdout="/mnt/Flash/mdns-advertiser\n", stderr=""), + mock.Mock(returncode=0, stdout=ps_out, stderr=""), + mock.Mock(returncode=0, stdout="ipv4\n", stderr=""), + mock.Mock(returncode=0, stdout=fstat_out, stderr=""), + ], + ) as run_ssh_mock: + result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=1) + + self.assertTrue(result.ready) + self.assertEqual([call.kwargs["timeout"] for call in run_ssh_mock.call_args_list[:2]], [5, 5]) + self.assertNotIn("FAIL:mdns-advertiser binary probe timed out after 5s", result.lines) - def test_probe_managed_mdns_takeover_returns_detail_when_not_ready(self) -> None: + def test_probe_managed_mdns_takeover_reports_binary_timeout_after_retry(self) -> None: with mock.patch( "timecapsulesmb.device.probe.run_ssh", - return_value=mock.Mock(returncode=1, stdout="FAIL:Apple mDNSResponder is still running\n"), + side_effect=[ + SshCommandTimeout("Timed out waiting for ssh command to finish: binary"), + SshCommandTimeout("Timed out waiting for ssh command to finish: binary"), + ], + ) as run_ssh_mock: + result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=1) + + self.assertFalse(result.ready) + self.assertEqual(result.detail, "mdns-advertiser binary probe timed out after 5s") + self.assertEqual([call.kwargs["timeout"] for call in run_ssh_mock.call_args_list], [5, 5]) + self.assertIn("FAIL:mdns-advertiser binary probe timed out after 5s", result.lines) + + def test_probe_managed_mdns_takeover_reports_apple_responder_conflict(self) -> None: + ps_out = ( + "123 1 S 0:00 mdns-advertiser /mnt/Flash/mdns-advertiser\n" + "124 1 S 0:00 mDNSResponder /usr/sbin/mDNSResponder\n" + ) + fstat_out = "root mdns-advertiser 123 4* internet dgram udp *:5353\n" + with mock.patch( + "timecapsulesmb.device.probe.run_ssh", + side_effect=[ + mock.Mock(returncode=0, stdout="/mnt/Flash/mdns-advertiser\n", stderr=""), + mock.Mock(returncode=0, stdout=ps_out, stderr=""), + mock.Mock(returncode=0, stdout="ipv4\n", stderr=""), + mock.Mock(returncode=0, stdout=fstat_out, stderr=""), + ], ): - result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=12) + result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=60) self.assertFalse(result.ready) self.assertEqual(result.detail, "Apple mDNSResponder is still running") - def test_probe_managed_mdns_takeover_returns_detail_when_probe_times_out(self) -> None: + def test_probe_managed_mdns_takeover_reports_socket_family_timeout(self) -> None: + ps_out = "123 1 S 0:00 mdns-advertiser /mnt/Flash/mdns-advertiser\n" with mock.patch( "timecapsulesmb.device.probe.run_ssh", - side_effect=SshCommandTimeout("Timed out waiting for ssh command to finish: runtime probe"), + side_effect=[ + mock.Mock(returncode=0, stdout="/mnt/Flash/mdns-advertiser\n", stderr=""), + mock.Mock(returncode=0, stdout=ps_out, stderr=""), + SshCommandTimeout("Timed out waiting for ssh command to finish: socket families"), + ], ): - result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=12) + result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=60) self.assertFalse(result.ready) - self.assertEqual(result.detail, "managed mDNS takeover probe timed out") - self.assertEqual(result.lines, ("FAIL:managed mDNS takeover probe timed out",)) + self.assertEqual(result.detail, "mdns-advertiser socket family probe timed out after 24s") + self.assertIn("FAIL:mdns-advertiser socket family probe timed out after 24s", result.lines) - def test_probe_runtime_startup_scripts_checks_rc_local_and_start_samba(self) -> None: + def test_probe_managed_mdns_takeover_reports_process_table_timeout(self) -> None: with mock.patch( "timecapsulesmb.device.probe.run_ssh", - return_value=mock.Mock(returncode=0, stdout="PASS:managed runtime startup script is running\n"), - ) as run_ssh_mock: - result = probe_runtime_startup_scripts_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=7) + side_effect=[ + mock.Mock(returncode=0, stdout="/mnt/Flash/mdns-advertiser\n", stderr=""), + SshCommandTimeout("Timed out waiting for ssh command to finish: ps"), + ], + ): + result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=60) + self.assertFalse(result.ready) + self.assertEqual(result.detail, "mDNS process table probe timed out after 12s") + self.assertIn("FAIL:mDNS process table probe timed out after 12s", result.lines) - self.assertTrue(result.running) - self.assertEqual(result.detail, "managed runtime startup script is running") - remote_command = run_ssh_mock.call_args.args[1] - self.assertIn("runtime_startup_script_present", remote_command) - self.assertIn("capture_ps_out", remote_command) - self.assertEqual(run_ssh_mock.call_args.kwargs["timeout"], 7) - - def test_probe_runtime_activation_state_detects_startup_before_running_rc_local(self) -> None: - startup_running = RuntimeStartupScriptsProbeResult( - running=True, - detail="managed runtime startup script is running", - lines=("PASS:managed runtime startup script is running",), - ) - with mock.patch("timecapsulesmb.device.probe.probe_runtime_startup_scripts_conn", return_value=startup_running): - with mock.patch("timecapsulesmb.device.probe.probe_managed_runtime_conn") as runtime_mock: - result = probe_runtime_activation_state_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=20) - - self.assertEqual(result.state, RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING) - self.assertIs(result.startup_scripts, startup_running) - runtime_mock.assert_not_called() - - def test_probe_runtime_activation_state_reports_ready_runtime(self) -> None: - startup_absent = RuntimeStartupScriptsProbeResult( - running=False, - detail="managed runtime startup script is not running", - lines=("FAIL:managed runtime startup script is not running",), - ) + def test_probe_managed_mdns_takeover_reports_fstat_timeout(self) -> None: + ps_out = "123 1 S 0:00 mdns-advertiser /mnt/Flash/mdns-advertiser\n" + with mock.patch( + "timecapsulesmb.device.probe.run_ssh", + side_effect=[ + mock.Mock(returncode=0, stdout="/mnt/Flash/mdns-advertiser\n", stderr=""), + mock.Mock(returncode=0, stdout=ps_out, stderr=""), + mock.Mock(returncode=0, stdout="ipv4\n", stderr=""), + SshCommandTimeout("Timed out waiting for ssh command to finish: fstat"), + ], + ): + result = probe_managed_mdns_takeover_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=60) + self.assertFalse(result.ready) + self.assertEqual(result.detail, "mdns-advertiser fstat probe timed out after 16s") + self.assertIn("FAIL:mdns-advertiser fstat probe timed out after 16s", result.lines) + + def test_probe_netbsd4_rc_local_autostart_detects_login_marker(self) -> None: + connection = SshConnection("host", "pw", "-o foo") + login = b"#!/bin/sh\nif [ -x /mnt/Flash/rc.local ]; then /mnt/Flash/rc.local; fi\n" + with mock.patch("timecapsulesmb.device.probe.run_ssh_capture_bytes", return_value=login) as run_mock: + result = probe_netbsd4_rc_local_autostart_conn(connection, timeout_seconds=7) + + self.assertTrue(result.enabled) + self.assertEqual(result.login_size, len(login)) + self.assertEqual(result.detail, "/etc/rc.d/LOGIN invokes /mnt/Flash/rc.local") + run_mock.assert_called_once() + self.assertEqual(run_mock.call_args.args[:2], (connection, "/bin/dd if=/etc/rc.d/LOGIN bs=4096 2>/dev/null")) + self.assertEqual(run_mock.call_args.kwargs["timeout"], 7) + + def test_probe_netbsd4_rc_local_autostart_reports_missing_marker(self) -> None: + with mock.patch("timecapsulesmb.device.probe.run_ssh_capture_bytes", return_value=b"#!/bin/sh\nexit 0\n"): + result = probe_netbsd4_rc_local_autostart_conn(SshConnection("host", "pw", "-o foo")) + + self.assertFalse(result.enabled) + self.assertEqual(result.detail, "/etc/rc.d/LOGIN does not invoke /mnt/Flash/rc.local") + + def test_decide_manual_activation_skips_ready_runtime(self) -> None: runtime_ready = ManagedRuntimeProbeResult( ready=True, detail="managed runtime is ready", - smbd=ManagedSmbdProbeResult(True, "managed smbd ready", ("PASS:managed smbd ready",)), - mdns=ManagedMdnsTakeoverProbeResult(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)), + smbd=readiness_result(True, "managed smbd ready", ("PASS:managed smbd ready",)), + mdns=readiness_result(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)), + ) + with mock.patch("timecapsulesmb.services.activation.probe_managed_runtime_conn", return_value=runtime_ready) as runtime_mock: + decision = decide_manual_activation(SshConnection("host", "pw", "-o foo"), runtime_probe_timeout_seconds=9) + + self.assertFalse(decision.run_actions) + self.assertFalse(decision.verify_runtime) + self.assertEqual(decision.reason, "runtime_already_ready") + self.assertIs(decision.runtime, runtime_ready) + runtime_mock.assert_called_once_with(SshConnection("host", "pw", "-o foo"), timeout_seconds=9) + + def test_decide_netbsd4_post_reboot_activation_uses_live_login_autostart(self) -> None: + autostart = RcLocalAutostartProbeResult( + enabled=True, + detail="/etc/rc.d/LOGIN invokes /mnt/Flash/rc.local", + login_size=128, + ) + with mock.patch("timecapsulesmb.services.activation.probe_netbsd4_rc_local_autostart_conn", return_value=autostart): + decision = decide_netbsd4_post_reboot_activation(SshConnection("host", "pw", "-o foo")) + + self.assertFalse(decision.run_actions) + self.assertTrue(decision.verify_runtime) + self.assertEqual(decision.reason, "firmware_autostart_enabled") + self.assertIs(decision.autostart, autostart) + + def test_complete_deployment_activate_now_runs_actions_and_verifies_runtime(self) -> None: + prepared_plan = self._prepared_deploy_plan(startup_mode=DEPLOY_STARTUP_ACTIVATE_NOW) + callbacks, stages, logs, _debug_fields, _finish_fields = self._operation_callbacks() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + run_actions = mock.Mock() + verify_runtime = mock.Mock() + request_reboot_func = mock.Mock() + request_reboot_and_wait_func = mock.Mock() + + result = complete_deployment_after_upload( + connection, + prepared_plan, + no_wait=False, + callbacks=callbacks, + run_remote_actions_func=run_actions, + request_reboot_func=request_reboot_func, + request_reboot_and_wait_func=request_reboot_and_wait_func, + verify_runtime_func=verify_runtime, + ) + + run_actions.assert_called_once_with(connection, prepared_plan.plan.activation_actions) + request_reboot_func.assert_not_called() + request_reboot_and_wait_func.assert_not_called() + verify_runtime.assert_called_once() + self.assertEqual(verify_runtime.call_args.kwargs["stage"], "verify_runtime_activation") + self.assertEqual(stages, ["activate_runtime"]) + self.assertIn("Starting deployed runtime without reboot.", logs) + self.assertFalse(result.reboot_requested) + self.assertFalse(result.rebooted) + self.assertTrue(result.verified) + + def test_complete_deployment_no_wait_requests_reboot_without_verifying_runtime(self) -> None: + prepared_plan = self._prepared_deploy_plan( + startup_mode=DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, + payload_family="netbsd4be_samba4", + is_netbsd4=True, + wait_after_reboot=False, + ) + callbacks, _stages, logs, _debug_fields, _finish_fields = self._operation_callbacks() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + request_reboot_func = mock.Mock() + request_reboot_and_wait_func = mock.Mock() + verify_runtime = mock.Mock() + + result = complete_deployment_after_upload( + connection, + prepared_plan, + no_wait=True, + callbacks=callbacks, + messages=DeployCompletionMessages(reboot_request_message="Requesting reboot..."), + request_reboot_func=request_reboot_func, + request_reboot_and_wait_func=request_reboot_and_wait_func, + verify_runtime_func=verify_runtime, ) - with mock.patch("timecapsulesmb.device.probe.probe_runtime_startup_scripts_conn", return_value=startup_absent): - with mock.patch("timecapsulesmb.device.probe.probe_managed_runtime_conn", return_value=runtime_ready): - result = probe_runtime_activation_state_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=20) - - self.assertEqual(result.state, RUNTIME_ACTIVATION_STATE_READY) - self.assertIs(result.runtime, runtime_ready) - self.assertIs(result.startup_scripts, startup_absent) - - def test_probe_runtime_activation_state_checks_startup_again_after_runtime_probe(self) -> None: - startup_absent = RuntimeStartupScriptsProbeResult( - running=False, - detail="managed runtime startup script is not running", - lines=("FAIL:managed runtime startup script is not running",), + + request_reboot_func.assert_called_once_with( + connection, + strategy="ssh_shutdown_then_reboot", + callbacks=callbacks, + raise_on_request_error=True, ) - startup_running = RuntimeStartupScriptsProbeResult( - running=True, - detail="managed runtime startup script is running", - lines=("PASS:managed runtime startup script is running",), + request_reboot_and_wait_func.assert_not_called() + verify_runtime.assert_not_called() + self.assertIn("Requesting reboot...", logs) + self.assertTrue(result.reboot_requested) + self.assertFalse(result.waited) + self.assertFalse(result.verified) + + def test_complete_deployment_netbsd4_runs_activation_after_reboot_when_autostart_missing(self) -> None: + prepared_plan = self._prepared_deploy_plan( + startup_mode=DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, + payload_family="netbsd4be_samba4", + is_netbsd4=True, ) - runtime_not_ready = ManagedRuntimeProbeResult( - ready=False, - detail="runtime verification timed out after 20s", - smbd=ManagedSmbdProbeResult(False, "managed smbd not ready", ("FAIL:managed smbd not ready",)), - mdns=ManagedMdnsTakeoverProbeResult(False, "managed mDNS takeover not active", ("FAIL:managed mDNS takeover not active",)), + callbacks, stages, logs, debug_fields, _finish_fields = self._operation_callbacks() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + run_actions = mock.Mock() + verify_runtime = mock.Mock() + request_reboot_and_wait_func = mock.Mock() + activation_decision = ActivationDecision( + run_actions=True, + verify_runtime=True, + reason="firmware_autostart_missing", + detail="/etc/rc.d/LOGIN does not invoke /mnt/Flash/rc.local", ) - with mock.patch( - "timecapsulesmb.device.probe.probe_runtime_startup_scripts_conn", - side_effect=[startup_absent, startup_running], - ) as startup_mock: - with mock.patch("timecapsulesmb.device.probe.probe_managed_runtime_conn", return_value=runtime_not_ready) as runtime_mock: - result = probe_runtime_activation_state_conn(SshConnection("host", "pw", "-o foo"), timeout_seconds=20) - - self.assertEqual(result.state, RUNTIME_ACTIVATION_STATE_STARTUP_RUNNING) - self.assertIs(result.runtime, runtime_not_ready) - self.assertIs(result.startup_scripts, startup_running) - self.assertEqual(startup_mock.call_count, 2) - runtime_mock.assert_called_once_with(SshConnection("host", "pw", "-o foo"), timeout_seconds=20) + + result = complete_deployment_after_upload( + connection, + prepared_plan, + no_wait=False, + callbacks=callbacks, + run_remote_actions_func=run_actions, + request_reboot_and_wait_func=request_reboot_and_wait_func, + decide_post_reboot_activation=mock.Mock(return_value=activation_decision), + verify_runtime_func=verify_runtime, + ) + + request_reboot_and_wait_func.assert_called_once() + run_actions.assert_called_once_with(connection, prepared_plan.plan.activation_actions) + verify_runtime.assert_called_once() + self.assertEqual(stages, ["probe_runtime", "post_reboot_activation"]) + self.assertEqual(debug_fields["activation_decision"], "firmware_autostart_missing") + self.assertTrue(debug_fields["manual_activation_required"]) + self.assertIn("Activating deployed runtime after reboot.", logs) + self.assertTrue(result.rebooted) + self.assertTrue(result.verified) + + def test_complete_deployment_netbsd6_reboot_waits_for_runtime(self) -> None: + prepared_plan = self._prepared_deploy_plan(startup_mode=DEPLOY_STARTUP_REBOOT_THEN_VERIFY) + callbacks, stages, logs, _debug_fields, _finish_fields = self._operation_callbacks() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + verify_runtime = mock.Mock() + + result = complete_deployment_after_upload( + connection, + prepared_plan, + no_wait=False, + callbacks=callbacks, + messages=DeployCompletionMessages(reboot_runtime_wait_message="Waiting for managed runtime..."), + request_reboot_and_wait_func=mock.Mock(), + verify_runtime_func=verify_runtime, + ) + + verify_runtime.assert_called_once() + self.assertEqual(verify_runtime.call_args.kwargs["stage"], "verify_runtime_reboot") + self.assertIn("Waiting for managed runtime...", logs) + self.assertEqual(stages, []) + self.assertTrue(result.verified) def test_probe_managed_runtime_polls_both_probes_and_rechecks_mdns_after_settle(self) -> None: - smbd_ready = ManagedSmbdProbeResult(True, "managed smbd ready", ("PASS:managed smbd ready",)) - mdns_not_ready = ManagedMdnsTakeoverProbeResult(False, "managed mDNS takeover not active", ("FAIL:managed mDNS takeover not active",)) - mdns_ready = ManagedMdnsTakeoverProbeResult(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)) + smbd_ready = readiness_result(True, "managed smbd ready", ("PASS:managed smbd ready",)) + mdns_not_ready = readiness_result(False, "managed mDNS takeover not active", ("FAIL:managed mDNS takeover not active",)) + mdns_ready = readiness_result(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)) connection = SshConnection("host", "pw", "-o foo") monotonic_values = iter([0.0, 0.0, 0.2, 1.3, 1.4, 5.1, 5.2, 5.3, 10.5, 10.6, 10.7]) with mock.patch("timecapsulesmb.device.probe.probe_managed_smbd_conn", side_effect=[smbd_ready, smbd_ready]) as smbd_mock: @@ -7511,9 +7725,9 @@ def test_probe_managed_runtime_polls_both_probes_and_rechecks_mdns_after_settle( self.assertIn(mock.call(3.0), sleep_mock.call_args_list) def test_probe_managed_runtime_continues_polling_after_single_probe_timeout(self) -> None: - smbd_timeout = ManagedSmbdProbeResult(False, "managed smbd readiness probe timed out", ("FAIL:managed smbd readiness probe timed out",)) - smbd_ready = ManagedSmbdProbeResult(True, "managed smbd ready", ("PASS:managed smbd ready",)) - mdns_ready = ManagedMdnsTakeoverProbeResult(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)) + smbd_timeout = readiness_result(False, "managed smbd readiness probe timed out", ("FAIL:managed smbd readiness probe timed out",)) + smbd_ready = readiness_result(True, "managed smbd ready", ("PASS:managed smbd ready",)) + mdns_ready = readiness_result(True, "managed mDNS takeover active", ("PASS:managed mDNS takeover active",)) connection = SshConnection("host", "pw", "-o foo") monotonic_values = iter([0.0, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) with mock.patch("timecapsulesmb.device.probe.probe_managed_smbd_conn", side_effect=[smbd_timeout, smbd_ready]) as smbd_mock: @@ -7532,8 +7746,8 @@ def test_probe_managed_runtime_continues_polling_after_single_probe_timeout(self self.assertEqual(mdns_mock.call_count, 2) def test_probe_managed_runtime_reports_readable_timeout(self) -> None: - smbd_timeout = ManagedSmbdProbeResult(False, "managed smbd readiness probe timed out", ("FAIL:managed smbd readiness probe timed out",)) - mdns_timeout = ManagedMdnsTakeoverProbeResult(False, "managed mDNS takeover probe timed out", ("FAIL:managed mDNS takeover probe timed out",)) + smbd_timeout = readiness_result(False, "managed smbd readiness probe timed out", ("FAIL:managed smbd readiness probe timed out",)) + mdns_timeout = readiness_result(False, "managed mDNS takeover probe timed out", ("FAIL:managed mDNS takeover probe timed out",)) connection = SshConnection("host", "pw", "-o foo") monotonic_values = iter([0.0, 0.0, 0.1, 0.2, 0.3, 1.1]) with mock.patch("timecapsulesmb.device.probe.probe_managed_smbd_conn", return_value=smbd_timeout): @@ -7598,10 +7812,12 @@ def test_reboot_then_activate_plan_contains_activation_actions(self) -> None: ) text = format_deployment_plan(plan) - self.assertIn("Remote actions (runtime activation):", text) + self.assertIn("Remote actions (post-reboot runtime start if firmware autostart is missing):", text) self.assertIn("/bin/sh /mnt/Flash/rc.local", text) self.assertIn("mode: reboot_then_activate", text) - self.assertIn("follow-up: run /mnt/Flash/rc.local after SSH returns", text) + self.assertIn("probe /etc/rc.d/LOGIN for /mnt/Flash/rc.local", text) + self.assertIn("if present: wait for managed runtime", text) + self.assertIn("if missing: run /mnt/Flash/rc.local, then wait for managed runtime", text) self.assertIn("managed runtime smb.conf is present", text) self.assertIn("smbd is bound to required TCP 445 sockets", text) self.assertIn("managed mDNS takeover becomes ready", text) @@ -7628,10 +7844,17 @@ def test_activate_now_plan_has_runtime_checks(self) -> None: ], ) self.assertEqual([check.id for check in plan.post_deploy_checks], [ + "managed_runtime_smbd_binary_present", "managed_runtime_smb_conf_present", + "active_smb_conf_passdb_ram", + "active_smb_conf_username_map_ram", + "active_smb_conf_xattr_tdb_persistent", + "managed_share_volumes_mounted", + "managed_runtime_manager_process", "managed_smbd_parent_process", "managed_smbd_bound_445", "managed_mdns_takeover_ready", + "managed_mdns_settle_healthy", ]) text = format_deployment_plan(plan) self.assertIn("mode: activate_now", text) @@ -7639,6 +7862,28 @@ def test_activate_now_plan_has_runtime_checks(self) -> None: self.assertIn("follow-up: run /mnt/Flash/rc.local without rebooting", text) self.assertIn("managed runtime smb.conf is present", text) + def test_reboot_then_activate_no_wait_plan_skips_post_reboot_activation_and_checks(self) -> None: + paths = self._payload_home("/Volumes/dk2", "samba4") + plan = build_deployment_plan( + "root@10.0.0.2", + paths, + Path("bin/smbd"), + Path("bin/mdns"), + Path("bin/nbns"), + startup_mode=DEPLOY_STARTUP_REBOOT_THEN_ACTIVATE, + wait_after_reboot=False, + ) + + self.assertTrue(plan.reboot_required) + self.assertFalse(plan.wait_after_reboot) + self.assertEqual(plan.activation_actions, []) + self.assertEqual(plan.post_deploy_checks, []) + text = format_deployment_plan(plan) + self.assertNotIn("Remote actions (runtime activation):", text) + self.assertIn("action: request reboot and return without post-reboot activation or verification", text) + self.assertIn("follow-up: return immediately after reboot request", text) + self.assertIn("Post-deploy checks:\n none", text) + def test_build_uninstall_plan_stops_nbns_process(self) -> None: plan = build_uninstall_plan("root@10.0.0.2", ["/Volumes/dk2"], ["/Volumes/dk2/samba4"]) rendered = [render_remote_action(action) for action in plan.remote_actions] @@ -7703,18 +7948,18 @@ def test_render_remove_path_refuses_flash_root(self) -> None: def test_remote_action_rendering_quotes_payload_paths_with_spaces(self) -> None: payload_dir = "/Volumes/dk2/Time Capsule Samba 4" prepare_cmd = render_remote_action( - prepare_dirs_action( - [payload_dir, f"{payload_dir}/private", f"{payload_dir}/cache"], - [RemoteSymlink("/root/tc netbsd4", "/mnt/Memory/samba4")], + PrepareDirsAction( + (payload_dir, f"{payload_dir}/private", f"{payload_dir}/cache"), + (RemoteSymlink("/root/tc netbsd4", "/mnt/Memory/samba4"),), ) ) permissions_cmd = render_remote_action( - install_permissions_action( - [ + InstallPermissionsAction( + ( RemotePermission(f"{payload_dir}/cache", "755"), RemotePermission(f"{payload_dir}/nbns-advertiser", "755"), RemotePermission(f"{payload_dir}/private/smbpasswd", "600"), - ] + ) ) ) self.assertIn("'/Volumes/dk2/Time Capsule Samba 4'", prepare_cmd) @@ -7735,16 +7980,6 @@ def test_remote_action_rendering_quotes_payload_paths_with_spaces(self) -> None: "/bin/sh '/mnt/Flash/Time Capsule SMB/rc.local'", ) - def test_collection_action_factories_normalize_to_tuples(self) -> None: - self.assertEqual( - prepare_dirs_action(["/payload"], [RemoteSymlink("/root/tc-netbsd7", "/mnt/Memory/samba4")]), - PrepareDirsAction(("/payload",), (RemoteSymlink("/root/tc-netbsd7", "/mnt/Memory/samba4"),)), - ) - self.assertEqual( - install_permissions_action([RemotePermission("/payload/private", "700")]), - InstallPermissionsAction((RemotePermission("/payload/private", "700"),)), - ) - def test_remote_action_json_preserves_dry_run_shape(self) -> None: self.assertEqual( remote_action_to_jsonable(StopProcessAction("smbd")), @@ -7775,8 +8010,8 @@ def test_render_remote_action_rejects_unknown_action_object(self) -> None: def test_deployment_plan_uses_install_permissions_action(self) -> None: paths = self._payload_home("/Volumes/dk2", "Time Capsule Samba 4") plan = build_deployment_plan("host", paths, Path("bin/smbd"), Path("bin/mdns"), Path("bin/nbns")) - self.assertEqual(plan.post_upload_actions[0], ensure_volume_mounted_action("/Volumes/dk2", "/dev/dk2", DEFAULT_APPLE_MOUNT_WAIT_SECONDS)) - self.assertIn(install_permissions_action(plan.permissions), plan.post_upload_actions) + self.assertEqual(plan.post_upload_actions[0], EnsureVolumeMountedAction("/Volumes/dk2", "/dev/dk2", DEFAULT_APPLE_MOUNT_WAIT_SECONDS)) + self.assertIn(InstallPermissionsAction(tuple(plan.permissions)), plan.post_upload_actions) def test_deployment_plan_guards_each_payload_write_action(self) -> None: paths = self._payload_home("/Volumes/dk2", "samba4") diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 1714429e..7aa7420b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -487,7 +487,7 @@ def test_discover_retries_pending_resolution_during_browse_window(self) -> None: with mock.patch("timecapsulesmb.discovery.bonjour.time.sleep") as fake_sleep: discover_snapshot_detailed(timeout=0.5) - fake_sleep.assert_called_once_with(0.5) + self.assertIn(mock.call(0.5), fake_sleep.call_args_list) fake_collector.resolve_pending.assert_has_calls([ mock.call(timeout_ms=500), mock.call(timeout_ms=3000), diff --git a/tests/test_discovery_devices.py b/tests/test_discovery_devices.py new file mode 100644 index 00000000..b5570120 --- /dev/null +++ b/tests/test_discovery_devices.py @@ -0,0 +1,127 @@ +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 test_derives_full_model_identifier_from_syap_when_model_is_missing(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="116", model="") + + device = device_candidates_from_records([record])[0] + + self.assertEqual(device.syap, "116") + self.assertEqual(device.model, "TimeCapsule6,116") + + def test_derives_full_model_identifier_from_syap_when_model_is_generic(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="119", model="TimeCapsule") + + device = device_candidates_from_records([record])[0] + + self.assertEqual(device.model, "TimeCapsule8,119") + + def test_keeps_explicit_model_when_syap_is_unknown(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="999", model="MysteryModel") + + device = device_candidates_from_records([record])[0] + + self.assertEqual(device.syap, "999") + self.assertEqual(device.model, "MysteryModel") + + 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_flash.py b/tests/test_flash.py index 5ed92726..eca26a2f 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -14,6 +14,7 @@ sys.path.insert(0, str(SRC_ROOT)) import timecapsulesmb.flash as flash_module +from timecapsulesmb.services import flash as flash_service from timecapsulesmb.flash import ( PATCHED_LOGIN_SCRIPT, STOCK_LOGIN_NETBSD4_DUMMY, @@ -28,6 +29,8 @@ inspection_to_jsonable, write_decision_for_bank, ) +from timecapsulesmb.integrations.acp import ACPAuthError +from timecapsulesmb.transport.ssh import SshConnection, SshError def make_gzip_member(data: bytes) -> bytes: @@ -68,6 +71,61 @@ def setUp(self) -> None: def tearDown(self) -> None: self._zopfli_patch.stop() + def test_service_read_flash_inputs_normalizes_syap_and_reads_login_last(self) -> None: + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + primary = b"primary" + secondary = b"secondary" + login = b"login" + logs: list[str] = [] + with mock.patch("timecapsulesmb.services.flash.run_ssh_capture_bytes", side_effect=[primary, secondary, login]) as capture: + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=[11, 22, 113]) as get_property: + inputs = flash_service.read_flash_inputs(connection, acp_host="10.0.0.2", password="pw", log=logs.append) + + self.assertEqual(inputs.primary, primary) + self.assertEqual(inputs.secondary, secondary) + self.assertEqual(inputs.cks1, 11) + self.assertEqual(inputs.cks2, 22) + self.assertEqual(inputs.syap, "113") + self.assertEqual(inputs.live_login, login) + self.assertEqual(capture.call_count, 3) + self.assertEqual([call.args[2] for call in get_property.mock_calls], ["cks1", "cks2", "syAP"]) + self.assertIn("Reading live /etc/rc.d/LOGIN...", logs) + + def test_service_read_flash_inputs_stops_before_login_when_acp_fails(self) -> None: + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + with mock.patch("timecapsulesmb.services.flash.run_ssh_capture_bytes", side_effect=[b"primary", b"secondary"]) as capture: + with mock.patch("timecapsulesmb.services.flash.get_property_int", side_effect=ACPAuthError("bad password")): + with self.assertRaises(FlashAnalysisError) as raised: + flash_service.read_flash_inputs(connection, acp_host="10.0.0.2", password="pw") + + self.assertEqual(capture.call_count, 2) + self.assertIn("ACP property cks1 read failed", str(raised.exception)) + + def test_service_read_flash_inputs_stops_before_acp_when_secondary_read_fails(self) -> None: + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + with mock.patch("timecapsulesmb.services.flash.run_ssh_capture_bytes", side_effect=[b"primary", SshError("rc=255")]): + with mock.patch("timecapsulesmb.services.flash.get_property_int") as get_property: + with self.assertRaises(SshError): + flash_service.read_flash_inputs(connection, acp_host="10.0.0.2", password="pw") + + get_property.assert_not_called() + + def test_service_validation_dump_preserves_validation_and_ssh_read_logs(self) -> None: + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + logs: list[str] = [] + with mock.patch("timecapsulesmb.services.flash.run_ssh_capture_bytes", return_value=b"bank") as capture: + payload = flash_service.dump_remote_bank_for_validation(connection, "/dev/rflash0.raw", log=logs.append) + + self.assertEqual(payload, b"bank") + capture.assert_called_once_with(connection, "/bin/dd if=/dev/rflash0.raw bs=65536 2>/dev/null", timeout=180) + self.assertEqual( + logs, + [ + "Reading back written firmware bank from /dev/rflash0.raw...", + "SSH: /bin/dd if=/dev/rflash0.raw bs=65536 2>/dev/null", + ], + ) + def test_find_footer_and_gzip_member_ignore_false_gzip_signature(self) -> None: bank = make_bank(extra_gzip_magic=b"\x1f\x8b\x08bad") footer = find_footer(bank) diff --git a/tests/test_flash_workflow.py b/tests/test_flash_workflow.py new file mode 100644 index 00000000..ba0bdacf --- /dev/null +++ b/tests/test_flash_workflow.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest import mock + +from timecapsulesmb.flash import ( + ActiveSelectionInfo, + BankAnalysis, + BankInspection, + FlashAnalysis, + FlashAnalysisError, + FlashInspection, + FooterInfo, + GzipMemberInfo, + LoginInfo, + PatchBuildInfo, + sha256_hex, +) +from timecapsulesmb.flash_payloads import AcpFlashPayload, AppleFirmwareMatch +from timecapsulesmb.flash_workflow import ( + FlashPlan, + expected_bank_after_write, + plan_check_apple, + plan_patch_primary, + plan_restore_apple, + require_active_and_inactive_valid, + require_patch_ready, + write_and_validate_plan, +) +from timecapsulesmb.integrations.acp import ACPError, ACPFlashResult +from timecapsulesmb.transport.ssh import SshConnection + + +def make_bank( + name: str = "primary", + *, + classification: str = "stock", + footer_valid: bool = True, + acp_checksum_matches: bool | None = True, + patch: PatchBuildInfo | None = None, + patch_error: str | None = None, + data: bytes | None = None, +) -> BankAnalysis: + data = data or b"abcdefghABCDEFGH" + return BankAnalysis( + name=name, + device=f"/dev/{name}", + data=data, + sha256=sha256_hex(data), + size=len(data), + footer=FooterInfo(offset=12, checksum=0x12345678, end_offset=8), + footer_valid=footer_valid, + acp_checksum=0x12345678, + acp_checksum_matches=acp_checksum_matches, + gzip_member=GzipMemberInfo(offset=0, consumed_length=8, decompressed=b"login"), + decompressed_sha256=sha256_hex(b"login"), + login=LoginInfo(classification=classification, offset=0, length=5, sha256=sha256_hex(b"login"), match_count=1), + kernel_identity_match=True, + kernel_identity_detail="matched", + active_selection_failures=(), + patch=patch, + patch_error=patch_error, + ) + + +def make_patch(bank: BankAnalysis) -> PatchBuildInfo: + return PatchBuildInfo( + target_bank_sha256=bank.sha256, + patched_image_sha256=sha256_hex(b"patched"), + patched_gzip_length=7, + compression_method="test", + changed_range_start=0, + changed_range_end=7, + footer_checksum=0x11111111, + target_bank=b"patched", + ) + + +def make_analysis( + *, + active_bank: str | None = "primary", + primary: BankAnalysis | None = None, + secondary: BankAnalysis | None = None, +) -> FlashAnalysis: + primary = primary or make_bank("primary") + secondary = secondary or make_bank("secondary") + candidates = () if active_bank is None else (active_bank,) + return FlashAnalysis( + primary=primary, + secondary=secondary, + active_bank=active_bank, + active_selection=ActiveSelectionInfo("single_candidate" if active_bank else "no_candidates", candidates, "test"), + ) + + +def make_inspection( + *, + primary: BankAnalysis | None = None, + secondary: BankAnalysis | None = None, + primary_backup_valid: bool = True, + secondary_backup_valid: bool = True, + primary_active_candidate: bool = True, +) -> FlashInspection: + primary = primary or make_bank("primary", patch=make_patch(make_bank("primary"))) + secondary = secondary or make_bank("secondary") + return FlashInspection( + primary=BankInspection( + "primary", + "/dev/primary", + primary.size, + primary.sha256, + primary.acp_checksum, + primary, + primary_backup_valid, + () if primary_backup_valid else ("bad primary",), + primary_active_candidate, + () if primary_active_candidate else ("not active",), + None, + ), + secondary=BankInspection( + "secondary", + "/dev/secondary", + secondary.size, + secondary.sha256, + secondary.acp_checksum, + secondary, + secondary_backup_valid, + () if secondary_backup_valid else ("bad secondary",), + False, + ("inactive",), + None, + ), + active_selection=ActiveSelectionInfo("single_candidate", ("primary",), "test"), + ) + + +def make_payload(*, expected_prefix: bytes = b"PATCHED!") -> AcpFlashPayload: + return AcpFlashPayload( + data=b"payload", + expected_prefix=expected_prefix, + expected_login_classification="already_patched", + template_source="template", + template_path=Path("/tmp/template.basebinary"), + template_product_id="116", + template_version="7.8.1", + template_sha256=sha256_hex(b"template"), + payload_sha256=sha256_hex(b"payload"), + key_id="test-key", + inner_model=116, + inner_version=0x00070801, + inner_payload_size=len(expected_prefix), + ) + + +class FlashWorkflowTests(unittest.TestCase): + def test_require_active_and_inactive_valid_rejects_missing_active(self) -> None: + with self.assertRaisesRegex(FlashAnalysisError, "no firmware bank passed active selection"): + require_active_and_inactive_valid(make_analysis(active_bank=None)) + + def test_require_active_and_inactive_valid_rejects_bad_inactive_backup(self) -> None: + secondary = make_bank("secondary", acp_checksum_matches=False) + + with self.assertRaisesRegex(FlashAnalysisError, "inactive firmware bank backup did not validate"): + require_active_and_inactive_valid(make_analysis(secondary=secondary)) + + def test_require_patch_ready_covers_login_and_patch_states(self) -> None: + patched = make_bank("primary", classification="already_patched") + self.assertIs(require_patch_ready(make_analysis(primary=patched)), patched) + + with self.assertRaisesRegex(FlashAnalysisError, "LOGIN classification unknown"): + require_patch_ready(make_analysis(primary=make_bank("primary", classification="unknown"))) + + with self.assertRaisesRegex(FlashAnalysisError, "no patch candidate: too large"): + require_patch_ready(make_analysis(primary=make_bank("primary", patch=None, patch_error="too large"))) + + def test_plan_patch_primary_respects_force_warnings_and_noop(self) -> None: + patched = make_bank("primary", classification="already_patched") + plan = plan_patch_primary( + make_inspection(primary=patched, secondary_backup_valid=False, primary_active_candidate=False), + force=True, + syap="116", + firmware_template=None, + ) + + self.assertTrue(plan.already_satisfied) + self.assertFalse(plan.write_requested) + self.assertEqual(plan.warnings, ( + "patch forced despite one or more invalid backup banks", + "patch forced even though the primary bank did not pass active-candidate checks", + )) + + def test_plan_patch_primary_builds_payload_for_stock_primary(self) -> None: + base = make_bank("primary") + primary = make_bank("primary", patch=make_patch(base)) + payload = make_payload() + + with mock.patch("timecapsulesmb.flash_workflow.build_patch_payload_for_bank", return_value=payload) as build: + plan = plan_patch_primary(make_inspection(primary=primary), syap="116", firmware_template=None) + + build.assert_called_once_with(primary, syap="116", firmware_template=None, firmware_version=None, cache_dir=None) + self.assertTrue(plan.write_requested) + self.assertIs(plan.payload, payload) + + def test_restore_and_check_plans_cover_already_satisfied_states(self) -> None: + payload = make_payload(expected_prefix=b"abcdefgh") + match = AppleFirmwareMatch(True, "template", None, "116", "7.8.1", "sha", "inner", 8, "key", 116, 0x70801) + analysis = make_analysis() + + with mock.patch("timecapsulesmb.flash_workflow.build_restore_payload_for_active_bank", return_value=payload): + restore_plan = plan_restore_apple(analysis, syap="116", firmware_template=None) + with mock.patch("timecapsulesmb.flash_workflow.find_apple_firmware_match", return_value=match): + check_plan = plan_check_apple(analysis, syap="116", firmware_template=None) + + self.assertTrue(restore_plan.already_satisfied) + self.assertFalse(restore_plan.write_requested) + self.assertTrue(check_plan.already_satisfied) + self.assertFalse(check_plan.write_requested) + + def write_plan(self, *, payload: AcpFlashPayload | None = None, readback: bytes | None = None) -> tuple[FlashPlan, bytes]: + active = make_bank("primary") + payload = payload or make_payload() + expected, _checksum = expected_bank_after_write(active, payload) + plan = FlashPlan("patch", active, payload, None, False) + return plan, readback if readback is not None else expected + + def test_write_and_validate_plan_rejects_missing_payload(self) -> None: + with self.assertRaisesRegex(FlashAnalysisError, "no write payload"): + write_and_validate_plan( + connection=SshConnection("root@10.0.0.2", "pw", "-o test"), + acp_host="10.0.0.2", + plan=FlashPlan("patch", make_bank("primary"), None, None, False), + os_release="4.0", + flash_firmware_bank_func=mock.Mock(), + dump_remote_bank_func=mock.Mock(), + get_property_int_func=mock.Mock(), + timeout=30, + ) + + def test_write_and_validate_plan_reports_write_and_readback_failures(self) -> None: + plan, readback = self.write_plan() + connection = SshConnection("root@10.0.0.2", "pw", "-o test") + + with self.assertRaisesRegex(FlashAnalysisError, "ACP flash command failed"): + write_and_validate_plan( + connection=connection, + acp_host="10.0.0.2", + plan=plan, + os_release="4.0", + flash_firmware_bank_func=mock.Mock(side_effect=ACPError("bad write")), + dump_remote_bank_func=mock.Mock(), + get_property_int_func=mock.Mock(), + timeout=30, + ) + + with self.assertRaisesRegex(FlashAnalysisError, "prefix SHA-256 mismatch"): + write_and_validate_plan( + connection=connection, + acp_host="10.0.0.2", + plan=plan, + os_release="4.0", + flash_firmware_bank_func=mock.Mock(return_value=ACPFlashResult(0x26, b"ok")), + dump_remote_bank_func=mock.Mock(return_value=b"WRONG!!!" + readback[8:]), + get_property_int_func=mock.Mock(), + timeout=30, + ) + + with self.assertRaisesRegex(FlashAnalysisError, "firmware bank SHA-256 mismatch"): + write_and_validate_plan( + connection=connection, + acp_host="10.0.0.2", + plan=plan, + os_release="4.0", + flash_firmware_bank_func=mock.Mock(return_value=ACPFlashResult(0x26, b"ok")), + dump_remote_bank_func=mock.Mock(return_value=readback + b"extra"), + get_property_int_func=mock.Mock(), + timeout=30, + ) + + def test_write_and_validate_plan_checks_acp_checksum_footer_and_login(self) -> None: + plan, readback = self.write_plan() + connection = SshConnection("root@10.0.0.2", "pw", "-o test") + + cases = [ + (mock.Mock(side_effect=ACPError("no cks")), mock.Mock(), "ACP checksum property cks1 read failed"), + (mock.Mock(return_value=0x11111111), mock.Mock(return_value=SimpleNamespace(footer_valid=False)), "footer checksum is invalid"), + ( + mock.Mock(return_value=0x11111111), + mock.Mock(return_value=SimpleNamespace(footer_valid=True, acp_checksum_matches=False)), + "ACP cks1 does not match", + ), + ( + mock.Mock(return_value=0x11111111), + mock.Mock(return_value=SimpleNamespace( + footer_valid=True, + acp_checksum_matches=True, + login=SimpleNamespace(classification="stock"), + footer=SimpleNamespace(checksum=0x11111111), + )), + "LOGIN classification is stock", + ), + ] + for get_property, analyze, expected_error in cases: + with self.subTest(expected_error=expected_error): + with mock.patch("timecapsulesmb.flash_workflow.analyze_bank", analyze): + with self.assertRaisesRegex(FlashAnalysisError, expected_error): + write_and_validate_plan( + connection=connection, + acp_host="10.0.0.2", + plan=plan, + os_release="4.0", + flash_firmware_bank_func=mock.Mock(return_value=ACPFlashResult(0x26, b"ok")), + dump_remote_bank_func=mock.Mock(return_value=readback), + get_property_int_func=get_property, + timeout=30, + ) + + def test_write_and_validate_plan_returns_summary_on_success(self) -> None: + plan, readback = self.write_plan() + readback_analysis = SimpleNamespace( + footer_valid=True, + acp_checksum_matches=True, + login=SimpleNamespace(classification="already_patched"), + footer=SimpleNamespace(checksum=0x11111111), + ) + + with mock.patch("timecapsulesmb.flash_workflow.analyze_bank", return_value=readback_analysis): + result = write_and_validate_plan( + connection=SshConnection("root@10.0.0.2", "pw", "-o test"), + acp_host="10.0.0.2", + plan=plan, + os_release="4.0", + flash_firmware_bank_func=mock.Mock(return_value=ACPFlashResult(0x26, b"reply")), + dump_remote_bank_func=mock.Mock(return_value=readback), + get_property_int_func=mock.Mock(return_value=0x11111111), + timeout=30, + ) + + self.assertEqual(result["mode"], "patch") + self.assertEqual(result["bank"], "primary") + self.assertEqual(result["login_classification"], "already_patched") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_identity.py b/tests/test_identity.py index d29c0167..97581917 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -12,7 +12,7 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) -from timecapsulesmb.identity import ensure_install_id, load_install_identity, parse_bootstrap_values +from timecapsulesmb.identity import ensure_install_id, load_install_identity, parse_bootstrap_values, set_telemetry_enabled class IdentityTests(unittest.TestCase): @@ -35,6 +35,21 @@ def test_ensure_install_id_preserves_telemetry_false(self) -> None: self.assertEqual(identity.install_id, install_id) self.assertFalse(identity.telemetry_enabled) + def test_set_telemetry_enabled_preserves_install_id_and_updates_preference(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".bootstrap" + path.write_text("INSTALL_ID=install-one\n") + + disabled = set_telemetry_enabled(False, path) + self.assertEqual(disabled.install_id, "install-one") + self.assertFalse(disabled.telemetry_enabled) + self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one", "TELEMETRY": "false"}) + + enabled = set_telemetry_enabled(True, path) + self.assertEqual(enabled.install_id, "install-one") + self.assertTrue(enabled.telemetry_enabled) + self.assertEqual(parse_bootstrap_values(path), {"INSTALL_ID": "install-one"}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_import_boundaries.py b/tests/test_import_boundaries.py new file mode 100644 index 00000000..36c90650 --- /dev/null +++ b/tests/test_import_boundaries.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import ast +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" / "timecapsulesmb" + + +def _imports(path: Path) -> set[str]: + tree = ast.parse(path.read_text()) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module) + return modules + + +def _matches_prefix(module: str, prefixes: tuple[str, ...]) -> bool: + return any(module == prefix or module.startswith(f"{prefix}.") for prefix in prefixes) + + +def _import_violations(root: Path, forbidden_prefixes: tuple[str, ...]) -> list[str]: + offenders: list[str] = [] + for path in root.rglob("*.py"): + for module in _imports(path): + if _matches_prefix(module, forbidden_prefixes): + offenders.append(f"{path.relative_to(REPO_ROOT)} imports {module}") + return sorted(offenders) + + +def test_app_layer_does_not_import_cli_layer() -> None: + assert _import_violations( + SRC_ROOT / "app", + ("timecapsulesmb.cli",), + ) == [] + + +def test_services_layer_does_not_import_adapters() -> None: + assert _import_violations( + SRC_ROOT / "services", + ( + "timecapsulesmb.app", + "timecapsulesmb.cli", + ), + ) == [] + + +def test_domain_layers_do_not_import_adapters() -> None: + offenders: list[str] = [] + for package in ("core", "device", "deploy"): + offenders.extend( + _import_violations( + SRC_ROOT / package, + ( + "timecapsulesmb.app", + "timecapsulesmb.cli", + ), + ) + ) + + assert sorted(offenders) == [] + + +def test_deploy_adapters_do_not_import_low_level_deploy_dependencies() -> None: + forbidden_prefixes = ( + "timecapsulesmb.deploy.", + "timecapsulesmb.device.compat", + "timecapsulesmb.device.probe", + "timecapsulesmb.device.storage", + ) + offenders: list[str] = [] + for path in ( + SRC_ROOT / "cli" / "deploy.py", + SRC_ROOT / "app" / "ops" / "deploy.py", + ): + for module in _imports(path): + if any(module == prefix.rstrip(".") or module.startswith(prefix) for prefix in forbidden_prefixes): + offenders.append(f"{path.relative_to(REPO_ROOT)} imports {module}") + + assert offenders == [] diff --git a/tests/test_macos_package_app.py b/tests/test_macos_package_app.py new file mode 100644 index 00000000..62a74a89 --- /dev/null +++ b/tests/test_macos_package_app.py @@ -0,0 +1,1016 @@ +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +PACKAGE_SCRIPT = Path(__file__).resolve().parents[1] / "macos" / "TimeCapsuleSMB" / "tools" / "package_app.py" + + +def load_package_app_module(): + spec = importlib.util.spec_from_file_location("package_app", PACKAGE_SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def create_fake_app_executable_and_resources(app: Path) -> None: + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + resource_bundle = ( + app + / "Contents" + / "Resources" + / "TimeCapsuleSMBMac_TimeCapsuleSMBApp.bundle" + / "en.lproj" + ) + executable.parent.mkdir(parents=True, exist_ok=True) + resource_bundle.mkdir(parents=True, exist_ok=True) + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + create_fake_python_runtime(app) + (resource_bundle / "Localizable.strings").write_text('"screen.readiness" = "Readiness";\n', encoding="utf-8") + + +def create_fake_python_runtime(app: Path) -> None: + python_home = ( + app + / "Contents" + / "Resources" + / "Python" + / "Runtime" + / "Python.framework" + / "Versions" + / "Current" + ) + python_home.mkdir(parents=True, exist_ok=True) + (python_home / "bin").mkdir(parents=True, exist_ok=True) + (python_home / "bin" / "python3").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (python_home / "bin" / "python3").chmod(0o755) + (python_home / "Python").write_text("python framework", encoding="utf-8") + + +def create_fake_certifi_package(site_packages: Path) -> None: + certifi = site_packages / "certifi" + certifi.mkdir(parents=True, exist_ok=True) + (certifi / "__init__.py").write_text("def where(): return __file__\n", encoding="utf-8") + (certifi / "cacert.pem").write_text("test ca bundle\n", encoding="utf-8") + + +def test_smoke_request_accepts_successful_result_event(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + calls: list[dict[str, object]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append({"cmd": cmd, "kwargs": kwargs}) + return subprocess.CompletedProcess( + cmd, + 0, + stdout='{"type":"stage","operation":"capabilities"}\n{"type":"result","operation":"capabilities","ok":true}\n', + stderr="", + ) + + monkeypatch.setattr(package_app, "run", fake_run) + + package_app.smoke_request(tmp_path / "tcapsule", "capabilities", tmp_path) + + assert calls + assert calls[0]["cmd"] == [str(tmp_path / "tcapsule"), "api"] + + +def test_smoke_request_rejects_missing_result_event(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(cmd, 0, stdout='{"type":"stage","operation":"capabilities"}\n', stderr="") + + monkeypatch.setattr(package_app, "run", fake_run) + + with pytest.raises(RuntimeError, match="did not emit a result event"): + package_app.smoke_request(tmp_path / "tcapsule", "capabilities", tmp_path) + + +def test_smoke_request_rejects_failed_result_event(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + cmd, + 0, + stdout='{"type":"result","operation":"validate-install","ok":false}\n', + stderr="", + ) + + monkeypatch.setattr(package_app, "run", fake_run) + + with pytest.raises(RuntimeError, match="smoke test failed"): + package_app.smoke_request(tmp_path / "tcapsule", "validate-install", tmp_path) + + +def test_assert_bundle_layout_checks_helper_python_tools_and_artifacts( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, python_packages, tools, distribution / "bin" / "payloads"): + directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") + + monkeypatch.setattr(package_app, "artifact_paths", lambda: ["bin/payloads/one", "bin/payloads/two"]) + monkeypatch.setattr(package_app, "assert_python_dependencies_are_bundled", lambda app: None) + # This synthetic bundle-layout test should stay portable across the CI + # matrix. Dedicated tests below cover the macOS Mach-O validators directly. + monkeypatch.setattr(package_app, "assert_no_external_macho_dependencies", lambda app: None) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid", lambda app: None) + monkeypatch.setattr(package_app, "validate_app_resources", lambda app: None) + create_fake_certifi_package(python_packages) + (distribution / "bin" / "payloads" / "one").write_text("one", encoding="utf-8") + + with pytest.raises(RuntimeError, match="missing payload artifact"): + package_app.assert_bundle_layout(app) + + (distribution / "bin" / "payloads" / "two").write_text("two", encoding="utf-8") + + package_app.assert_bundle_layout(app) + + +def test_assert_bundle_layout_requires_artifact_manifest(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, python_packages, tools, distribution / "bin"): + directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + + with pytest.raises(RuntimeError, match="missing bundled artifact manifest"): + package_app.assert_bundle_layout(app) + + +def test_assert_bundle_layout_requires_python_packages(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, tools, distribution / "bin"): + directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + + with pytest.raises(RuntimeError, match="missing bundled Python packages"): + package_app.assert_bundle_layout(app) + + +def test_assert_bundle_layout_requires_swift_resource_bundle(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, executable.parent, python_packages, tools, distribution / "bin"): + directory.mkdir(parents=True) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + executable.chmod(0o755) + create_fake_python_runtime(app) + (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") + + with pytest.raises(RuntimeError, match="missing Swift resource bundle"): + package_app.assert_bundle_layout(app) + + +def test_build_swift_creates_universal_binary_with_lipo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + if cmd[:2] == ["swift", "build"]: + architecture = cmd[cmd.index("--triple") + 1].split("-", 1)[0] + executable = package_app.swift_build_dir("release", architecture) / "TimeCapsuleSMB" + executable.parent.mkdir(parents=True, exist_ok=True) + executable.write_text(architecture, encoding="utf-8") + executable.chmod(0o755) + if cmd and cmd[0] == "lipo": + output = Path(cmd[cmd.index("-output") + 1]) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("universal", encoding="utf-8") + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(package_app, "run", fake_run) + + executable, resource_build_dir = package_app.build_swift("release", ("arm64", "x86_64")) + + assert executable == tmp_path / ".build" / "package-app" / "release" / "TimeCapsuleSMB" + assert resource_build_dir == tmp_path / ".build" / "arm64-apple-macosx" / "release" + assert ["lipo", "-create"] == calls[-1][:2] + + +def test_remove_optional_zeroconf_extensions_keeps_pure_python_package(tmp_path: Path) -> None: + package_app = load_package_app_module() + zeroconf = tmp_path / "site-packages" / "zeroconf" + nested = zeroconf / "_services" + nested.mkdir(parents=True) + py_module = nested / "browser.py" + extension = nested / "browser.cpython-39-darwin.so" + py_module.write_text("# pure python fallback\n", encoding="utf-8") + extension.write_text("arm64 binary", encoding="utf-8") + + package_app.remove_optional_zeroconf_extensions(tmp_path / "site-packages") + + assert py_module.is_file() + assert not extension.exists() + + +def test_prune_python_runtime_removes_unused_gui_frameworks(tmp_path: Path) -> None: + package_app = load_package_app_module() + framework = tmp_path / "Python.framework" + version = framework / "Versions" / "3.13" + current = framework / "Versions" / "Current" + (version / "bin").mkdir(parents=True) + (version / "Python").write_text("python", encoding="utf-8") + (version / "bin" / "python3-intel64").write_text("intel shim", encoding="utf-8") + dynload = version / "lib" / "python3.13" / "lib-dynload" + dynload.mkdir(parents=True) + (dynload / "_tkinter.cpython-313-darwin.so").write_text("tk", encoding="utf-8") + for relative in ( + "Frameworks/Tcl.framework", + "Frameworks/Tk.framework", + "lib/tcl8.6", + "lib/tk8.6", + "lib/python3.13/idlelib", + "lib/python3.13/tkinter", + "lib/python3.13/test", + ): + (version / relative).mkdir(parents=True) + current.symlink_to(version) + + package_app.prune_python_runtime(framework) + + assert not (version / "bin" / "python3-intel64").exists() + assert not (version / "Frameworks" / "Tk.framework").exists() + assert not (version / "lib" / "python3.13" / "tkinter").exists() + assert not (dynload / "_tkinter.cpython-313-darwin.so").exists() + + +def test_create_app_icon_reuses_cached_icns(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + source = tmp_path / "tcs.jpg" + source.write_bytes(b"fake jpg") + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + if cmd[0] == "sips": + output = Path(cmd[cmd.index("--out") + 1]) + output.write_text("png", encoding="utf-8") + elif cmd[0] == "iconutil": + output = Path(cmd[cmd.index("-o") + 1]) + output.write_text("icns", encoding="utf-8") + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(package_app, "run", fake_run) + + first_resources = tmp_path / "FirstResources" + second_resources = tmp_path / "SecondResources" + first_resources.mkdir() + second_resources.mkdir() + + package_app.create_app_icon(source, first_resources) + assert calls + + calls.clear() + package_app.create_app_icon(source, second_resources) + + assert calls == [] + assert (second_resources / "TimeCapsuleSMB.icns").read_text(encoding="utf-8") == "icns" + + +def test_prepared_python_framework_reuses_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + calls: list[Path] = [] + source = tmp_path / "python.pkg" + source.write_text("pkg", encoding="utf-8") + + def fake_runtime_source(args: object) -> tuple[str, Path, dict[str, object]]: + return ("pkg", source, {"source_sha256": "pkg"}) + + def fake_extract(pkg: Path, destination: Path) -> Path: + calls.append(destination) + current = destination / "Versions" / "Current" + (current / "bin").mkdir(parents=True) + (current / "Python").write_text("python dylib", encoding="utf-8") + (current / "bin" / "python3").write_text("#!/bin/sh\n", encoding="utf-8") + return destination + + monkeypatch.setattr(package_app, "python_runtime_source", fake_runtime_source) + monkeypatch.setattr(package_app, "extract_python_framework", fake_extract) + monkeypatch.setattr(package_app, "prune_python_runtime", lambda framework: None) + monkeypatch.setattr(package_app, "rewrite_python_framework_install_names", lambda framework: None) + # This cache test runs on Linux CI; Mach-O validators are covered separately + # and shell out to macOS tools such as lipo and otool. + monkeypatch.setattr(package_app, "assert_macho_has_architectures", lambda path, architectures, label: None) + monkeypatch.setattr(package_app, "assert_macho_architectures_for_roots", lambda roots, architectures, label: None) + monkeypatch.setattr(package_app, "assert_no_external_macho_dependencies_for_roots", lambda roots: None) + monkeypatch.setattr(package_app, "ad_hoc_codesign_python_framework", lambda framework: None) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid_for_roots", lambda roots: None) + + args = SimpleNamespace() + first = package_app.prepared_python_framework(args, ("arm64", "x86_64")) + second = package_app.prepared_python_framework(args, ("arm64", "x86_64")) + + assert first == second + assert len(calls) == 1 + assert (second / "Versions" / "Current" / "bin" / "python3").is_file() + + +def assert_no_python_bytecode(root: Path) -> None: + assert not list(root.rglob("__pycache__")) + assert not list(root.rglob("*.pyc")) + assert not list(root.rglob("*.pyo")) + + +def create_python_bytecode(root: Path) -> None: + pycache = root / "timecapsulesmb" / "__pycache__" + pycache.mkdir(parents=True, exist_ok=True) + (pycache / "__init__.cpython-313.pyc").write_bytes(b"pyc") + (root / "timecapsulesmb" / "stale.pyo").write_bytes(b"pyo") + + +def test_python_subprocess_env_disables_bytecode_and_redirects_cache( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + + env = package_app.python_subprocess_env( + {"PYTHONDONTWRITEBYTECODE": "0", "PYTHONNOUSERSITE": "0"}, + python_home=tmp_path / "Python.framework" / "Versions" / "Current", + ) + + assert env["PYTHONDONTWRITEBYTECODE"] == "1" + assert env["PYTHONNOUSERSITE"] == "1" + assert env["PYTHONHOME"] == str(tmp_path / "Python.framework" / "Versions" / "Current") + assert env["PYTHONPYCACHEPREFIX"] == str(tmp_path / ".build" / "package-app" / "python-bytecode") + + +def test_build_python_packages_uses_bytecode_safe_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + calls: list[tuple[list[str], dict[str, str]]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((cmd, kwargs["env"])) # type: ignore[index] + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(package_app, "python_major_minor", lambda python: (3, 13)) + monkeypatch.setattr(package_app, "run", fake_run) + monkeypatch.setattr(package_app, "remove_optional_zeroconf_extensions", lambda site_packages: None) + + package_app.build_python_packages("python3", tmp_path / "site-packages") + + assert len(calls) == 4 + for _cmd, env in calls: + assert env["PYTHONDONTWRITEBYTECODE"] == "1" + assert env["PYTHONNOUSERSITE"] == "1" + assert Path(env["PYTHONPYCACHEPREFIX"]).name == "pycache" + + +def test_remove_python_bytecode_removes_nested_pycache_and_orphans(tmp_path: Path) -> None: + package_app = load_package_app_module() + root = tmp_path / "site-packages" + package = root / "timecapsulesmb" + create_python_bytecode(root) + (package / "module.py").write_text("value = 1\n", encoding="utf-8") + + package_app.remove_python_bytecode(root) + + assert (package / "module.py").is_file() + assert_no_python_bytecode(root) + + +def test_create_python_packages_reuses_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + cache_entry = tmp_path / "cache" / "site" + calls: list[Path] = [] + + def fake_build(python: str, site_packages: Path) -> None: + calls.append(site_packages) + package = site_packages / "timecapsulesmb" + package.mkdir(parents=True) + (package / "__init__.py").write_text("# cached package\n", encoding="utf-8") + create_python_bytecode(site_packages) + + monkeypatch.setattr(package_app, "python_site_packages_cache_entry", lambda python, architectures: cache_entry) + monkeypatch.setattr(package_app, "build_python_packages", fake_build) + + first_resources = tmp_path / "FirstResources" + second_resources = tmp_path / "SecondResources" + + package_app.create_python_packages("python3", first_resources, ("arm64",)) + package_app.create_python_packages("python3", second_resources, ("arm64",)) + + assert len(calls) == 1 + assert (first_resources / "Python" / "site-packages" / "timecapsulesmb" / "__init__.py").is_file() + assert (second_resources / "Python" / "site-packages" / "timecapsulesmb" / "__init__.py").is_file() + assert_no_python_bytecode(first_resources / "Python" / "site-packages") + assert_no_python_bytecode(second_resources / "Python" / "site-packages") + assert_no_python_bytecode(cache_entry / "site-packages") + + +def test_create_python_packages_cleans_bytecode_from_existing_cache( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + cache_entry = tmp_path / "cache" / "site" + cached_site_packages = cache_entry / "site-packages" + package = cached_site_packages / "timecapsulesmb" + package.mkdir(parents=True) + (package / "__init__.py").write_text("# cached package\n", encoding="utf-8") + create_python_bytecode(cached_site_packages) + (cache_entry / ".complete").write_text("ok\n", encoding="utf-8") + monkeypatch.setattr(package_app, "python_site_packages_cache_entry", lambda python, architectures: cache_entry) + monkeypatch.setattr(package_app, "build_python_packages", lambda python, site_packages: pytest.fail("cache was not reused")) + + resources = tmp_path / "Resources" + package_app.create_python_packages("python3", resources, ("arm64",)) + + assert (resources / "Python" / "site-packages" / "timecapsulesmb" / "__init__.py").is_file() + assert_no_python_bytecode(resources / "Python" / "site-packages") + + +def test_finalize_python_bundle_cleans_before_resigning( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + resources = tmp_path / "Resources" + framework = resources / "Python" / "Runtime" / "Python.framework" + site_packages = resources / "Python" / "site-packages" + framework.mkdir(parents=True) + site_packages.mkdir(parents=True) + create_python_bytecode(framework) + create_python_bytecode(site_packages) + calls: list[str] = [] + + def fake_sign_framework(path: Path) -> None: + assert path == framework + assert_no_python_bytecode(resources) + calls.append("framework") + + def fake_sign_site_packages(path: Path) -> None: + assert path == site_packages + assert_no_python_bytecode(resources) + calls.append("site-packages") + + monkeypatch.setattr(package_app, "ad_hoc_codesign_python_framework", fake_sign_framework) + monkeypatch.setattr(package_app, "ad_hoc_codesign_site_packages", fake_sign_site_packages) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid_for_roots", lambda roots: calls.append("verify")) + + package_app.finalize_python_bundle(resources) + + assert calls == ["framework", "site-packages", "verify"] + assert_no_python_bytecode(resources) + + +def test_package_args_do_not_allow_missing_bundled_tools() -> None: + package_app = load_package_app_module() + + args = package_app.parse_args([]) + assert not hasattr(args, "require_tools") + assert args.no_cache is False + assert args.full_validation is False + assert package_app.parse_args(["--no-cache"]).no_cache is True + assert package_app.parse_args(["--full-validation"]).full_validation is True + with pytest.raises(SystemExit): + package_app.parse_args(["--allow-missing-tools"]) + + +def test_helper_wrapper_uses_bundled_python_runtime(tmp_path: Path) -> None: + package_app = load_package_app_module() + helper = tmp_path / "tcapsule" + + package_app.write_helper_wrapper(helper) + + text = helper.read_text(encoding="utf-8") + assert "Python/Runtime/Python.framework/Versions/Current" in text + assert 'PYTHON="$PYTHON_HOME/bin/python3"' in text + assert 'export PYTHONHOME="$PYTHON_HOME"' in text + assert "certifi/cacert.pem" in text + assert "SSL_CERT_FILE" in text + assert "PYTHONDONTWRITEBYTECODE=1" in text + assert "/usr/bin/python3" not in text + + +def test_assert_bundle_layout_requires_bundled_ca_certificates( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, python_packages, tools, distribution / "bin"): + directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") + monkeypatch.setattr(package_app, "artifact_paths", lambda: []) + + with pytest.raises(RuntimeError, match="missing bundled CA certificates"): + package_app.assert_bundle_layout(app) + + +def test_assert_bundle_layout_uses_full_macho_validation_only_when_requested( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + helper = app / "Contents" / "Helpers" / "tcapsule" + python_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + distribution = app / "Contents" / "Resources" / "Distribution" + for directory in (helper.parent, python_packages, tools, distribution / "bin"): + directory.mkdir(parents=True) + create_fake_app_executable_and_resources(app) + helper.write_text("#!/bin/sh\n", encoding="utf-8") + helper.chmod(0o755) + (distribution / "artifact-manifest.json").write_text('{"artifacts":{}}', encoding="utf-8") + create_fake_certifi_package(python_packages) + calls: list[str] = [] + + monkeypatch.setattr(package_app, "artifact_paths", lambda: []) + monkeypatch.setattr(package_app, "assert_macho_has_architectures", lambda path, architectures, label: None) + monkeypatch.setattr(package_app, "assert_python_extension_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_tool_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_python_dependencies_are_bundled", lambda app: None) + monkeypatch.setattr(package_app, "validate_app_resources", lambda app: None) + monkeypatch.setattr(package_app, "assert_runtime_macho_architectures", lambda app, architectures: calls.append("runtime")) + monkeypatch.setattr(package_app, "assert_no_external_macho_dependencies", lambda app: calls.append("external")) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid", lambda app: calls.append("codesign")) + + package_app.assert_bundle_layout(app, architectures=("arm64",)) + assert calls == [] + + package_app.assert_bundle_layout(app, architectures=("arm64",), full_validation=True) + assert calls == ["runtime", "external", "codesign"] + + +def test_copy_tools_creates_arch_dispatch_wrappers(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + sources = tmp_path / "sources" + sources.mkdir() + for tool in ("sshpass", "smbclient"): + for architecture in ("arm64", "x86_64"): + source = sources / f"{tool}-{architecture}" + source.write_text(tool, encoding="utf-8") + source.chmod(0o755) + monkeypatch.setenv(f"TCAPSULE_PACKAGE_{tool.upper()}_{architecture.upper()}", str(source)) + + def fake_architectures(path: Path) -> set[str]: + if str(path).endswith("-arm64"): + return {"arm64"} + if str(path).endswith("-x86_64"): + return {"x86_64"} + return set() + + monkeypatch.setattr(package_app, "macho_architectures", fake_architectures) + monkeypatch.setattr(package_app.shutil, "which", lambda name: None) + + resources = tmp_path / "Resources" + package_app.copy_tools(resources, ("arm64", "x86_64")) + + tools_bin = resources / "Tools" / "bin" + assert "arm64) exec" in (tools_bin / "sshpass").read_text(encoding="utf-8") + assert "x86_64) exec" in (tools_bin / "smbclient").read_text(encoding="utf-8") + assert (tools_bin / "arm64" / "sshpass").is_file() + assert (tools_bin / "x86_64" / "smbclient").is_file() + + +def test_copy_tools_requires_each_architecture_when_requested(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + package_app = load_package_app_module() + arm_sshpass = tmp_path / "sshpass-arm64" + arm_sshpass.write_text("sshpass", encoding="utf-8") + arm_sshpass.chmod(0o755) + monkeypatch.setenv("TCAPSULE_PACKAGE_SSHPASS_ARM64", str(arm_sshpass)) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"} if path == arm_sshpass else set()) + monkeypatch.setattr(package_app.shutil, "which", lambda name: None) + + with pytest.raises(RuntimeError, match=r"sshpass \(x86_64\).*smbclient \(arm64\).*smbclient \(x86_64\)"): + package_app.copy_tools(tmp_path / "Resources", ("arm64", "x86_64")) + + +def test_copy_native_tools_layer_reuses_cached_vendored_layer( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + sources_dir = tmp_path / "sources" + sshpass = sources_dir / "sshpass" + smbclient = sources_dir / "smbclient" + dependency = sources_dir / "libnative.dylib" + for path in (sshpass, smbclient, dependency): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(path.name, encoding="utf-8") + path.chmod(0o755) + sources = { + ("sshpass", "arm64"): sshpass, + ("smbclient", "arm64"): smbclient, + } + vendor_calls: list[Path] = [] + + def fake_vendor(app: Path) -> set[Path]: + vendor_calls.append(app) + frameworks = app / "Contents" / "Frameworks" + frameworks.mkdir(parents=True, exist_ok=True) + (frameworks / "libnative.dylib").write_text("vendored", encoding="utf-8") + return {dependency} + + monkeypatch.setattr(package_app, "resolve_tool_sources", lambda architectures: sources) + monkeypatch.setattr(package_app, "vendor_macho_dependencies", fake_vendor) + monkeypatch.setattr(package_app, "ad_hoc_codesign_macho_bundle", lambda app: None) + monkeypatch.setattr(package_app, "assert_tool_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_runtime_macho_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_no_external_macho_dependencies", lambda app: None) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid", lambda app: None) + + first_app = tmp_path / "First.app" + second_app = tmp_path / "Second.app" + package_app.copy_native_tools_layer(first_app, ("arm64",)) + package_app.copy_native_tools_layer(second_app, ("arm64",)) + + assert len(vendor_calls) == 1 + assert (second_app / "Contents" / "Resources" / "Tools" / "bin" / "smbclient").is_file() + assert (second_app / "Contents" / "Frameworks" / "libnative.dylib").is_file() + + +def test_copy_native_tools_layer_rebuilds_when_vendored_input_changes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + sources_dir = tmp_path / "sources" + sshpass = sources_dir / "sshpass" + smbclient = sources_dir / "smbclient" + dependency = sources_dir / "libnative.dylib" + for path in (sshpass, smbclient, dependency): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("original", encoding="utf-8") + path.chmod(0o755) + sources = { + ("sshpass", "arm64"): sshpass, + ("smbclient", "arm64"): smbclient, + } + vendor_calls: list[Path] = [] + + def fake_vendor(app: Path) -> set[Path]: + vendor_calls.append(app) + frameworks = app / "Contents" / "Frameworks" + frameworks.mkdir(parents=True, exist_ok=True) + (frameworks / "libnative.dylib").write_text("vendored", encoding="utf-8") + return {dependency} + + monkeypatch.setattr(package_app, "resolve_tool_sources", lambda architectures: sources) + monkeypatch.setattr(package_app, "vendor_macho_dependencies", fake_vendor) + monkeypatch.setattr(package_app, "ad_hoc_codesign_macho_bundle", lambda app: None) + monkeypatch.setattr(package_app, "assert_tool_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_runtime_macho_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_no_external_macho_dependencies", lambda app: None) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid", lambda app: None) + + package_app.copy_native_tools_layer(tmp_path / "First.app", ("arm64",)) + dependency.write_text("changed", encoding="utf-8") + package_app.copy_native_tools_layer(tmp_path / "Second.app", ("arm64",)) + + assert len(vendor_calls) == 2 + + +def test_copy_native_tools_layer_rebuilds_when_cached_output_changes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + sources_dir = tmp_path / "sources" + sshpass = sources_dir / "sshpass" + smbclient = sources_dir / "smbclient" + dependency = sources_dir / "libnative.dylib" + for path in (sshpass, smbclient, dependency): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("original", encoding="utf-8") + path.chmod(0o755) + sources = { + ("sshpass", "arm64"): sshpass, + ("smbclient", "arm64"): smbclient, + } + vendor_calls: list[Path] = [] + + def fake_vendor(app: Path) -> set[Path]: + vendor_calls.append(app) + frameworks = app / "Contents" / "Frameworks" + frameworks.mkdir(parents=True, exist_ok=True) + (frameworks / "libnative.dylib").write_text("vendored", encoding="utf-8") + return {dependency} + + monkeypatch.setattr(package_app, "resolve_tool_sources", lambda architectures: sources) + monkeypatch.setattr(package_app, "vendor_macho_dependencies", fake_vendor) + monkeypatch.setattr(package_app, "ad_hoc_codesign_macho_bundle", lambda app: None) + monkeypatch.setattr(package_app, "assert_tool_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_runtime_macho_architectures", lambda app, architectures: None) + monkeypatch.setattr(package_app, "assert_no_external_macho_dependencies", lambda app: None) + monkeypatch.setattr(package_app, "assert_macho_code_signatures_valid", lambda app: None) + + package_app.copy_native_tools_layer(tmp_path / "First.app", ("arm64",)) + cache_entry = next((tmp_path / ".build" / "package-app" / "native-tools").iterdir()) + (cache_entry / "Contents" / "Frameworks" / "libnative.dylib").write_text("corrupt", encoding="utf-8") + package_app.copy_native_tools_layer(tmp_path / "Second.app", ("arm64",)) + + assert len(vendor_calls) == 2 + + +def test_vendor_macho_dependencies_rewrites_loader_path_to_matching_source_copy( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + tools = app / "Contents" / "Resources" / "Tools" / "bin" + arm_tool = tools / "arm64" / "smbclient" + x86_tool = tools / "x86_64" / "smbclient" + for tool in (arm_tool, x86_tool): + tool.parent.mkdir(parents=True, exist_ok=True) + tool.write_text("tool", encoding="utf-8") + tool.chmod(0o755) + + sources = tmp_path / "sources" + arm_i18n = sources / "arm64" / "libicui18n.78.dylib" + arm_icuuc = sources / "arm64" / "libicuuc.78.dylib" + arm_icudata = sources / "arm64" / "libicudata.78.dylib" + x86_i18n = sources / "x86_64" / "libicui18n.78.dylib" + x86_icuuc = sources / "x86_64" / "libicuuc.78.dylib" + x86_icudata = sources / "x86_64" / "libicudata.78.dylib" + for source in (arm_i18n, arm_icuuc, arm_icudata, x86_i18n, x86_icuuc, x86_icudata): + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text(source.parent.name, encoding="utf-8") + + def fake_dependencies(path: Path) -> list[str] | None: + resolved = path.resolve() + if resolved == arm_tool.resolve(): + return [str(arm_i18n)] + if resolved == x86_tool.resolve(): + return [str(x86_i18n)] + if path.name.startswith("libicui18n"): + return ["@loader_path/libicuuc.78.dylib", "@loader_path/libicudata.78.dylib"] + if path.name.startswith("libicuuc"): + return ["@loader_path/libicudata.78.dylib"] + return [] + + changes: list[list[str]] = [] + + def fake_run_quiet(cmd: list[str]) -> subprocess.CompletedProcess[str]: + changes.append(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(package_app, "macho_dependencies", fake_dependencies) + monkeypatch.setattr(package_app, "run_quiet", fake_run_quiet) + monkeypatch.setattr(package_app, "set_macho_id_if_supported", lambda path: None) + + package_app.vendor_macho_dependencies(app) + + frameworks = app / "Contents" / "Frameworks" + assert (frameworks / "libicuuc.78.dylib").is_file() + x86_icuuc_bundle = next(frameworks.glob("libicuuc-*.78.dylib")) + x86_icudata_bundle = next(frameworks.glob("libicudata-*.78.dylib")) + x86_i18n_bundle = next(frameworks.glob("libicui18n-*.78.dylib")) + + assert any( + cmd[0:3] == ["install_name_tool", "-change", "@loader_path/libicuuc.78.dylib"] + and cmd[3] == f"@loader_path/{x86_icuuc_bundle.name}" + and cmd[-1] == str(x86_i18n_bundle) + for cmd in changes + ) + assert any( + cmd[0:3] == ["install_name_tool", "-change", "@loader_path/libicudata.78.dylib"] + and cmd[3] == f"@loader_path/{x86_icudata_bundle.name}" + and cmd[-1] == str(x86_icuuc_bundle) + for cmd in changes + ) + + +def test_ad_hoc_codesign_macho_bundle_signs_only_macho_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + macho = app / "Contents" / "Resources" / "Tools" / "bin" / "smbclient" + script = tmp_path / "wrapper" + macho.parent.mkdir(parents=True) + macho.write_text("macho", encoding="utf-8") + script.write_text("#!/bin/sh\n", encoding="utf-8") + calls: list[list[str]] = [] + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [macho, script]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"} if path == macho else set()) + + def fake_run_quiet(cmd: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(package_app, "run_quiet", fake_run_quiet) + + package_app.ad_hoc_codesign_macho_bundle(app) + + assert calls == [["codesign", "--force", "--sign", "-", str(macho)]] + + +def test_ad_hoc_codesign_macho_bundle_does_not_sign_app_executable_as_nested_code( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + library = app / "Contents" / "Frameworks" / "libtool.dylib" + tool = app / "Contents" / "Resources" / "Tools" / "bin" / "smbclient" + for path in (executable, library, tool): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("macho", encoding="utf-8") + calls: list[Path] = [] + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [executable, tool, library]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"}) + monkeypatch.setattr(package_app, "ad_hoc_codesign", lambda path: calls.append(path)) + + package_app.ad_hoc_codesign_macho_bundle(app) + + assert executable not in calls + assert library in calls + assert tool in calls + + +def test_ad_hoc_codesign_macho_bundle_signs_python_framework_last( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + python_binary = app / "Contents" / "Resources" / "Python" / "Runtime" / "Python.framework" / "Versions" / "3.13" / "Python" + framework = app / "Contents" / "Resources" / "Python" / "Runtime" / "Python.framework" + python_binary.parent.mkdir(parents=True) + python_binary.write_text("python", encoding="utf-8") + calls: list[Path] = [] + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [python_binary]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"}) + monkeypatch.setattr(package_app, "ad_hoc_codesign", lambda path: calls.append(path)) + + package_app.ad_hoc_codesign_macho_bundle(app) + + assert calls == [python_binary, framework] + + +def test_assert_macho_code_signatures_valid_reports_invalid_signature( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + macho = app / "Contents" / "Resources" / "Tools" / "bin" / "smbclient" + macho.parent.mkdir(parents=True) + macho.write_text("macho", encoding="utf-8") + + monkeypatch.setattr(package_app, "macho_validation_roots", lambda app: [macho]) + monkeypatch.setattr(package_app, "macho_architectures", lambda path: {"arm64"}) + + def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="invalid signature\n") + + monkeypatch.setattr(package_app.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError, match="invalid Mach-O code signature"): + package_app.assert_macho_code_signatures_valid(app) + + +def test_macho_files_under_skips_symlink_aliases(tmp_path: Path) -> None: + package_app = load_package_app_module() + root = tmp_path / "root" + root.mkdir() + real = root / "libcrypto.3.dylib" + alias = root / "libcrypto.dylib" + real.write_text("macho", encoding="utf-8") + alias.symlink_to(real.name) + + paths = package_app.macho_files_under([root]) + + assert real in paths + assert alias not in paths + + +def test_runtime_macho_architecture_validation_checks_internal_dependencies( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + dependency = app / "Contents" / "Frameworks" / "libtool.dylib" + executable.parent.mkdir(parents=True) + dependency.parent.mkdir(parents=True) + executable.write_text("app", encoding="utf-8") + dependency.write_text("dependency", encoding="utf-8") + + def fake_architectures(path: Path) -> set[str]: + if path.resolve() == executable.resolve(): + return {"arm64", "x86_64"} + if path.resolve() == dependency.resolve(): + return {"arm64"} + return set() + + def fake_dependencies(path: Path) -> list[str] | None: + if path.resolve() == executable.resolve(): + return ["@loader_path/../Frameworks/libtool.dylib"] + return [] + + monkeypatch.setattr(package_app, "macho_architectures", fake_architectures) + monkeypatch.setattr(package_app, "macho_dependencies", fake_dependencies) + + with pytest.raises(RuntimeError, match=r"libtool\.dylib: missing x86_64"): + package_app.assert_runtime_macho_architectures(app, ("arm64", "x86_64")) + + +def test_python_dependency_validation_uses_bundled_python( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + package_app = load_package_app_module() + monkeypatch.setattr(package_app, "PACKAGE_ROOT", tmp_path) + app = tmp_path / "TimeCapsuleSMB.app" + create_fake_app_executable_and_resources(app) + site_packages = app / "Contents" / "Resources" / "Python" / "site-packages" + site_packages.mkdir(parents=True) + calls: list[tuple[list[str], dict[str, str]]] = [] + + def fake_run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((cmd, kwargs["env"])) # type: ignore[index] + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(package_app.subprocess, "run", fake_run) + + package_app.assert_python_dependencies_are_bundled(app) + + assert calls + cmd, env = calls[0] + assert cmd[0] == str(package_app.bundled_python_executable(app)) + assert env["PYTHONHOME"] == str(package_app.bundled_python_home(app)) + assert env["PYTHONPATH"] == str(site_packages) + assert env["PYTHONDONTWRITEBYTECODE"] == "1" + assert env["PYTHONNOUSERSITE"] == "1" + assert env["PYTHONPYCACHEPREFIX"] == str(tmp_path / ".build" / "package-app" / "python-bytecode") + + +def test_validate_app_resources_rejects_swift_resource_bundle_crash(tmp_path: Path) -> None: + package_app = load_package_app_module() + app = tmp_path / "TimeCapsuleSMB.app" + executable = app / "Contents" / "MacOS" / "TimeCapsuleSMB" + executable.parent.mkdir(parents=True) + executable.write_text("#!/bin/sh\necho resource crash >&2\nexit 70\n", encoding="utf-8") + executable.chmod(0o755) + + with pytest.raises(RuntimeError, match="App executable resource validation failed"): + package_app.validate_app_resources(app) diff --git a/tests/test_mdns_build.py b/tests/test_mdns_build.py new file mode 100644 index 00000000..987449b1 --- /dev/null +++ b/tests/test_mdns_build.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class MdnsBuildWrapperTests(unittest.TestCase): + def make_executable(self, path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(0o755) + + def prepare_fake_toolchain(self, out: Path, triple: str) -> None: + tools = out / "tools" / "bin" + sysroot = out / "obj" / "destdir.evbarm" + sysroot.mkdir(parents=True, exist_ok=True) + self.make_executable(tools / "nbmake", "#!/bin/sh\nexit 0\n") + self.make_executable(tools / "nbfile", "#!/bin/sh\nprintf '%s: fake ELF\\n' \"$1\"\n") + self.make_executable( + tools / f"{triple}-gcc", + textwrap.dedent( + """\ + #!/bin/sh + printf '%s\\n' "$@" > "$TEST_GCC_ARGS" + out= + while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out="$1" + fi + shift || break + done + mkdir -p "$(dirname "$out")" + printf 'fake mdns\\n' > "$out" + """ + ), + ) + self.make_executable( + tools / f"{triple}-strip", + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$TEST_STRIP_ARGS\"\n", + ) + self.make_executable(tools / f"{triple}-objdump", "#!/bin/sh\nprintf 'Program Header:\\n'\n") + + def env_for(self, root: Path, *, triple: str) -> tuple[dict[str, str], Path, Path, Path]: + out = root / "out" + stage = root / "stage" + log = root / "mdns.log" + gcc_args = root / "gcc.args" + strip_args = root / "strip.args" + self.prepare_fake_toolchain(out, triple) + env = os.environ.copy() + env.update({ + "TC_ENV_FILE": "/dev/null", + "BUILD_OUT": str(out), + "BUILD_SRC": str(root / "src"), + "MDNS_STAGE": str(stage), + "MDNS_LOG": str(log), + "TEST_GCC_ARGS": str(gcc_args), + "TEST_STRIP_ARGS": str(strip_args), + }) + return env, log, gcc_args, strip_args + + def run_wrapper(self, wrapper: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["/bin/sh", str(REPO_ROOT / "build" / wrapper)], + cwd=REPO_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=30, + ) + + def test_netbsd7_mdns_build_uses_sysroot_static_gc_and_strips_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, log, gcc_args, strip_args = self.env_for(Path(tmp), triple="arm--netbsdelf") + + result = self.run_wrapper("mdns.sh", env) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + args = gcc_args.read_text().splitlines() + self.assertIn(f"--sysroot={Path(tmp) / 'out' / 'obj' / 'destdir.evbarm'}", args) + self.assertIn("-Wl,--gc-sections", args) + self.assertIn("--strip-unneeded", strip_args.read_text()) + self.assertTrue((Path(tmp) / "stage" / "mdns-advertiser.stripped").exists()) + self.assertIn("SDK_FAMILY=netbsd7", log.read_text()) + + def test_netbsd4be_mdns_wrapper_uses_big_endian_lane_without_sysroot(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, log, gcc_args, _strip_args = self.env_for(Path(tmp), triple="armeb--netbsdelf") + + result = self.run_wrapper("mdnsoldbe.sh", env) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + args = gcc_args.read_text().splitlines() + self.assertNotIn(f"--sysroot={Path(tmp) / 'out' / 'obj' / 'destdir.evbarm'}", args) + self.assertIn(f"-B{Path(tmp) / 'out' / 'obj' / 'destdir.evbarm' / 'usr' / 'lib'}", args) + self.assertIn("TRIPLE=armeb--netbsdelf", log.read_text()) + self.assertIn("SDK_FAMILY=netbsd4", log.read_text()) + + def test_mdns_build_fails_before_mutation_when_toolchain_is_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env = os.environ.copy() + env.update({ + "TC_ENV_FILE": "/dev/null", + "BUILD_OUT": str(root / "missing-out"), + "BUILD_SRC": str(root / "src"), + "MDNS_STAGE": str(root / "stage"), + "MDNS_LOG": str(root / "mdns.log"), + }) + + result = self.run_wrapper("mdns.sh", env) + + self.assertEqual(result.returncode, 1) + self.assertIn("Unable to find cross compiler", result.stderr) + self.assertFalse((root / "stage").exists()) + self.assertFalse((root / "mdns.log").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nbns_build.py b/tests/test_nbns_build.py new file mode 100644 index 00000000..f5b4d75a --- /dev/null +++ b/tests/test_nbns_build.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class NbnsBuildWrapperTests(unittest.TestCase): + def make_executable(self, path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(0o755) + + def prepare_fake_toolchain(self, out: Path, triple: str) -> None: + tools = out / "tools" / "bin" + sysroot = out / "obj" / "destdir.evbarm" + sysroot.mkdir(parents=True, exist_ok=True) + self.make_executable(tools / "nbmake", "#!/bin/sh\nexit 0\n") + self.make_executable(tools / "nbfile", "#!/bin/sh\nprintf '%s: fake ELF\\n' \"$1\"\n") + self.make_executable( + tools / f"{triple}-gcc", + textwrap.dedent( + """\ + #!/bin/sh + printf '%s\\n' "$@" > "$TEST_GCC_ARGS" + out= + while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + shift + out="$1" + fi + shift || break + done + mkdir -p "$(dirname "$out")" + printf 'fake nbns\\n' > "$out" + """ + ), + ) + self.make_executable( + tools / f"{triple}-strip", + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$TEST_STRIP_ARGS\"\n", + ) + self.make_executable(tools / f"{triple}-objdump", "#!/bin/sh\nprintf 'Program Header:\\n'\n") + + def env_for(self, root: Path, *, triple: str) -> tuple[dict[str, str], Path, Path, Path]: + out = root / "out" + stage = root / "stage" + log = root / "nbns.log" + gcc_args = root / "gcc.args" + strip_args = root / "strip.args" + self.prepare_fake_toolchain(out, triple) + env = os.environ.copy() + env.update({ + "TC_ENV_FILE": "/dev/null", + "BUILD_OUT": str(out), + "BUILD_SRC": str(root / "src"), + "NBNS_STAGE": str(stage), + "NBNS_LOG": str(log), + "TEST_GCC_ARGS": str(gcc_args), + "TEST_STRIP_ARGS": str(strip_args), + }) + return env, log, gcc_args, strip_args + + def run_wrapper(self, wrapper: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["/bin/sh", str(REPO_ROOT / "build" / wrapper)], + cwd=REPO_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=30, + ) + + def test_netbsd7_nbns_build_uses_sysroot_static_gc_and_strips_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, log, gcc_args, strip_args = self.env_for(Path(tmp), triple="arm--netbsdelf") + + result = self.run_wrapper("nbns.sh", env) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + args = gcc_args.read_text().splitlines() + self.assertIn(f"--sysroot={Path(tmp) / 'out' / 'obj' / 'destdir.evbarm'}", args) + self.assertIn("-Wl,--gc-sections", args) + self.assertIn("--strip-unneeded", strip_args.read_text()) + self.assertTrue((Path(tmp) / "stage" / "nbns-advertiser.stripped").exists()) + self.assertIn("SDK_FAMILY=netbsd7", log.read_text()) + + def test_netbsd4le_nbns_wrapper_uses_little_endian_lane_without_sysroot(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, log, gcc_args, _strip_args = self.env_for(Path(tmp), triple="arm--netbsdelf") + + result = self.run_wrapper("nbnsoldle.sh", env) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + args = gcc_args.read_text().splitlines() + self.assertNotIn(f"--sysroot={Path(tmp) / 'out' / 'obj' / 'destdir.evbarm'}", args) + self.assertIn(f"-B{Path(tmp) / 'out' / 'obj' / 'destdir.evbarm' / 'usr' / 'lib'}", args) + self.assertIn("TRIPLE=arm--netbsdelf", log.read_text()) + self.assertIn("SDK_FAMILY=netbsd4", log.read_text()) + + def test_nbns_build_fails_before_mutation_when_toolchain_is_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env = os.environ.copy() + env.update({ + "TC_ENV_FILE": "/dev/null", + "BUILD_OUT": str(root / "missing-out"), + "BUILD_SRC": str(root / "src"), + "NBNS_STAGE": str(root / "stage"), + "NBNS_LOG": str(root / "nbns.log"), + }) + + result = self.run_wrapper("nbns.sh", env) + + self.assertEqual(result.returncode, 1) + self.assertIn("Unable to find cross compiler", result.stderr) + self.assertFalse((root / "stage").exists()) + self.assertFalse((root / "nbns.log").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_net.py b/tests/test_net.py index 072d8dea..b8bdad0f 100644 --- a/tests/test_net.py +++ b/tests/test_net.py @@ -13,7 +13,8 @@ sys.path.insert(0, str(SRC_ROOT)) from timecapsulesmb.core.net import ( # noqa: E402 - extract_host, + canonical_ssh_target, + endpoint_host, ipv4_literal, ipv6_literal, is_link_local_ip, @@ -27,9 +28,31 @@ class NetTests(unittest.TestCase): - def test_extract_host_removes_user_prefix(self) -> None: - self.assertEqual(extract_host("root@10.0.0.5"), "10.0.0.5") - self.assertEqual(extract_host("10.0.0.5"), "10.0.0.5") + def test_endpoint_host_strips_supported_wrappers_users_paths_and_ports(self) -> None: + cases = { + "root@192.168.1.1:22": "192.168.1.1", + "airport.local:445": "airport.local", + "root@airport.local:22": "airport.local", + "smb://admin@airport.local:445/share": "airport.local", + "root@[fd00::2]:22": "fd00::2", + "[fd00::2]:445": "fd00::2", + "fd00::2": "fd00::2", + " capsule.local. ": "capsule.local", + } + for raw, expected in cases.items(): + with self.subTest(raw=raw): + self.assertEqual(endpoint_host(raw), expected) + + def test_canonical_ssh_target_adds_root_and_strips_default_port(self) -> None: + self.assertEqual(canonical_ssh_target("10.0.0.2:22"), "root@10.0.0.2") + self.assertEqual(canonical_ssh_target("admin@capsule.local:22"), "admin@capsule.local") + self.assertEqual(canonical_ssh_target("root@[fd00::2]:22"), "root@fd00::2") + + def test_canonical_ssh_target_rejects_non_default_or_invalid_ports(self) -> None: + with self.assertRaises(ValueError): + canonical_ssh_target("root@10.0.0.2:2222") + with self.assertRaises(ValueError): + canonical_ssh_target("root@capsule.local:ssh") def test_ipv4_literal_accepts_zero_padded_ipv4_and_rejects_non_ipv4(self) -> None: self.assertEqual(ipv4_literal("010.000.001.007"), "10.0.1.7") diff --git a/tests/test_operation_callbacks.py b/tests/test_operation_callbacks.py new file mode 100644 index 00000000..ab508868 --- /dev/null +++ b/tests/test_operation_callbacks.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +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.services.callbacks import OperationCallbacks + + +class OperationCallbacksTests(unittest.TestCase): + def test_forwards_all_operation_events_to_entrypoint_hooks(self) -> None: + stages: list[str] = [] + logs: list[str] = [] + debug_fields: list[dict[str, object]] = [] + update_fields: list[dict[str, object]] = [] + callbacks = OperationCallbacks( + set_stage=stages.append, + log=logs.append, + add_debug_fields=lambda **fields: debug_fields.append(fields), + update_fields=lambda **fields: update_fields.append(fields), + ) + + callbacks.stage("scan") + callbacks.message("scanning") + callbacks.debug(source="repair-xattrs", attempt=1) + callbacks.update(scanned_paths=4) + + self.assertEqual(stages, ["scan"]) + self.assertEqual(logs, ["scanning"]) + self.assertEqual(debug_fields, [{"source": "repair-xattrs", "attempt": 1}]) + self.assertEqual(update_fields, [{"scanned_paths": 4}]) + + def test_missing_hooks_are_noops(self) -> None: + callbacks = OperationCallbacks() + + callbacks.stage("scan") + callbacks.message("scanning") + callbacks.debug(source="repair-xattrs") + callbacks.update(scanned_paths=4) + + def test_callbacks_can_be_used_by_runtime_flows(self) -> None: + logs: list[str] = [] + + OperationCallbacks(log=logs.append).message("reboot requested") + + self.assertEqual(logs, ["reboot requested"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_probe.py b/tests/test_probe.py index 69288a6e..c0f65db1 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -14,6 +14,7 @@ import timecapsulesmb.device.probe as probe from timecapsulesmb.device.probe import ( + flash_runtime_config_present_conn, preferred_interface_name, read_deployed_version_conn, probe_remote_interface_conn, @@ -67,6 +68,30 @@ def test_read_deployed_version_conn_sources_flash_runtime_config(self) -> None: self.assertFalse(kwargs["check"]) self.assertEqual(kwargs["timeout"], probe.REMOTE_STATE_PROBE_TIMEOUT_SECONDS) + def test_flash_runtime_config_present_conn_returns_true_when_file_exists(self) -> None: + connection = SshConnection("root@10.0.0.2", "pw", "-o StrictHostKeyChecking=no") + proc = subprocess.CompletedProcess(args=["ssh"], returncode=0, stdout="") + + with mock.patch("timecapsulesmb.device.probe.run_ssh", return_value=proc) as run_ssh_mock: + result = flash_runtime_config_present_conn(connection) + + self.assertTrue(result) + args, kwargs = run_ssh_mock.call_args + self.assertEqual(args[0], connection) + self.assertIn(probe.FLASH_RUNTIME_CONFIG, args[1]) + self.assertIn("[ -f", args[1]) + self.assertFalse(kwargs["check"]) + self.assertEqual(kwargs["timeout"], probe.REMOTE_STATE_PROBE_TIMEOUT_SECONDS) + + def test_flash_runtime_config_present_conn_returns_false_when_file_is_missing(self) -> None: + connection = SshConnection("root@10.0.0.2", "pw", "-o StrictHostKeyChecking=no") + proc = subprocess.CompletedProcess(args=["ssh"], returncode=1, stdout="") + + with mock.patch("timecapsulesmb.device.probe.run_ssh", return_value=proc): + result = flash_runtime_config_present_conn(connection) + + self.assertFalse(result) + def test_read_deployed_version_conn_reports_missing_metadata(self) -> None: connection = SshConnection("root@10.0.0.2", "pw", "-o StrictHostKeyChecking=no") proc = subprocess.CompletedProcess(args=["ssh"], returncode=0, stdout="release_tag=\ncli_version_code=\n") diff --git a/tests/test_reachability.py b/tests/test_reachability.py new file mode 100644 index 00000000..2bcdf63a --- /dev/null +++ b/tests/test_reachability.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import subprocess +import unittest +from unittest import mock + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app import service +from timecapsulesmb.core.config import AppConfig, DEFAULTS +from timecapsulesmb.core.net import endpoint_host +from timecapsulesmb.services import reachability + + +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] + + +class ReachabilityTests(unittest.TestCase): + def test_reachability_passes_when_ssh_and_smb_work(self) -> None: + config = AppConfig.from_values({ + "TC_HOST": "root@tc.local", + "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"], + }) + + with mock.patch("timecapsulesmb.services.reachability.resolve_host_ips", return_value=("10.0.0.2",)): + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess(["ssh"], 0, stdout=reachability.REACHABILITY_OK_TOKEN, stderr=""), + ): + result = reachability.run_reachability( + config, + {"smb_hosts": ["tc.local"]}, + password="pw", + ) + + self.assertEqual(result.status, "reachable") + self.assertEqual(result.summary, "SSH reachable; SMB port reachable.") + self.assertEqual({check.id: check.status for check in result.checks}, { + "dns": "PASS", + "ping": "PASS", + "ssh_port": "PASS", + "ssh_auth": "PASS", + "smb_port": "PASS", + }) + + def test_missing_password_skips_auth_but_checks_ports(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + with mock.patch("timecapsulesmb.services.reachability.run_ssh") as ssh: + result = reachability.run_reachability(config, {}, password="") + + ssh.assert_not_called() + self.assertEqual(result.status, "reachable") + self.assertEqual(result.summary, "SSH reachable; SMB port reachable.") + self.assertEqual({check.id: check.status for check in result.checks}["ssh_auth"], "SKIP") + + def test_reachability_strips_ports_from_host_candidates(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2:22", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + tcp_calls: list[tuple[str, int]] = [] + + def tcp(host: str, port: int, *, timeout: float) -> str | None: + tcp_calls.append((host, port)) + return None + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", side_effect=tcp): + result = reachability.run_reachability( + config, + {"smb_hosts": ["capsule.local:445"]}, + password="", + ) + + self.assertEqual(result.status, "reachable") + self.assertIn(("10.0.0.2", 22), tcp_calls) + self.assertIn(("capsule.local", 445), tcp_calls) + + def test_partial_when_ssh_port_works_but_smb_port_is_closed(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + def tcp(host: str, port: int, *, timeout: float) -> str | None: + return None if port == 22 else "connection refused" + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", side_effect=tcp): + result = reachability.run_reachability(config, {}, password="") + + self.assertEqual(result.status, "partial") + self.assertEqual(result.summary, "SSH reachable, SMB port closed.") + + def test_ssh_proxy_skips_direct_port_check_but_auth_can_pass(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": "-J jump"}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value="connection refused") as tcp: + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess(["ssh"], 0, stdout=reachability.REACHABILITY_OK_TOKEN, stderr=""), + ) as ssh: + result = reachability.run_reachability(config, {}, password="pw") + + self.assertEqual(tcp.call_count, 1) + ssh.assert_called_once() + self.assertEqual({check.id: check.status for check in result.checks}["ssh_port"], "SKIP") + self.assertEqual({check.id: check.status for check in result.checks}["ssh_auth"], "PASS") + self.assertEqual(result.status, "partial") + + def test_ping_is_secondary_when_tcp_services_fail(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value="connection refused"): + result = reachability.run_reachability(config, {}, password="") + + self.assertEqual(result.status, "unreachable") + self.assertEqual(result.summary, "Could not reach SSH or SMB.") + + def test_all_failed_checks_return_unreachable_without_raising(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@tc.local", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.resolve_host_ips", return_value=()): + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 1, stderr=b"timeout"), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value="connection timed out"): + result = reachability.run_reachability(config, {}, password="") + + self.assertEqual(result.status, "unreachable") + self.assertEqual({check.id: check.status for check in result.checks}["dns"], "FAIL") + self.assertEqual({check.id: check.status for check in result.checks}["ssh_auth"], "SKIP") + + def test_invalid_timeout_params_fall_back_to_defaults(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ) as ping: + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None) as tcp: + result = reachability.run_reachability( + config, + {"tcp_timeout": "not-a-number", "ssh_timeout": "not-a-number"}, + password="", + ) + + self.assertEqual(result.status, "reachable") + self.assertEqual(ping.call_args.kwargs["timeout"], 3.0) + self.assertEqual(tcp.call_args.kwargs["timeout"], 2.0) + + def test_ipv6_candidates_use_ping6_when_available(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@[fd00::2]", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + def which(command: str) -> str | None: + return f"/sbin/{command}" if command == "ping6" else None + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", side_effect=which): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping6"], 0, stderr=b""), + ) as ping: + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + reachability.run_reachability(config, {}, password="") + + self.assertEqual(ping.call_args.args[0][0], "/sbin/ping6") + self.assertIn("fd00::2", ping.call_args.args[0]) + + def test_endpoint_host_normalizes_urls_ports_ipv6_and_trailing_dots(self) -> None: + cases = { + "smb://Capsule.local.:445/Data": "capsule.local", + "root@Capsule.local.:22": "Capsule.local", + "root@[fd00::2]:22": "fd00::2", + "[fe80::1%bridge0]:445": "fe80::1", + "10.000.000.002:445": "10.0.0.2", + } + + for raw, expected in cases.items(): + with self.subTest(raw=raw): + self.assertEqual(endpoint_host(raw), expected) + + def test_ssh_target_from_params_canonicalizes_url_and_port_inputs(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@fallback.local", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + self.assertEqual( + reachability.ssh_target_from_params(config, {"ssh_host": "ssh://admin@Capsule.local.:22"}), + "admin@capsule.local", + ) + self.assertEqual( + reachability.ssh_target_from_params(config, {"host": "Capsule.local:2222"}), + "root@Capsule.local", + ) + self.assertEqual( + reachability.ssh_target_from_params(config, {"host": "root@[fd00::2]:22"}), + "root@fd00::2", + ) + + def test_smb_hosts_from_params_dedupes_case_and_ignores_empty_items(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@Capsule.local.", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + hosts = reachability.smb_hosts_from_params( + config, + {"smb_hosts": ["capsule.local", "", None], "smb_host": "smb://CAPSULE.local:445/Data"}, + ssh_host="Capsule.local", + ) + + self.assertEqual(hosts, ["capsule.local"]) + + def test_negative_timeout_params_fall_back_to_defaults(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ) as ping: + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None) as tcp: + reachability.run_reachability( + config, + {"tcp_timeout": -1, "ssh_timeout": -1}, + password="", + ) + + self.assertEqual(ping.call_args.kwargs["timeout"], 3.0) + self.assertEqual(tcp.call_args.kwargs["timeout"], 2.0) + + def test_ssh_auth_requires_ok_token_even_when_rc_is_zero(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + port_check = reachability.ReachabilityCheck("ssh_port", "PASS", "ok", host="10.0.0.2") + + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess(["ssh"], 0, stdout="login banner only", stderr=""), + ): + result = reachability.check_ssh_auth( + "root@10.0.0.2", + config, + password="pw", + port_check=port_check, + timeout=8, + ) + + self.assertEqual(result.status, "FAIL") + self.assertEqual(result.detail, "login banner only") + + def test_ssh_auth_accepts_ok_token_after_login_noise(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + port_check = reachability.ReachabilityCheck("ssh_port", "PASS", "ok", host="10.0.0.2") + + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess( + ["ssh"], + 0, + stdout=f"Last login: today\n{reachability.REACHABILITY_OK_TOKEN}\n", + stderr="", + ), + ): + result = reachability.check_ssh_auth( + "root@10.0.0.2", + config, + password="pw", + port_check=port_check, + timeout=8, + ) + + self.assertEqual(result.status, "PASS") + + def test_app_operation_emits_stages_checks_and_payload(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}) + + with mock.patch("timecapsulesmb.app.ops.common.load_optional_env_config", return_value=config): + with mock.patch("timecapsulesmb.services.reachability.shutil.which", return_value="/sbin/ping"): + with mock.patch( + "timecapsulesmb.services.reachability.subprocess.run", + return_value=subprocess.CompletedProcess(["ping"], 0, stderr=b""), + ): + with mock.patch("timecapsulesmb.services.reachability.tcp_connect_error", return_value=None): + with mock.patch( + "timecapsulesmb.services.reachability.run_ssh", + return_value=subprocess.CompletedProcess(["ssh"], 0, stdout=reachability.REACHABILITY_OK_TOKEN, stderr=""), + ): + rc = service.run_api_request( + {"operation": "reachability", "params": {"credentials": {"password": "pw"}}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual( + [event["stage"] for event in collector.events_of_type("stage")], + ["load_config", "build_candidates", "check_dns", "check_ping", "check_ssh_port", "check_ssh_auth", "check_smb_port"], + ) + self.assertEqual(len(collector.events_of_type("check")), 5) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["status"], "reachable") + self.assertEqual(result["payload"]["counts"]["PASS"], 5) + + def test_reachability_does_not_import_zeroconf(self) -> None: + with mock.patch("timecapsulesmb.services.reachability.resolve_host_ips", side_effect=AssertionError("no dns needed")): + result = reachability.run_reachability( + AppConfig.from_values({"TC_HOST": "", "TC_SSH_OPTS": DEFAULTS["TC_SSH_OPTS"]}), + {}, + ) + + self.assertEqual(result.status, "skipped") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repair_xattrs.py b/tests/test_repair_xattrs.py index 6a4d072c..ae0a8239 100644 --- a/tests/test_repair_xattrs.py +++ b/tests/test_repair_xattrs.py @@ -15,9 +15,11 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) -from timecapsulesmb.cli import repair_xattrs +from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli from timecapsulesmb import repair_xattrs as repair_xattrs_domain from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services import repair_xattrs as repair_xattrs_service class RecordingCommandContext: @@ -56,6 +58,22 @@ def set_stage(self, stage: str) -> None: self.stages.append(stage) +class RecordingRepairCallbacks: + def __init__(self) -> None: + self.stages: list[str] = [] + self.fields: dict[str, object] = {} + self.logs: list[str] = [] + + def set_stage(self, stage: str) -> None: + self.stages.append(stage) + + def update_fields(self, **fields: object) -> None: + self.fields.update(fields) + + def log(self, message: str) -> None: + self.logs.append(message) + + UNSET = object() @@ -127,7 +145,7 @@ def setUp(self) -> None: self.ensure_install_id_patch.start() self.addCleanup(self.ensure_install_id_patch.stop) self.path_guard_patch = mock.patch( - "timecapsulesmb.cli.repair_xattrs.validate_repair_root_under_volumes", + "timecapsulesmb.services.repair_xattrs.validate_repair_root_under_volumes", side_effect=lambda path: path.expanduser(), ) self.path_guard_mock = self.path_guard_patch.start() @@ -143,11 +161,11 @@ def find_findings_with_commands( include_hidden: bool = False, include_time_machine: bool = False, include_directories: bool = False, - summary: repair_xattrs.RepairSummary | None = None, + summary: repair_xattrs_domain.RepairSummary | None = None, ): - summary = summary or repair_xattrs.RepairSummary() + summary = summary or repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=commands): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=recursive, max_depth=max_depth, @@ -181,9 +199,95 @@ def run_repair_cli( if recording_context: mocks.command_context = stack.enter_context(mock.patch("timecapsulesmb.cli.repair_xattrs.CommandContext", RecordingCommandContext)) with redirect_stdout(output): - rc = repair_xattrs.main(["--path", str(root), *(argv or [])]) + rc = repair_xattrs_cli.main(["--path", str(root), *(argv or [])]) return SimpleNamespace(rc=rc, output=output, text=output.getvalue(), commands=commands, mocks=mocks) + def test_cli_passes_parsed_options_config_and_confirm_to_service(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "Data" + root.mkdir() + config_path = Path(tmp) / ".env" + config_path.write_text("TC_HOST='root@10.0.0.2'\n") + config = self.app_config({"TC_HOST": "root@10.0.0.2"}) + result = repair_xattrs_service.RepairRunResult( + returncode=0, + root=root, + findings=[], + candidates=[], + summary=repair_xattrs_domain.RepairSummary(), + ) + + with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.cli.repair_xattrs.load_optional_env_config", return_value=config) as load_config_mock: + with mock.patch("timecapsulesmb.cli.repair_xattrs.run_repair_service", return_value=result) as run_mock: + with mock.patch("timecapsulesmb.cli.repair_xattrs.CommandContext", RecordingCommandContext): + with redirect_stdout(io.StringIO()): + rc = repair_xattrs_cli.main( + [ + "--config", + str(config_path), + "--path", + str(root), + "--dry-run", + "--no-recursive", + "--max-depth", + "2", + "--include-hidden", + "--include-time-machine", + "--fix-permissions", + "--verbose", + ] + ) + + self.assertEqual(rc, 0) + load_config_mock.assert_called_once_with(env_path=config_path) + request, passed_config = run_mock.call_args.args[:2] + self.assertEqual( + request, + repair_xattrs_service.RepairXattrsRequest( + path=root, + dry_run=True, + approve_repairs=False, + recursive=False, + max_depth=2, + include_hidden=True, + include_time_machine=True, + fix_permissions=True, + verbose=True, + ), + ) + self.assertIs(passed_config, config) + self.assertIsNotNone(run_mock.call_args.kwargs["confirm"]) + context = RecordingCommandContext.instances[-1] + self.assertEqual(context.stages, ["platform_check"]) + self.assertEqual(context.fields["host_platform"], "darwin") + self.assertEqual(context.result, "success") + + def test_cli_no_input_passes_no_confirm_callback_to_service(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "Data" + root.mkdir() + config = self.app_config({"TC_HOST": "root@10.0.0.2"}) + result = repair_xattrs_service.RepairRunResult( + returncode=0, + root=root, + findings=[], + candidates=[], + summary=repair_xattrs_domain.RepairSummary(), + ) + + with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.cli.repair_xattrs.load_optional_env_config", return_value=config): + with mock.patch("timecapsulesmb.cli.repair_xattrs.run_repair_service", return_value=result) as run_mock: + with mock.patch("timecapsulesmb.cli.repair_xattrs.CommandContext", RecordingCommandContext): + with redirect_stdout(io.StringIO()): + rc = repair_xattrs_cli.main(["--path", str(root), "--no-input"]) + + self.assertEqual(rc, 0) + request = run_mock.call_args.args[0] + self.assertFalse(request.approve_repairs) + self.assertIsNone(run_mock.call_args.kwargs["confirm"]) + def test_finds_arch_file_when_xattr_fails(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -196,7 +300,7 @@ def test_finds_arch_file_when_xattr_fails(self) -> None: summary = result.summary self.assertEqual([finding.path.name for finding in findings], ["broken.txt"]) self.assertEqual(findings[0].kind, "repairable_arch_flag") - self.assertEqual(findings[0].actions, (repair_xattrs.ACTION_CLEAR_ARCH_FLAG,)) + self.assertEqual(findings[0].actions, (repair_xattrs_domain.ACTION_CLEAR_ARCH_FLAG,)) self.assertEqual(summary.scanned, 1) self.assertEqual(summary.repairable, 1) @@ -213,7 +317,7 @@ def test_find_findings_can_scan_repairable_directories(self) -> None: self.assertEqual([finding.path.name for finding in findings], ["broken-dir"]) self.assertEqual(findings[0].kind, "repairable_arch_flag") self.assertEqual(findings[0].path_type, "directory") - self.assertEqual(findings[0].actions, (repair_xattrs.ACTION_CLEAR_ARCH_FLAG,)) + self.assertEqual(findings[0].actions, (repair_xattrs_domain.ACTION_CLEAR_ARCH_FLAG,)) self.assertEqual(summary.scanned_dirs, 1) def test_does_not_repair_when_xattr_is_readable(self) -> None: @@ -222,8 +326,8 @@ def test_does_not_repair_when_xattr_is_readable(self) -> None: (root / "ok.txt").write_text("data") with mock.patch("timecapsulesmb.repair_xattrs.run_capture", return_value=mock.Mock(returncode=0, stdout="", stderr="")): - summary = repair_xattrs.RepairSummary() - findings = repair_xattrs.find_findings( + summary = repair_xattrs_domain.RepairSummary() + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -254,10 +358,10 @@ def __next__(self) -> Path: root = Path(tmp) first = root / "first.txt" first.write_text("data") - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch.object(Path, "iterdir", return_value=ExplodingAfterFirstIterator(first)): - scanner = repair_xattrs.iter_scan_paths( + scanner = repair_xattrs_domain.iter_scan_paths( root, recursive=True, max_depth=None, @@ -282,9 +386,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="-\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -329,9 +433,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch,nodump\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -342,7 +446,7 @@ def fake_run(args: list[str]): self.assertEqual([finding.path.name for finding in findings], ["broken.txt"]) self.assertEqual(findings[0].flags, "arch,nodump") - self.assertEqual(findings[0].actions, (repair_xattrs.ACTION_CLEAR_ARCH_FLAG,)) + self.assertEqual(findings[0].actions, (repair_xattrs_domain.ACTION_CLEAR_ARCH_FLAG,)) def test_does_not_repair_when_stat_fails(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -356,9 +460,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=1, stdout="", stderr="stat failed") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -404,6 +508,52 @@ def test_dry_run_with_detected_issues_records_failure_telemetry(self) -> None: self.assertEqual(RecordingCommandContext.instances[-1].result, "failure") self.assertIn("repair-xattrs detected issues", RecordingCommandContext.instances[-1].error or "") + def test_service_dry_run_uses_typed_request_and_callbacks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "broken.txt").write_text("data") + callbacks = RecordingRepairCallbacks() + request = repair_xattrs_service.RepairXattrsRequest( + path=root, + dry_run=True, + approve_repairs=False, + ) + + with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=FakeXattrCommands()): + result = repair_xattrs_service.run_repair( + request, + self.app_config({}), + callbacks=OperationCallbacks( + set_stage=callbacks.set_stage, + update_fields=callbacks.update_fields, + log=callbacks.log, + ), + ) + + self.assertEqual(result.returncode, 0) + self.assertEqual(result.telemetry_result, "failure") + self.assertIn("resolve_scan_root", callbacks.stages) + self.assertIn("scan_findings", callbacks.stages) + self.assertEqual(callbacks.fields["repairable_count"], 2) + self.assertTrue(any(line.startswith("Would repair:") for line in callbacks.logs)) + + def test_service_repair_requires_approval_or_confirm_callback(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "broken.txt").write_text("data") + request = repair_xattrs_service.RepairXattrsRequest( + path=root, + dry_run=False, + approve_repairs=False, + ) + + with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=FakeXattrCommands()): + result = repair_xattrs_service.run_repair(request, self.app_config({})) + + self.assertEqual(result.returncode, 1) + self.assertEqual(result.telemetry_result, "failure") + self.assertIn("--yes", result.error or "") + def test_apply_repairs_after_prompt(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -463,7 +613,7 @@ def fake_run(args: list[str]): with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): with redirect_stdout(io.StringIO()): - rc = repair_xattrs.main(["--path", str(root), "--fix-permissions", "--yes"]) + rc = repair_xattrs_cli.main(["--path", str(root), "--fix-permissions", "--yes"]) self.assertEqual(rc, 0) self.assertIn(["chmod", "ugo+rw", str(file_path.resolve())], chmod_calls) @@ -479,8 +629,8 @@ def test_fix_permissions_excludes_samba4_even_when_hidden_included(self) -> None visible.write_text("data") with mock.patch("timecapsulesmb.repair_xattrs.run_capture", return_value=mock.Mock(returncode=0, stdout="", stderr="")): - summary = repair_xattrs.RepairSummary() - findings = repair_xattrs.find_findings( + summary = repair_xattrs_domain.RepairSummary() + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -612,9 +762,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -641,9 +791,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -660,9 +810,9 @@ def test_skips_top_level_hidden_file_by_default(self) -> None: target = Path(tmp) / ".broken.txt" target.write_text("data") - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture") as run_mock: - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( target, recursive=True, max_depth=None, @@ -687,9 +837,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( target, recursive=True, max_depth=None, @@ -715,9 +865,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -744,9 +894,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=None, @@ -773,9 +923,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=False, max_depth=None, @@ -804,9 +954,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( root, recursive=True, max_depth=1, @@ -830,9 +980,9 @@ def fake_run(args: list[str]): return mock.Mock(returncode=0, stdout="arch\n", stderr="") raise AssertionError(args) - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("timecapsulesmb.repair_xattrs.run_capture", side_effect=fake_run): - findings = repair_xattrs.find_findings( + findings = repair_xattrs_domain.find_findings( target, recursive=True, max_depth=None, @@ -844,18 +994,18 @@ def fake_run(args: list[str]): self.assertEqual([finding.path for finding in findings], [target.resolve()]) def test_parse_mounted_smb_shares_decodes_mount_output(self) -> None: - shares = repair_xattrs.parse_mounted_smb_shares( + shares = repair_xattrs_domain.parse_mounted_smb_shares( "//James%20Chang@timecapsulesamba4.local/Data on /Volumes/Data (smbfs, nodev)\n" "//James%20Chang@AirPort._afpovertcp._tcp.local/Data on /Volumes/AfpData (afpfs, nodev)\n" ) - self.assertEqual(shares, [repair_xattrs.MountedSmbShare("timecapsulesamba4.local", "Data", Path("/Volumes/Data"))]) + self.assertEqual(shares, [repair_xattrs_domain.MountedSmbShare("timecapsulesamba4.local", "Data", Path("/Volumes/Data"))]) def test_default_share_path_uses_env_host_when_smb_mounted(self) -> None: env = {"TC_HOST": "root@192.168.1.217"} shares = [ - repair_xattrs.MountedSmbShare("10.0.0.2", "Data", Path("/Volumes/WrongData")), - repair_xattrs.MountedSmbShare("192.168.1.217", "Data", Path("/Volumes/Data")), + repair_xattrs_domain.MountedSmbShare("10.0.0.2", "Data", Path("/Volumes/WrongData")), + repair_xattrs_domain.MountedSmbShare("192.168.1.217", "Data", Path("/Volumes/Data")), ] self.assertEqual( repair_xattrs_domain.default_share_path_from_config( @@ -868,7 +1018,7 @@ def test_default_share_path_uses_env_host_when_smb_mounted(self) -> None: def test_default_share_path_uses_unique_matching_smb_share_when_host_label_differs(self) -> None: env = {"TC_HOST": "root@192.168.1.217"} - shares = [repair_xattrs.MountedSmbShare("timecapsulesamba4.local", "Data", Path("/Volumes/Data-1"))] + shares = [repair_xattrs_domain.MountedSmbShare("timecapsulesamba4.local", "Data", Path("/Volumes/Data-1"))] self.assertEqual( repair_xattrs_domain.default_share_path_from_config( self.app_config(env), @@ -887,8 +1037,8 @@ def test_default_share_path_ignores_afp_mount_with_matching_volume_name(self) -> def test_default_share_path_ignores_inaccessible_smb_mountpoints(self) -> None: env = {"TC_HOST": "root@192.168.1.217"} shares = [ - repair_xattrs.MountedSmbShare("Time Capsule Samba 4._smb._tcp.local", "Data", Path("/Volumes/.timemachine/Data")), - repair_xattrs.MountedSmbShare("192.168.1.217", "Data", Path("/Volumes/Data")), + repair_xattrs_domain.MountedSmbShare("Time Capsule Samba 4._smb._tcp.local", "Data", Path("/Volumes/.timemachine/Data")), + repair_xattrs_domain.MountedSmbShare("192.168.1.217", "Data", Path("/Volumes/Data")), ] def fake_path_exists(path: Path) -> bool: @@ -908,8 +1058,8 @@ def fake_path_exists(path: Path) -> bool: def test_default_share_path_rejects_ambiguous_matching_smb_shares(self) -> None: env = {"TC_HOST": "root@192.168.1.217"} shares = [ - repair_xattrs.MountedSmbShare("timecapsule-a.local", "Data", Path("/Volumes/Data")), - repair_xattrs.MountedSmbShare("timecapsule-b.local", "Data", Path("/Volumes/Data-1")), + repair_xattrs_domain.MountedSmbShare("timecapsule-a.local", "Data", Path("/Volumes/Data")), + repair_xattrs_domain.MountedSmbShare("timecapsule-b.local", "Data", Path("/Volumes/Data-1")), ] with self.assertRaises(RuntimeError) as cm: repair_xattrs_domain.default_share_path_from_config( @@ -940,9 +1090,9 @@ def test_explicit_repair_path_does_not_require_valid_env_share_name(self) -> Non target = Path(tmp) / "file.txt" target.write_text("data") with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.cli.repair_xattrs.find_findings", return_value=[]): + with mock.patch("timecapsulesmb.services.repair_xattrs.find_findings", return_value=[]): with redirect_stdout(io.StringIO()): - rc = repair_xattrs.main(["--path", str(target)]) + rc = repair_xattrs_cli.main(["--path", str(target)]) self.assertEqual(rc, 0) self.path_guard_mock.assert_called_with(target) @@ -970,13 +1120,13 @@ def test_validate_repair_root_rejects_volumes_root(self) -> None: def test_explicit_inaccessible_repair_path_reports_clean_error(self) -> None: target = Path("/Volumes/.timemachine/Data") - summary = repair_xattrs.RepairSummary() + summary = repair_xattrs_domain.RepairSummary() with mock.patch("pathlib.Path.resolve", return_value=target): with mock.patch("pathlib.Path.is_file", side_effect=PermissionError("permission denied")): with self.assertRaises(RuntimeError) as cm: list( - repair_xattrs.iter_scan_paths( + repair_xattrs_domain.iter_scan_paths( target, recursive=True, max_depth=None, @@ -994,18 +1144,18 @@ def test_dry_run_and_yes_are_mutually_exclusive(self) -> None: with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): with redirect_stderr(io.StringIO()): with self.assertRaises(SystemExit): - repair_xattrs.main(["--dry-run", "--yes", "--path", "/tmp"]) + repair_xattrs_cli.main(["--dry-run", "--yes", "--path", "/tmp"]) def test_negative_max_depth_is_rejected(self) -> None: with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): with redirect_stderr(io.StringIO()): with self.assertRaises(SystemExit): - repair_xattrs.main(["--max-depth", "-1", "--path", "/tmp"]) + repair_xattrs_cli.main(["--max-depth", "-1", "--path", "/tmp"]) def test_non_macos_is_rejected(self) -> None: with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "linux"): with self.assertRaises(SystemExit) as cm: - repair_xattrs.main(["--path", "/tmp"]) + repair_xattrs_cli.main(["--path", "/tmp"]) self.assertIn("must be run on macOS", str(cm.exception)) def test_missing_default_path_is_rejected(self) -> None: @@ -1014,13 +1164,13 @@ def test_missing_default_path_is_rejected(self) -> None: "timecapsulesmb.cli.repair_xattrs.load_optional_env_config", return_value=self.app_config({"TC_HOST": "root@192.168.1.217"}), ): - with mock.patch("timecapsulesmb.cli.repair_xattrs.mounted_smb_shares", return_value=[]): + with mock.patch("timecapsulesmb.services.repair_xattrs.mounted_smb_shares", return_value=[]): with self.assertRaises(SystemExit) as cm: - repair_xattrs.main([]) + repair_xattrs_cli.main([]) self.assertIn("Pass --path explicitly", str(cm.exception)) def test_subprocess_output_decodes_invalid_xattr_bytes(self) -> None: - proc = repair_xattrs.run_capture([ + proc = repair_xattrs_domain.run_capture([ sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'bad\\\\xffbytes')", diff --git a/tests/test_runtime.py b/tests/test_runtime.py index adbfeced..317b663b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -15,11 +15,10 @@ from timecapsulesmb.cli.runtime import ( json_text, - resolve_env_connection, - ssh_target_link_local_resolution_error, write_json_file, ) -from timecapsulesmb.core.config import AppConfig, DEFAULTS +from timecapsulesmb.core.config import AppConfig, ConfigError, DEFAULTS +from timecapsulesmb.services.runtime import resolve_env_connection, ssh_target_link_local_resolution_error class RuntimeTests(unittest.TestCase): @@ -41,6 +40,23 @@ def test_resolve_env_connection_preserves_configured_ssh_opts(self) -> None: self.assertEqual(connection.ssh_opts, "-o ProxyJump=bastion") + def test_resolve_env_connection_uses_password_provider_when_password_missing(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2"}) + provider = mock.Mock(return_value="prompted-pw") + + connection = resolve_env_connection(config, password_provider=provider) + + provider.assert_called_once_with("Device root password: ") + self.assertEqual(connection.password, "prompted-pw") + + def test_resolve_env_connection_does_not_prompt_without_provider(self) -> None: + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2"}) + + with self.assertRaises(ConfigError) as ctx: + resolve_env_connection(config) + + self.assertIn("TC_PASSWORD is required", str(ctx.exception)) + def test_ssh_target_link_local_resolution_error_rejects_resolved_hostname(self) -> None: addrinfo = [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.44.9", 0))] diff --git a/tests/test_storage_runtime.py b/tests/test_storage_runtime.py index 696b8c22..2ca45cbf 100644 --- a/tests/test_storage_runtime.py +++ b/tests/test_storage_runtime.py @@ -10,7 +10,8 @@ from timecapsulesmb.core.config import AppConfig from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG -from timecapsulesmb.cli.deploy import render_flash_runtime_config +from timecapsulesmb.services.deploy import render_flash_runtime_config +from timecapsulesmb.services.deploy import render_flash_runtime_config as render_gui_flash_runtime_config from timecapsulesmb.deploy.executor import upload_flash_file from timecapsulesmb.deploy.boot_assets import load_boot_asset_text from timecapsulesmb.deploy.planner import ( @@ -878,12 +879,12 @@ def test_select_payload_home_prefers_writable_internal_volume(self) -> None: internal = MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "f42bdb83-c265-5522-a087-25606a4d0abf", True, "hfs") external = MaStVolume("sd0", "dk3", "/Volumes/dk3", "USB", "51f93e6f-dc69-524d-986d-cee4d7cb3573", False, "hfs") - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", return_value=True) as mount_mock: + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", return_value=True) as mount_mock: with mock.patch("timecapsulesmb.device.storage.volume_root_is_writable_conn", side_effect=[True]) as writable_mock: selection = select_payload_home_with_diagnostics_conn(connection, (external, internal), ".samba4", wait_seconds=30) self.assertEqual(selection.payload_home, PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4")) - mount_mock.assert_called_once_with(connection, internal, wait_seconds=30) + mount_mock.assert_called_once_with(connection, "/Volumes/dk2", "/dev/dk2", wait_seconds=30) writable_mock.assert_called_once_with(connection, "/Volumes/dk2") def test_ensure_volume_root_mounted_conn_claims_diskd_without_mount_hfs_fallback(self) -> None: @@ -950,7 +951,7 @@ def test_select_payload_home_skips_unmountable_internal_before_external(self) -> internal = MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "f42bdb83-c265-5522-a087-25606a4d0abf", True, "hfs") external = MaStVolume("sd0", "dk3", "/Volumes/dk3", "USB", "51f93e6f-dc69-524d-986d-cee4d7cb3573", False, "hfs") - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", side_effect=[False, True]) as mount_mock: + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", side_effect=[False, True]) as mount_mock: with mock.patch("timecapsulesmb.device.storage.volume_root_is_writable_conn", return_value=True) as writable_mock: selection = select_payload_home_with_diagnostics_conn(connection, (external, internal), ".samba4", wait_seconds=9) @@ -958,8 +959,8 @@ def test_select_payload_home_skips_unmountable_internal_before_external(self) -> self.assertEqual( mount_mock.call_args_list, [ - mock.call(connection, internal, wait_seconds=9), - mock.call(connection, external, wait_seconds=9), + mock.call(connection, "/Volumes/dk2", "/dev/dk2", wait_seconds=9), + mock.call(connection, "/Volumes/dk3", "/dev/dk3", wait_seconds=9), ], ) writable_mock.assert_called_once_with(connection, "/Volumes/dk3") @@ -969,7 +970,7 @@ def test_select_payload_home_with_diagnostics_records_mount_and_write_results(se internal = MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "f42bdb83-c265-5522-a087-25606a4d0abf", True, "hfs") external = MaStVolume("sd0", "dk3", "/Volumes/dk3", "USB", "51f93e6f-dc69-524d-986d-cee4d7cb3573", False, "hfs") - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", side_effect=[False, True]): + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", side_effect=[False, True]): with mock.patch("timecapsulesmb.device.storage.volume_root_is_writable_conn", return_value=True): selection = select_payload_home_with_diagnostics_conn( connection, @@ -1018,7 +1019,7 @@ def test_select_payload_home_with_diagnostics_returns_no_home_when_all_unwritabl internal = MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "f42bdb83-c265-5522-a087-25606a4d0abf", True, "hfs") external = MaStVolume("sd0", "dk3", "/Volumes/dk3", "USB", "51f93e6f-dc69-524d-986d-cee4d7cb3573", False, "hfs") - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", return_value=True): + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", return_value=True): with mock.patch("timecapsulesmb.device.storage.volume_root_is_writable_conn", return_value=False): selection = select_payload_home_with_diagnostics_conn( connection, @@ -1036,7 +1037,7 @@ def test_select_payload_home_records_unmountable_candidates(self) -> None: internal = MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "f42bdb83-c265-5522-a087-25606a4d0abf", True, "hfs") external = MaStVolume("sd0", "dk3", "/Volumes/dk3", "USB", "51f93e6f-dc69-524d-986d-cee4d7cb3573", False, "hfs") - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", return_value=False): + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", return_value=False): with mock.patch("timecapsulesmb.device.storage.volume_root_is_writable_conn") as writable_mock: selection = select_payload_home_with_diagnostics_conn(connection, (internal, external), ".samba4", wait_seconds=30) @@ -1049,12 +1050,12 @@ def test_select_payload_home_falls_back_to_external_and_records_none_writable(se internal = MaStVolume("wd0", "dk2", "/Volumes/dk2", "Data", "f42bdb83-c265-5522-a087-25606a4d0abf", True, "hfs") external = MaStVolume("sd0", "dk3", "/Volumes/dk3", "USB", "51f93e6f-dc69-524d-986d-cee4d7cb3573", False, "hfs") - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", return_value=True): + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", return_value=True): with mock.patch("timecapsulesmb.device.storage.volume_root_is_writable_conn", side_effect=[False, True]): selection = select_payload_home_with_diagnostics_conn(connection, (internal, external), ".samba4", wait_seconds=30) self.assertEqual(selection.payload_home, PayloadHome("/Volumes/dk3", "/dev/dk3", ".samba4")) - with mock.patch("timecapsulesmb.device.storage.ensure_mast_volume_mounted_conn", return_value=True): + with mock.patch("timecapsulesmb.device.storage.ensure_volume_root_mounted_conn", return_value=True): with mock.patch("timecapsulesmb.device.storage.volume_root_is_writable_conn", return_value=False): selection = select_payload_home_with_diagnostics_conn(connection, (internal, external), ".samba4", wait_seconds=30) self.assertIsNone(selection.payload_home) @@ -1101,6 +1102,72 @@ 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_gui_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=None, + ) + + self.assertIn("SMBD_DEBUG_LOGGING=1\n", rendered) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", rendered) + + def test_flash_runtime_config_deploy_time_debug_override_can_disable_saved_value(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=0\n", rendered) + self.assertIn("MDNS_DEBUG_LOGGING=0\n", rendered) + + def test_flash_runtime_config_accepts_deploy_time_advanced_overrides(self) -> None: + config = AppConfig.from_values( + { + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "false", + "TC_ANY_PROTOCOL": "false", + } + ) + + rendered = render_gui_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + internal_share_use_disk_root=True, + any_protocol=True, + ) + + self.assertIn("INTERNAL_SHARE_USE_DISK_ROOT=1\n", rendered) + self.assertIn("ANY_PROTOCOL=1\n", rendered) + + def test_flash_runtime_config_deploy_time_overrides_can_disable_saved_values(self) -> None: + config = AppConfig.from_values( + { + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true", + "TC_ANY_PROTOCOL": "true", + } + ) + + rendered = render_gui_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + internal_share_use_disk_root=False, + any_protocol=False, + ) + + self.assertIn("INTERNAL_SHARE_USE_DISK_ROOT=0\n", rendered) + self.assertIn("ANY_PROTOCOL=0\n", rendered) + def test_flash_runtime_config_uses_drive_settings_from_config(self) -> None: config = AppConfig.from_values( { @@ -1764,7 +1831,7 @@ def test_manager_log_uses_second_timestamps_and_byte_cap(self) -> None: tc_manager_reset_pass_state() { i=0 payload='abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz' - while [ "$i" -lt 220 ]; do + while [ "$i" -lt 130 ]; do tc_log "heavy manager log line $i $payload $payload $payload $payload $payload $payload $payload $payload" i=$((i + 1)) done @@ -4210,6 +4277,53 @@ def test_common_stage_runtime_installs_executables_with_temp_rename(self) -> Non ) self.assertNotIn(f"cp:{payload}/smbd:{memory}/samba4/sbin/smbd\n", proc.stdout) + def test_common_generate_smb_conf_propagates_identity_failure_in_conditional_context(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + flash, memory, _locks, volumes = self.write_runtime_harness(tmp_path) + payload = volumes / "Data" / ".samba4" + payload.mkdir(parents=True) + script = tmp_path / "smb-conf-identity-failure.sh" + share_rows = f"Data\t{volumes}/Data\tdk2\t1\t12345678-1234-1234-1234-123456789012" + script.write_text( + textwrap.dedent( + f"""\ + #!/bin/sh + set -eu + . {flash}/common.sh + . {flash}/tcapsulesmb.conf + tc_init_runtime_env + tc_prepare_ram_root + tc_set_log "$RAM_VAR/test.log" test + TC_SMB_BIND_INTERFACES="192.168.1.2/24" + SMB_NETBIOS_NAME=TimeCapsule + SMB_SERVER_STRING=TimeCapsule + tc_ensure_runtime_identity() {{ + echo identity-failed + return 1 + }} + if ! tc_generate_smb_conf_from_share_rows {payload} {shlex.quote(share_rows)}; then + echo status=failed + else + echo status=unexpected-success + fi + if [ -f "$TC_SMBD_CONF" ]; then + echo conf=present + else + echo conf=absent + fi + """ + ) + ) + script.chmod(0o755) + + proc = subprocess.run([str(script)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("identity-failed\n", proc.stdout) + self.assertIn("status=failed\n", proc.stdout) + self.assertIn("conf=absent\n", proc.stdout) + def test_common_smb_bind_probe_rejects_invalid_cidr_output(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) diff --git a/tests/test_storage_service.py b/tests/test_storage_service.py new file mode 100644 index 00000000..8624dd14 --- /dev/null +++ b/tests/test_storage_service.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import sys +import unittest +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.device.storage import MaStDiscoveryResult, MaStVolume +from timecapsulesmb.services.callbacks import OperationCallbacks +from timecapsulesmb.services.storage import ( + mount_mast_volumes_with_diagnostics, + read_mast_volumes_with_diagnostics, + wait_for_mast_volumes_with_diagnostics, +) +from timecapsulesmb.transport.ssh import SshConnection + + +class RecordingMaStCallbacks: + def __init__(self) -> None: + self.stages: list[str] = [] + self.debug_fields: dict[str, object] = {} + + def set_stage(self, stage: str) -> None: + self.stages.append(stage) + + def add_debug_fields(self, **fields: object) -> None: + self.debug_fields.update(fields) + + def callbacks(self) -> OperationCallbacks: + return OperationCallbacks( + set_stage=self.set_stage, + add_debug_fields=self.add_debug_fields, + ) + + +class MaStStorageServiceTests(unittest.TestCase): + def make_connection(self) -> SshConnection: + return SshConnection("root@10.0.0.2", "pw", "-o foo") + + def make_volume(self, partition_device: str = "dk2") -> MaStVolume: + return MaStVolume( + "wd0", + partition_device, + f"/Volumes/{partition_device}", + "Data", + "12345678-1234-1234-1234-123456789012", + True, + "hfs", + ) + + def test_read_mast_volumes_records_stage_and_candidates(self) -> None: + recorder = RecordingMaStCallbacks() + connection = self.make_connection() + volume = self.make_volume() + + result = read_mast_volumes_with_diagnostics( + connection, + callbacks=recorder.callbacks(), + read_mast_volumes=mock.Mock(return_value=(volume,)), + ) + + self.assertEqual(result, (volume,)) + self.assertEqual(recorder.stages, ["read_mast"]) + self.assertEqual(recorder.debug_fields["mast_volume_count"], 1) + self.assertEqual(recorder.debug_fields["mast_candidates"][0]["part"], "dk2") + + def test_mount_mast_volumes_reads_mounts_and_records_mounted_candidates(self) -> None: + recorder = RecordingMaStCallbacks() + connection = self.make_connection() + read_volume = self.make_volume("dk2") + mounted_volume = self.make_volume("dk3") + read_mock = mock.Mock(return_value=(read_volume,)) + mount_mock = mock.Mock(return_value=(mounted_volume,)) + + result = mount_mast_volumes_with_diagnostics( + connection, + callbacks=recorder.callbacks(), + wait_seconds=12, + mount_stage="mount_hfs_volumes", + read_mast_volumes=read_mock, + mounted_mast_volumes=mount_mock, + ) + + self.assertEqual(result, (mounted_volume,)) + read_mock.assert_called_once_with(connection) + mount_mock.assert_called_once_with(connection, (read_volume,), wait_seconds=12) + self.assertEqual(recorder.stages, ["read_mast", "mount_hfs_volumes"]) + self.assertEqual(recorder.debug_fields["mast_volume_count"], 1) + self.assertEqual(recorder.debug_fields["mast_mounted_volume_count"], 1) + self.assertEqual(recorder.debug_fields["mast_mounted_candidates"][0]["part"], "dk3") + + def test_wait_for_mast_volumes_records_raw_output_only_when_empty(self) -> None: + recorder = RecordingMaStCallbacks() + connection = self.make_connection() + discovery = MaStDiscoveryResult((), 10, "MaSt=[]") + wait_mock = mock.Mock(return_value=discovery) + + result = wait_for_mast_volumes_with_diagnostics( + connection, + callbacks=recorder.callbacks(), + attempts=10, + delay_seconds=3, + wait_for_mast_volumes=wait_mock, + ) + + self.assertEqual(result, discovery) + wait_mock.assert_called_once_with(connection, attempts=10, delay_seconds=3) + self.assertEqual(recorder.stages, ["read_mast"]) + self.assertEqual(recorder.debug_fields["mast_read_attempts"], 10) + self.assertEqual(recorder.debug_fields["mast_volume_count"], 0) + self.assertEqual(recorder.debug_fields["mast_acp_output_chars"], len("MaSt=[]")) + self.assertEqual(recorder.debug_fields["mast_acp_output"], "MaSt=[]") + + def test_bad_mounted_candidate_shape_does_not_block_operation(self) -> None: + recorder = RecordingMaStCallbacks() + connection = self.make_connection() + volume = self.make_volume() + mounted = (SimpleNamespace(volume_root="/Volumes/dk2"),) + + result = mount_mast_volumes_with_diagnostics( + connection, + callbacks=recorder.callbacks(), + wait_seconds=12, + read_mast_volumes=mock.Mock(return_value=(volume,)), + mounted_mast_volumes=mock.Mock(return_value=mounted), + ) + + self.assertEqual(result, mounted) + self.assertEqual(recorder.debug_fields["mast_mounted_volume_count"], 1) + self.assertIsNone(recorder.debug_fields["mast_mounted_candidates"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 41ca7e02..382e61d5 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -21,14 +21,14 @@ COMMAND_FIELD_BLACKLIST, COMMAND_VALUE_BLACKLIST, CommandContext, - render_command_debug_lines, ) -from timecapsulesmb.cli.runtime import ManagedTargetState +from timecapsulesmb.services.context import render_operation_debug_lines from timecapsulesmb.core.config import AppConfig, ConfigError from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.errors import DeviceError from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState, RemoteInterfaceProbeResult from timecapsulesmb.discovery.bonjour import BonjourResolvedService +from timecapsulesmb.services.runtime import ManagedTargetState from timecapsulesmb.telemetry import MAX_SEND_ATTEMPTS, TelemetryClient from timecapsulesmb.telemetry.debug import render_debug_mapping from timecapsulesmb.transport.ssh import SshConnection, SshError @@ -42,7 +42,7 @@ def telemetry_client_from_values( class TelemetryTests(unittest.TestCase): - def test_emit_builds_schema_v3_payload_without_stale_config_identity(self) -> None: + def test_emit_builds_schema_v4_payload_without_stale_config_identity(self) -> None: with tempfile.TemporaryDirectory() as tmp: bootstrap_path = Path(tmp) / ".bootstrap" bootstrap_path.write_text("INSTALL_ID=test-install\n") @@ -59,8 +59,10 @@ def test_emit_builds_schema_v3_payload_without_stale_config_identity(self) -> No with mock.patch.object(client, "_dispatch_payload_async") as dispatch_mock: client.emit("deploy_started") payload = dispatch_mock.call_args.args[0] - self.assertEqual(payload["schema_version"], 3) + self.assertEqual(payload["schema_version"], 4) self.assertEqual(payload["event"], "deploy_started") + self.assertEqual(payload["operation"], "deploy") + self.assertEqual(payload["phase"], "started") self.assertEqual(payload["install_id"], "test-install") self.assertEqual(payload["configure_id"], "config-id") self.assertNotIn("device_model", payload) @@ -153,9 +155,53 @@ def test_command_context_reuses_command_id_for_started_and_finished_events(self) finished_payload = send_mock.call_args.args[0] self.assertIn("command_id", started_payload) self.assertEqual(started_payload["command_id"], finished_payload["command_id"]) + self.assertEqual(started_payload["operation_id"], finished_payload["operation_id"]) + self.assertEqual(started_payload["operation"], "deploy") + self.assertEqual(started_payload["phase"], "started") + self.assertEqual(started_payload["entrypoint"], "cli") + self.assertEqual(started_payload["client"], "terminal") self.assertEqual(finished_payload["event"], "deploy_finished") + self.assertEqual(finished_payload["phase"], "finished") self.assertEqual(finished_payload["result"], "success") + def test_command_context_records_normalized_options_and_details(self) -> None: + telemetry = mock.Mock() + args = SimpleNamespace(dry_run=False, no_reboot=False, no_wait=True, volume="Data", password="secret") + + command = CommandContext(telemetry, "fsck", "fsck_started", "fsck_finished", args=args) + command.update_fields( + fsck_device="/dev/dk2", + fsck_mountpoint="/Volumes/Data", + reboot_was_attempted=True, + device_came_back_after_reboot=False, + ) + command.finish(result="success") + + started_kwargs = telemetry.emit.call_args_list[0].kwargs + finished_kwargs = telemetry.emit.call_args_list[1].kwargs + self.assertEqual(started_kwargs["options"], { + "dry_run": False, + "no_reboot": False, + "no_wait": True, + }) + self.assertNotIn("password", started_kwargs["options"]) + self.assertEqual(finished_kwargs["details"]["volume"], "Data") + self.assertEqual(finished_kwargs["details"]["fsck_device"], "/dev/dk2") + self.assertEqual(finished_kwargs["details"]["fsck_mountpoint"], "/Volumes/Data") + self.assertTrue(finished_kwargs["details"]["reboot_requested"]) + self.assertFalse(finished_kwargs["details"]["verified"]) + + def test_operation_telemetry_renames_reserved_legacy_fields(self) -> None: + telemetry = mock.Mock() + + command = CommandContext(telemetry, "flash", "flash_started", "flash_finished") + command.update_fields(operation="read") + command.finish(result="success") + + finished_kwargs = telemetry.emit.call_args_list[1].kwargs + self.assertEqual(finished_kwargs["operation"], "flash") + self.assertEqual(finished_kwargs["legacy_operation"], "read") + def test_command_context_ignores_started_telemetry_exception(self) -> None: telemetry = mock.Mock() telemetry.emit.side_effect = RuntimeError("telemetry unavailable") @@ -227,11 +273,11 @@ def test_command_context_inspect_managed_connection_records_probe_state(self) -> config=config, ) with mock.patch( - "timecapsulesmb.cli.context.runtime.resolve_env_connection", + "timecapsulesmb.cli.context.service_runtime.resolve_env_connection", return_value=connection, ) as resolve_mock: with mock.patch( - "timecapsulesmb.cli.context.runtime.inspect_managed_connection", + "timecapsulesmb.cli.context.service_runtime.inspect_managed_connection", return_value=target, ) as inspect_mock: result = context.inspect_managed_connection(iface="bridge0", include_probe=True) @@ -491,7 +537,7 @@ def test_command_context_still_finishes_when_debug_context_rendering_fails(self) client = telemetry_client_from_values({}, bootstrap_path=bootstrap_path) with mock.patch.object(client, "_dispatch_payload_async"): with mock.patch.object(client, "_send_payload") as send_mock: - with mock.patch("timecapsulesmb.cli.context.render_command_debug_lines", side_effect=RuntimeError("debug boom")): + with mock.patch("timecapsulesmb.services.context.render_operation_debug_lines", side_effect=RuntimeError("debug boom")): with self.assertRaises(RuntimeError) as raised: with CommandContext(client, "deploy", "deploy_started", "deploy_finished"): raise RuntimeError("upload failed") @@ -643,7 +689,7 @@ def test_command_context_render_debug_mapping_applies_password_and_duplicate_bla self.assertEqual(lines, ["selected_net_iface=bridge0"]) - def test_render_command_debug_lines_combines_context_sources(self) -> None: + def test_render_operation_debug_lines_combines_context_sources(self) -> None: state = ProbedDeviceState( probe_result=ProbeResult( ssh_port_reachable=True, @@ -656,8 +702,8 @@ def test_render_command_debug_lines_combines_context_sources(self) -> None: ), compatibility=None, ) - lines = render_command_debug_lines( - command_name="configure", + lines = render_operation_debug_lines( + operation_name="configure", stage="ssh_probe", connection=SshConnection("root@192.168.1.217", "secret", "-o ProxyJump=bastion"), values={ @@ -697,9 +743,9 @@ def test_render_command_debug_lines_combines_context_sources(self) -> None: self.assertNotIn("reboot_was_attempted=true", lines) self.assertNotIn("device_model=TimeCapsule8,119", lines) - def test_render_command_debug_lines_uses_values_when_connection_is_missing(self) -> None: - lines = render_command_debug_lines( - command_name="doctor", + def test_render_operation_debug_lines_uses_values_when_connection_is_missing(self) -> None: + lines = render_operation_debug_lines( + operation_name="doctor", stage=None, connection=None, values={"TC_HOST": "root@10.0.0.1", "TC_SSH_OPTS": "-o ConnectTimeout=5"}, @@ -712,11 +758,11 @@ def test_render_command_debug_lines_uses_values_when_connection_is_missing(self) self.assertIn("host=root@10.0.0.1", lines) self.assertIn("ssh_opts=-o ConnectTimeout=5", lines) - def test_render_command_debug_lines_includes_env_path_when_config_is_available(self) -> None: + def test_render_operation_debug_lines_includes_env_path_when_config_is_available(self) -> None: with tempfile.TemporaryDirectory() as tmp: env_path = Path(tmp) / ".env" - lines = render_command_debug_lines( - command_name="deploy", + lines = render_operation_debug_lines( + operation_name="deploy", stage="validate_config", connection=None, values={}, diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 0a5fcb7b..d210d3b7 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -13,7 +13,7 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) -from timecapsulesmb.cli.version_check import ( +from timecapsulesmb.services.version_check import ( DEFAULT_DOWNLOAD_URL, DEFAULT_UNSUPPORTED_MESSAGE, VERSION_CHECK_CACHE_SECONDS, @@ -81,6 +81,10 @@ def test_supported_client_fetches_and_caches_successful_response(self) -> None: ) self.assertFalse(result.should_block) + self.assertEqual(result.source, "network") + self.assertEqual(result.current_version, 20004) + self.assertEqual(result.min_supported_version, 20004) + self.assertEqual(result.latest_tag, "v2.0.4") self.assertEqual(len(calls), 1) request, timeout = calls[0] self.assertEqual(request.full_url, VERSION_CHECK_URL) @@ -114,6 +118,9 @@ def test_outdated_client_blocks_with_remote_message_and_download_url(self) -> No self.assertTrue(result.should_block) self.assertEqual(result.message, message) self.assertEqual(result.download_url, download_url) + self.assertEqual(result.source, "network") + self.assertEqual(result.current_version, 20005) + self.assertEqual(result.min_supported_version, 20005) self.assertEqual(len(calls), 1) def test_invalid_or_unreachable_version_metadata_fails_open(self) -> None: @@ -198,6 +205,8 @@ def opener(_request, timeout): ) self.assertFalse(result.should_block) + self.assertEqual(result.source, "cache") + self.assertEqual(result.current_version, 20004) self.assertEqual(calls, []) def test_stale_cache_fetches_remote_metadata(self) -> None: @@ -261,7 +270,7 @@ def test_cache_write_failure_is_ignored(self) -> None: def test_unexpected_internal_exception_fails_open(self) -> None: with tempfile.TemporaryDirectory() as tmp: cache_path = Path(tmp) / "version-cache.json" - with mock.patch("timecapsulesmb.cli.version_check.load_fresh_cached_payload", side_effect=RuntimeError("boom")): + with mock.patch("timecapsulesmb.services.version_check.load_fresh_cached_payload", side_effect=RuntimeError("boom")): result = check_client_version( local_version_code=20004, cache_path=cache_path, diff --git a/version.json b/version.json index 9787cac6..9b173998 100644 --- a/version.json +++ b/version.json @@ -1,8 +1,8 @@ { "schema": 1, - "current_version": 20128, + "current_version": 20203, "min_supported_version": 20121, - "latest_tag": "v2.1.7", + "latest_tag": "v2.2.0-beta3", "download_url": "https://github.com/jamesyc/TimeCapsuleSMB/releases/latest", "message": "This version is no longer supported. Please update before continuing. Version v2.0.7 and earlier use Samba 4.8 and have known security issues." }