From eec1bcb6ed0f0fcadf11d9b54bf48aaecac13eb8 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 20:52:36 -0700 Subject: [PATCH 01/20] Implement native macOS GUI foundation with structured API helper --- .gitignore | 7 + macos/TimeCapsuleSMB/Package.swift | 15 + .../TimeCapsuleSMBApp/BackendClient.swift | 139 +++ .../TimeCapsuleSMBApp/ContentView.swift | 211 ++++ .../Sources/TimeCapsuleSMBApp/Models.swift | 109 +++ .../TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift | 11 + src/timecapsulesmb/app/__init__.py | 2 + src/timecapsulesmb/app/events.py | 78 ++ src/timecapsulesmb/app/helper.py | 47 + src/timecapsulesmb/app/service.py | 898 ++++++++++++++++++ src/timecapsulesmb/cli/main.py | 4 +- tests/test_app_api.py | 215 +++++ 12 files changed, 1735 insertions(+), 1 deletion(-) create mode 100644 macos/TimeCapsuleSMB/Package.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift create mode 100644 src/timecapsulesmb/app/__init__.py create mode 100644 src/timecapsulesmb/app/events.py create mode 100644 src/timecapsulesmb/app/helper.py create mode 100644 src/timecapsulesmb/app/service.py create mode 100644 tests/test_app_api.py diff --git a/.gitignore b/.gitignore index a59cc144..23fb2f0d 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/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift new file mode 100644 index 00000000..bd471c91 --- /dev/null +++ b/macos/TimeCapsuleSMB/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "TimeCapsuleSMBMac", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBApp"]) + ], + targets: [ + .executableTarget(name: "TimeCapsuleSMBApp") + ] +) + diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift new file mode 100644 index 00000000..9ec5bcf0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -0,0 +1,139 @@ +import Foundation + +@MainActor +final class BackendClient: ObservableObject { + @Published var helperPath: String + @Published var events: [BackendEvent] = [] + @Published var isRunning = false + @Published var lastExitCode: Int32? + + init() { + helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? ".venv/bin/tcapsule" + } + + func clear() { + events.removeAll() + lastExitCode = nil + } + + func run(operation: String, params: [String: JSONValue] = [:]) { + guard !isRunning else { return } + isRunning = true + lastExitCode = nil + let helperPath = self.helperPath + Task.detached { + let exitCode = await Self.runHelper( + helperPath: helperPath, + operation: operation, + params: params + ) { event in + Task { @MainActor in + self.events.append(event) + } + } + await MainActor.run { + self.lastExitCode = exitCode + self.isRunning = false + } + } + } + + private static func runHelper( + helperPath: String, + operation: String, + params: [String: JSONValue], + onEvent: @escaping (BackendEvent) -> Void + ) async -> Int32 { + let process = Process() + process.executableURL = helperURL(for: helperPath) + process.arguments = ["api"] + process.environment = helperEnvironment() + + let input = Pipe() + let output = Pipe() + let error = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = error + + let decoder = JSONDecoder() + let parser = OutputLineParser(onEvent: onEvent) + output.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + parser.append(data) + } + + do { + try process.run() + let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(params)] + let requestData = try JSONEncoder().encode(JSONValue.object(request)) + input.fileHandleForWriting.write(requestData) + input.fileHandleForWriting.closeFile() + process.waitUntilExit() + output.fileHandleForReading.readabilityHandler = nil + _ = error.fileHandleForReading.readDataToEndOfFile() + return process.terminationStatus + } catch { + let fallback = """ + {"type":"error","operation":"\(operation)","message":"\(error.localizedDescription)"} + """ + if let data = fallback.data(using: .utf8), let event = try? decoder.decode(BackendEvent.self, from: data) { + onEvent(event) + } + return 1 + } + } + + private static func helperURL(for path: String) -> URL { + if path.hasPrefix("/") { + return URL(fileURLWithPath: path) + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(path) + } + + private static func helperEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + guard + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.appendingPathComponent("TimeCapsuleSMB", isDirectory: true) + else { + return environment + } + try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + if environment["TCAPSULE_CONFIG"] == nil { + environment["TCAPSULE_CONFIG"] = appSupport.appendingPathComponent(".env").path + } + if environment["TCAPSULE_STATE_DIR"] == nil { + environment["TCAPSULE_STATE_DIR"] = appSupport.path + } + return environment + } +} + +private final class OutputLineParser: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + private let decoder = JSONDecoder() + private let onEvent: (BackendEvent) -> Void + + init(onEvent: @escaping (BackendEvent) -> Void) { + self.onEvent = onEvent + } + + func append(_ data: Data) { + lock.lock() + defer { lock.unlock() } + buffer.append(data) + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer.prefix(upTo: newline) + buffer.removeSubrange(...newline) + guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: line) else { + continue + } + onEvent(event) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift new file mode 100644 index 00000000..ac342031 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -0,0 +1,211 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var backend = BackendClient() + @State private var selection: Screen = .readiness + @State private var host = "root@192.168.x.x" + @State private var password = "" + @State private var repairPath = "" + @State private var volume = "" + @State private var nbnsEnabled = true + @State private var noReboot = false + @State private var dryRun = true + + var body: some View { + NavigationSplitView { + List(Screen.allCases, selection: $selection) { screen in + Label(screen.title, systemImage: screen.icon) + .tag(screen) + } + .navigationTitle("TimeCapsuleSMB") + } detail: { + VStack(spacing: 0) { + form + Divider() + EventList(events: backend.events) + } + .toolbar { + ToolbarItemGroup { + Button { + backend.clear() + } label: { + Label("Clear", systemImage: "trash") + } + .disabled(backend.isRunning) + } + } + } + .frame(minWidth: 980, minHeight: 680) + } + + @ViewBuilder + private var form: some View { + switch selection { + case .readiness: + CommandPanel(title: "Readiness") { + TextField("Helper", text: $backend.helperPath) + HStack { + runButton("Paths", icon: "folder", operation: "paths") + runButton("Validate", icon: "checkmark.seal", operation: "validate-install") + } + } + case .connect: + CommandPanel(title: "Discover And Connect") { + TextField("Host", text: $host) + SecureField("Password", text: $password) + HStack { + runButton("Discover", icon: "network", operation: "discover") + Button { + backend.run(operation: "configure", params: [ + "host": .string(host), + "password": .string(password) + ]) + } label: { + Label("Configure", systemImage: "lock.open") + } + .disabled(backend.isRunning || password.isEmpty) + } + } + case .deploy: + CommandPanel(title: "Deploy") { + Toggle("Enable NBNS", isOn: $nbnsEnabled) + Toggle("No Reboot", isOn: $noReboot) + Toggle("Dry Run", isOn: $dryRun) + Button { + backend.run(operation: "deploy", params: [ + "dry_run": .bool(dryRun), + "yes": .bool(true), + "no_reboot": .bool(noReboot), + "nbns_enabled": .bool(nbnsEnabled) + ]) + } label: { + Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") + } + .disabled(backend.isRunning) + } + case .doctor: + CommandPanel(title: "Doctor") { + runButton("Run Doctor", icon: "stethoscope", operation: "doctor") + } + case .maintenance: + CommandPanel(title: "Maintenance") { + TextField("Repair xattrs path", text: $repairPath) + TextField("fsck volume, optional", text: $volume) + HStack { + runButton("Activate", icon: "power", operation: "activate", params: ["yes": .bool(true)]) + runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: ["dry_run": .bool(true)]) + } + HStack { + Button { + backend.run(operation: "fsck", params: [ + "yes": .bool(true), + "volume": .string(volume) + ]) + } label: { + Label("Run fsck", systemImage: "externaldrive.badge.checkmark") + } + .disabled(backend.isRunning) + Button { + backend.run(operation: "repair-xattrs", params: [ + "path": .string(repairPath), + "dry_run": .bool(true) + ]) + } label: { + Label("Scan xattrs", systemImage: "wand.and.stars") + } + .disabled(backend.isRunning || repairPath.isEmpty) + } + } + case .advanced: + CommandPanel(title: "Advanced") { + Text("Flash backup, patch, and restore remain CLI-only in this version.") + .foregroundStyle(.secondary) + Text("Use `.venv/bin/tcapsule flash --help` for firmware operations.") + .font(.system(.body, design: .monospaced)) + } + } + } + + private func runButton( + _ title: String, + icon: String, + operation: String, + params: [String: JSONValue] = [:] + ) -> some View { + Button { + backend.run(operation: operation, params: params) + } label: { + Label(title, systemImage: icon) + } + .disabled(backend.isRunning) + } +} + +private enum Screen: String, CaseIterable, Identifiable { + case readiness + case connect + case deploy + case doctor + case maintenance + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .readiness: return "Readiness" + case .connect: return "Connect" + case .deploy: return "Deploy" + case .doctor: return "Doctor" + case .maintenance: return "Maintenance" + case .advanced: return "Advanced" + } + } + + var icon: String { + switch self { + case .readiness: return "checklist" + case .connect: return "network" + case .deploy: return "square.and.arrow.up" + case .doctor: return "stethoscope" + case .maintenance: return "wrench.and.screwdriver" + case .advanced: return "exclamationmark.triangle" + } + } +} + +private struct CommandPanel: View { + let title: String + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title2.weight(.semibold)) + content + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct EventList: View { + let events: [BackendEvent] + + var body: some View { + List(events) { event in + VStack(alignment: .leading, spacing: 4) { + Text(event.summary) + .font(.body) + if let payload = event.payload, event.type == "result" { + Text(payload.displayText) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(6) + } + } + .padding(.vertical, 3) + } + } +} + diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift new file mode 100644 index 00000000..b9a70e53 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -0,0 +1,109 @@ +import Foundation + +enum JSONValue: Codable, Hashable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + 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)) + } + } + + 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() + } + } + + 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" + } + } +} + +struct BackendEvent: Decodable, Identifiable { + let id = UUID() + let type: String + let operation: String + let stage: String? + let level: String? + let message: String? + let status: String? + let ok: Bool? + let payload: JSONValue? + let details: JSONValue? + let debug: JSONValue? + + enum CodingKeys: String, CodingKey { + case type + case operation + case stage + case level + case message + case status + case ok + case payload + case details + case debug + } + + var summary: String { + switch type { + case "stage": + return stage.map { "\(operation): \($0)" } ?? operation + case "check": + return "\(status ?? "INFO") \(message ?? "")" + case "result": + return "\(operation): \(ok == true ? "finished" : "failed")" + case "error": + return "\(operation): \(message ?? "error")" + default: + return message ?? stage ?? operation + } + } +} + diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift new file mode 100644 index 00000000..390cb570 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct TimeCapsuleSMBApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + 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/events.py b/src/timecapsulesmb/app/events.py new file mode 100644 index 00000000..258d9db8 --- /dev/null +++ b/src/timecapsulesmb/app/events.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + + +SENSITIVE_KEY_PARTS = ("password", "secret", "token") +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) + + def to_jsonable(self) -> dict[str, object]: + data = {"type": self.type, "operation": self.operation} + 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]) -> None: + self._emit = emit + + def emit(self, event: AppEvent) -> None: + self._emit(event) + + def stage(self, operation: str, stage: str) -> None: + self.emit(AppEvent("stage", operation, {"stage": stage})) + + 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, *, debug: object | None = None) -> None: + fields: dict[str, object] = {"message": message} + if debug is not None: + fields["debug"] = debug + 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..69209238 --- /dev/null +++ b/src/timecapsulesmb/app/helper.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import json +import sys +from typing import Optional, TextIO + +from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app.service import run_api_request + + +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) + + raw = sys.stdin.read() + try: + request = json.loads(raw) + except json.JSONDecodeError as exc: + message = f"invalid JSON request: {exc.msg}" + sink.error("api", message, debug={"pos": exc.pos}) + if args.pretty_error: + print(message, file=sys.stderr) + return 1 + if not isinstance(request, dict): + sink.error("api", "request must be a JSON object") + return 1 + return run_api_request(request, sink) + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py new file mode 100644 index 00000000..e01d82b0 --- /dev/null +++ b/src/timecapsulesmb/app/service.py @@ -0,0 +1,898 @@ +from __future__ import annotations + +import argparse +import io +import shlex +import sys +import tempfile +import uuid +from contextlib import ExitStack, redirect_stdout +from dataclasses import asdict, dataclass, is_dataclass +from pathlib import Path +from typing import Callable + +from timecapsulesmb.app.events import EventSink, redact +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli +from timecapsulesmb.cli.deploy import render_flash_runtime_config +from timecapsulesmb.cli.doctor import build_doctor_error +from timecapsulesmb.cli.fsck import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + build_remote_fsck_script, + select_fsck_target, + _target_from_volume, +) +from timecapsulesmb.cli.runtime import ( + load_env_config, + load_optional_env_config, + resolve_env_connection, + resolve_validated_managed_target, + ssh_target_link_local_resolution_error, +) +from timecapsulesmb.core.config import ( + DEFAULTS, + MANAGED_PAYLOAD_DIR_NAME, + AppConfig, + airport_family_display_name_from_identity, + parse_bool, + parse_env_file, + write_env_file, +) +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts +from timecapsulesmb.deploy.artifacts import validate_artifacts +from timecapsulesmb.deploy.auth import render_smbpasswd +from timecapsulesmb.deploy.boot_assets import boot_asset_path +from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable +from timecapsulesmb.deploy.executor import ( + flush_remote_filesystem_writes, + remote_request_reboot, + remote_request_shutdown_reboot, + remote_uninstall_payload, + run_remote_actions, + upload_deployment_payload, +) +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_START_SAMBA_SOURCE, + PACKAGED_WATCHDOG_SOURCE, + build_deployment_plan, + build_netbsd4_activation_plan, + build_uninstall_plan, +) +from timecapsulesmb.deploy.verify import ( + managed_runtime_ready, + render_managed_runtime_verification, + render_post_uninstall_verification, + verify_managed_runtime, + verify_post_uninstall, +) +from timecapsulesmb.device.compat import ( + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, + require_compatibility, +) +from timecapsulesmb.device.probe import ( + probe_connection_state, + probe_managed_runtime_conn, + wait_for_ssh_state_conn, +) +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, + build_dry_run_payload_home, + mounted_mast_volumes_conn, + read_mast_volumes_conn, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot, + discovered_record_root_host, + discovery_record_to_jsonable, + service_instance_to_jsonable, +) +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + paths_to_jsonable, + validate_install, +) +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh + + +REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." +DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." +) +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." +) +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 + + +class AppOperationError(RuntimeError): + def __init__(self, message: str, *, debug: object | None = None) -> None: + super().__init__(message) + self.debug = debug + + +@dataclass(frozen=True) +class OperationResult: + ok: bool + payload: object | None = None + + +def _jsonable(value: object) -> object: + if is_dataclass(value): + return _jsonable(asdict(value)) + if isinstance(value, 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): + return value.strip().lower() in {"1", "true", "yes", "y"} + return bool(value) + + +def _int_param(params: dict[str, object], name: str, default: int) -> int: + value = params.get(name, default) + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise AppOperationError(f"{name} must be an integer") from exc + if parsed < 0: + raise AppOperationError(f"{name} must be 0 or greater") + 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}") + return value + + +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]: + return { + "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], + "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], + } + + +def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "discover" + timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) + sink.stage(operation, "bonjour_discovery") + snapshot = discover_snapshot(timeout=timeout) + return OperationResult(True, _snapshot_payload(snapshot)) + + +def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "paths" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "summarize_artifacts") + return OperationResult(True, paths_to_jsonable(app_paths)) + + +def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "validate-install" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + sink.check( + operation, + status="PASS" if check.ok else "FAIL", + message=check.message, + details=check.details, + ) + return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) + + +def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "configure" + sink.stage(operation, "load_existing_config") + app_paths = resolve_app_paths(config_path=_config_path(params)) + env_path = app_paths.config_path + existing = parse_env_file(env_path) + configure_id = str(uuid.uuid4()) + ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") + password = _require_string_param(params, "password") + if not host: + raise AppOperationError("missing required parameter: host") + + resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) + if resolution_error is not None: + raise AppOperationError(resolution_error) + + values = { + "TC_HOST": host, + "TC_PASSWORD": password, + "TC_SSH_OPTS": ssh_opts, + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if _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"])), + ) else "false", + "TC_ANY_PROTOCOL": "true" if _bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ) else "false", + "TC_CONFIGURE_ID": configure_id, + } + + sink.stage(operation, "ssh_probe") + connection = SshConnection(host, password, ssh_opts) + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not _bool_param(params, "enable_ssh", True): + raise AppOperationError("SSH is not reachable and enable_ssh is false.") + sink.stage(operation, "acp_enable_ssh") + try: + enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", debug=str(exc)) from exc + except ACPError as exc: + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}") from exc + + sink.stage(operation, "wait_for_ssh_after_acp") + if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): + raise AppOperationError("SSH did not open after enabling via ACP.") + sink.stage(operation, "ssh_probe_after_acp") + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_authenticated: + raise AppOperationError(probe.error or "The provided AirPort SSH target and password did not work.") + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + raise AppOperationError(render_compatibility_message(compatibility)) + + selected_props = _selected_record_properties(params) + observed_syap = None if compatibility is None else compatibility.exact_syap + observed_model = None if compatibility is None else compatibility.exact_model + if observed_syap is None: + observed_syap = selected_props.get("syAP") or None + + sink.stage(operation, "write_env") + env_path.parent.mkdir(parents=True, exist_ok=True) + write_env_file(env_path, values) + return OperationResult(True, { + "config_path": str(env_path), + "host": host, + "configure_id": configure_id, + "ssh_authenticated": True, + "device_syap": observed_syap, + "device_model": observed_model, + "compatibility": _jsonable(compatibility) if compatibility is not None else None, + }) + + +def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: + from timecapsulesmb.cli.flows import wait_for_tcp_port_state + + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + verbose=False, + service_name="SSH port", + ) + + +def _require_supported_payload(target, *, allow_unsupported: bool) -> object: + probe_state = target.probe_state + if probe_state is None: + raise AppOperationError("Failed to determine remote device OS compatibility.") + compatibility = require_compatibility( + probe_state.compatibility, + fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + if not compatibility.supported and not allow_unsupported: + raise AppOperationError(render_compatibility_message(compatibility)) + if not compatibility.payload_family: + raise AppOperationError("No deployable payload is available for this detected device.") + return compatibility + + +def _load_config_and_target( + operation: str, + params: dict[str, object], + sink: EventSink, + *, + profile: str, + include_probe: bool, +) -> tuple[AppConfig, object]: + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile=profile, + include_probe=include_probe, + ) + return config, target + + +def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "deploy" + nbns_enabled = _bool_param(params, "nbns_enabled", True) + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + yes = _bool_param(params, "yes", True) + mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + allow_unsupported = _bool_param(params, "allow_unsupported") + debug_logging = _bool_param(params, "debug_logging") + + config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=_config_path(params)) + + sink.stage(operation, "validate_artifacts") + failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] + if failures: + raise AppOperationError("; ".join(failures)) + + sink.stage(operation, "check_compatibility") + compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) + + if dry_run: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + else: + sink.stage(operation, "read_mast") + mast_discovery = wait_for_mast_volumes_conn( + connection, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + if not mast_discovery.volumes: + raise AppOperationError( + f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " + f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart." + ) + sink.stage(operation, "select_payload_home") + selection = select_payload_home_with_diagnostics_conn( + connection, + mast_discovery.volumes, + MANAGED_PAYLOAD_DIR_NAME, + wait_seconds=mount_wait, + ) + if selection.payload_home is None: + raise AppOperationError(f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.") + payload_home = selection.payload_home + + sink.stage(operation, "build_deployment_plan") + plan = build_deployment_plan( + connection.host, + payload_home, + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + if dry_run: + return OperationResult(True, deployment_plan_to_jsonable(plan)) + + if is_netbsd4 and not yes: + raise AppOperationError("NetBSD 4 deploy requires explicit confirmation.") + + sink.stage(operation, "pre_upload_actions") + run_remote_actions(connection, plan.pre_upload_actions) + sink.stage(operation, "prepare_deployment_files") + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + tmpdir = Path(tmp) + generated_flash_config = tmpdir / "tcapsulesmb.conf" + generated_smbpasswd = tmpdir / "smbpasswd" + generated_username_map = tmpdir / "username.map" + generated_flash_config.write_text(flash_config_text) + smbpasswd_text, username_map_text = render_smbpasswd(connection.password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + upload_sources = { + BINARY_SMBD_SOURCE: plan.smbd_path, + BINARY_MDNS_SOURCE: plan.mdns_path, + BINARY_NBNS_SOURCE: plan.nbns_path, + GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, + GENERATED_USERNAME_MAP_SOURCE: generated_username_map, + GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, + PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), + PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), + PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), + } + sink.stage(operation, "upload_payload") + upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) + + sink.stage(operation, "post_upload_actions") + run_remote_actions(connection, plan.post_upload_actions) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) + sink.stage(operation, "flush_payload_upload") + sink.log(operation, "Flushing deployed payload to disk...") + flush_remote_filesystem_writes(connection) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) + + if is_netbsd4: + sink.stage(operation, "netbsd4_activation") + run_remote_actions(connection, plan.activation_actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, { + "payload_dir": plan.payload_dir, + "netbsd4": True, + "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + }) + + if no_reboot: + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) + if not yes: + 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, + ) + raise AppOperationError(f"Deploy requires confirmation to reboot the {device_name}.") + + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + ) + _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) + + +def _verify_payload_upload( + operation: str, + sink: EventSink, + connection: SshConnection, + payload_home, + *, + wait_seconds: int, + post_sync: bool = False, +) -> None: + sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) + sink.log(operation, verification.detail) + if not verification.ok: + raise AppOperationError(f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}") + + +def _verify_runtime( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, +) -> None: + sink.stage(operation, stage) + verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification( + verification, + heading="Waiting for managed runtime to finish starting...", + ): + sink.log(operation, line) + if not managed_runtime_ready(verification): + raise AppOperationError(f"Managed runtime did not become ready. {verification.detail.strip()}".strip()) + + +def _request_reboot_and_wait( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + reboot_no_down_message: str, + down_timeout_seconds: int = 60, + up_timeout_seconds: int = 240, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + _request_ssh_reboot(operation, sink, connection, shutdown=False) + else: + _request_ssh_reboot(operation, sink, connection, shutdown=True) + + sink.stage(operation, "wait_for_reboot_down") + sink.log(operation, "Waiting for the device to go down...") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message) + sink.stage(operation, "wait_for_reboot_up") + sink.log(operation, "Waiting for the device to come back up...") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) + sink.log(operation, "Device is back online.") + + +def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: + try: + if shutdown: + remote_request_shutdown_reboot(connection) + else: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + return + sink.log(operation, "SSH reboot requested.") + + +def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "activate" + yes = _bool_param(params, "yes", True) + dry_run = _bool_param(params, "dry_run") + _, target = _load_config_and_target(operation, params, sink, 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.") + sink.stage(operation, "build_activation_plan") + plan = build_netbsd4_activation_plan() + if dry_run: + return OperationResult(True, _jsonable(plan)) + if not yes: + raise AppOperationError("NetBSD4 activation requires explicit confirmation.") + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, {"already_active": True}) + sink.stage(operation, "run_activation") + run_remote_actions(connection, plan.actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) + + +def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "uninstall" + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + yes = _bool_param(params, "yes", True) + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + if dry_run: + volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] + payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] + else: + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_mast_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + volume_roots = [volume.volume_root for volume in mounted_volumes] + payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] + sink.stage(operation, "build_uninstall_plan") + plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) + if dry_run: + return OperationResult(True, uninstall_plan_to_jsonable(plan)) + sink.stage(operation, "uninstall_payload") + remote_uninstall_payload(connection, plan) + if no_reboot: + return OperationResult(True, {"rebooted": False, "verified": False}) + if not yes: + raise AppOperationError("Uninstall requires explicit confirmation to reboot.") + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="acp_then_ssh", + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + ) + sink.stage(operation, "verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + sink.log(operation, line) + if not verification: + raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.") + return OperationResult(True, {"rebooted": True, "verified": True}) + + +def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "fsck" + yes = _bool_param(params, "yes", True) + no_reboot = _bool_param(params, "no_reboot") + no_wait = _bool_param(params, "no_wait") + if not yes: + raise AppOperationError("fsck requires explicit confirmation.") + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + sink.stage(operation, "select_fsck_volume") + try: + target = select_fsck_target( + tuple(_target_from_volume(volume) for volume in mounted_volumes), + _string_param(params, "volume") or None, + prompt=False, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc)) from exc + sink.stage(operation, "run_fsck") + script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + ) + if proc.stdout: + for line in proc.stdout.splitlines(): + sink.log(operation, line) + if no_reboot: + return OperationResult(proc.returncode == 0, { + "device": target.device, + "mountpoint": target.mountpoint, + "returncode": proc.returncode, + }) + if no_wait: + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) + _observe_reboot_cycle( + operation, + sink, + connection, + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) + + +def _observe_reboot_cycle( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + reboot_no_down_message: str, + down_timeout_seconds: int, + up_timeout_seconds: int, +) -> None: + sink.stage(operation, "wait_for_reboot_down") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message) + sink.stage(operation, "wait_for_reboot_up") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) + + +class _RepairContext: + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self.sink.stage(self.operation, stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + dry_run = _bool_param(params, "dry_run") + yes = _bool_param(params, "yes") + if not dry_run and not yes: + raise AppOperationError("repair-xattrs requires dry_run or explicit confirmation.") + if sys.platform != "darwin": + raise AppOperationError("repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.") + config = load_optional_env_config(env_path=_config_path(params)) + args = argparse.Namespace( + path=Path(str(params["path"])) if params.get("path") else None, + dry_run=dry_run, + yes=yes, + recursive=_bool_param(params, "recursive", True), + max_depth=params.get("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 args.max_depth is not None: + args.max_depth = int(args.max_depth) + context = _RepairContext(operation, sink) + output = io.StringIO() + with redirect_stdout(output): + try: + rc = repair_xattrs_cli.run_repair(args, context, config) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + raise AppOperationError(message) from exc + for line in output.getvalue().splitlines(): + sink.log(operation, line) + return OperationResult(rc == 0, {"returncode": rc, "telemetry_result": context.result, "error": context.error}) + + +def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "doctor" + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + app_paths = resolve_app_paths(config_path=_config_path(params)) + connection = None + if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + sink.check(operation, status=result.status, message=result.message, details=result.details) + + sink.stage(operation, "run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=_bool_param(params, "skip_ssh"), + skip_bonjour=_bool_param(params, "skip_bonjour"), + skip_smb=_bool_param(params, "skip_smb"), + on_result=on_result, + debug_fields=debug_fields, + ) + payload = { + "fatal": fatal, + "results": [_jsonable(result) for result in results], + "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", + } + if fatal: + payload["error"] = build_doctor_error(results, debug_fields) + return OperationResult(not fatal, payload) + + +OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { + "activate": activate_operation, + "configure": configure_operation, + "deploy": deploy_operation, + "discover": discover_operation, + "doctor": doctor_operation, + "fsck": fsck_operation, + "paths": paths_operation, + "repair-xattrs": repair_xattrs_operation, + "uninstall": uninstall_operation, + "validate-install": validate_install_operation, +} + + +def run_api_request(request: dict[str, object], sink: EventSink) -> int: + operation = str(request.get("operation") or "") + params = request.get("params") or {} + if not operation: + sink.error("api", "missing required field: operation") + return 1 + if not isinstance(params, dict): + sink.error(operation, "params must be a JSON object") + return 1 + handler = OPERATIONS.get(operation) + if handler is None: + sink.error(operation, f"unknown operation: {operation}", debug={"known_operations": sorted(OPERATIONS)}) + return 1 + try: + result = handler(params, sink) + except AppOperationError as exc: + sink.error(operation, str(exc), debug=redact(exc.debug) if exc.debug is not None else None) + return 1 + except (SystemExit, KeyboardInterrupt): + raise + except Exception as exc: + sink.error(operation, f"{type(exc).__name__}: {exc}") + return 1 + sink.result(operation, ok=result.ok, payload=result.payload) + return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/cli/main.py b/src/timecapsulesmb/cli/main.py index 0dc61e2f..0062d5a0 100644 --- a/src/timecapsulesmb/cli/main.py +++ b/src/timecapsulesmb/cli/main.py @@ -5,11 +5,13 @@ from typing import Optional from . import activate, bootstrap, configure, deploy, discover, doctor, flash, fsck, paths, set_ssh, repair_xattrs, uninstall, validate_install +from timecapsulesmb.app import helper as app_helper from timecapsulesmb.core.paths import DistributionRootError from .version_check import check_client_version, render_version_block_message COMMANDS = { + "api": app_helper.main, "bootstrap": bootstrap.main, "activate": activate.main, "configure": configure.main, @@ -36,7 +38,7 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) - if "-h" not in args.args and "--help" not in args.args: + if args.command != "api" and "-h" not in args.args and "--help" not in args.args: try: version_check = check_client_version() if version_check.should_block: diff --git a/tests/test_app_api.py b/tests/test_app_api.py new file mode 100644 index 00000000..975d2de1 --- /dev/null +++ b/tests/test_app_api.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from timecapsulesmb.app.events import AppEvent, EventSink +from timecapsulesmb.app import helper, service +from timecapsulesmb.cli import main as cli_main +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.device.compat import DeviceCompatibility +from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState +from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance +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] + + +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 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(), + ) + + +class AppApiTests(unittest.TestCase): + def test_event_redacts_password_fields(self) -> None: + event = AppEvent("result", "configure", { + "ok": True, + "payload": { + "password": "secret", + "nested": {"TC_PASSWORD": "secret"}, + }, + }) + + data = event.to_jsonable() + + self.assertEqual(data["payload"]["password"], "") + self.assertEqual(data["payload"]["nested"]["TC_PASSWORD"], "") + + def test_result_event_preserves_falsey_payloads(self) -> None: + collector = CollectingSink() + + collector.sink.result("paths", ok=True, payload=[]) + + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"], []) + + 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) + self.assertEqual(len(collector.events_of_type("error")), 1) + self.assertEqual(collector.events_of_type("result"), []) + + 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=("10.0.0.2",), + properties={"syAP": "119"}, + ) + ], + ) + + with mock.patch("timecapsulesmb.app.service.discover_snapshot", return_value=snapshot): + rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) + + self.assertEqual(rc, 0) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["resolved"][0]["name"], "TC") + self.assertEqual(result["payload"]["resolved"][0]["ipv4"], ["10.0.0.2"]) + + def test_configure_writes_env_without_leaking_password_to_events(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.service.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.assertIn("TC_PASSWORD=goodpw", config_path.read_text()) + serialized_events = json.dumps(collector.events) + self.assertNotIn("goodpw", serialized_events) + + 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.service.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.service.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.service.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_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.service.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.service.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.service.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.service.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.service.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": True, "yes": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = collector.events_of_type("result")[0] + self.assertEqual(result["payload"]["host"], "root@10.0.0.2") + self.assertEqual(result["payload"]["reboot_required"], True) + + def test_helper_reads_request_and_writes_ndjson(self) -> None: + output = io.StringIO() + fake_stdin = io.StringIO('{"operation":"paths","params":{}}') + with mock.patch.object(sys, "stdin", fake_stdin): + with mock.patch("timecapsulesmb.app.helper.run_api_request") as run_mock: + run_mock.side_effect = lambda request, sink: (sink.result(request["operation"], ok=True, payload={"ok": True}) or 0) + with redirect_stdout(output): + rc = helper.main([]) + + self.assertEqual(rc, 0) + line = json.loads(output.getvalue()) + self.assertEqual(line["type"], "result") + self.assertEqual(line["operation"], "paths") + + def test_api_command_is_registered(self) -> None: + self.assertIs(cli_main.COMMANDS["api"], helper.main) + + +if __name__ == "__main__": + unittest.main() From 7716369d8e5665a7aab19b6cf771af8c8b5612c2 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 21:56:03 -0700 Subject: [PATCH 02/20] Implement macOS helper runner, confirmations, and service-layer app operations --- macos/TimeCapsuleSMB/Package.swift | 29 +- .../TimeCapsuleSMBApp/BackendClient.swift | 122 +-- .../TimeCapsuleSMBApp/ContentView.swift | 64 +- .../TimeCapsuleSMBApp/HelperLocator.swift | 195 ++++ .../TimeCapsuleSMBApp/HelperRunner.swift | 171 ++++ .../Sources/TimeCapsuleSMBApp/Models.swift | 86 +- .../TimeCapsuleSMBApp/OutputLineParser.swift | 42 + .../PendingConfirmation.swift | 86 ++ .../main.swift} | 4 +- .../BackendEventTests.swift | 37 + .../HelperLocatorTests.swift | 69 ++ .../HelperRunnerTests.swift | 90 ++ .../OutputLineParserTests.swift | 20 + .../PendingConfirmationTests.swift | 41 + .../TemporaryDirectory.swift | 10 + src/timecapsulesmb/app/events.py | 39 +- src/timecapsulesmb/app/helper.py | 10 +- src/timecapsulesmb/app/operations.py | 863 +++++++++++++++++ src/timecapsulesmb/app/service.py | 909 +----------------- src/timecapsulesmb/cli/repair_xattrs.py | 150 ++- src/timecapsulesmb/services/__init__.py | 2 + src/timecapsulesmb/services/app.py | 80 ++ tests/test_app_api.py | 523 +++++++++- 23 files changed, 2558 insertions(+), 1084 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift rename macos/TimeCapsuleSMB/Sources/{TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift => TimeCapsuleSMBExecutable/main.swift} (64%) create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/TemporaryDirectory.swift create mode 100644 src/timecapsulesmb/app/operations.py create mode 100644 src/timecapsulesmb/services/__init__.py create mode 100644 src/timecapsulesmb/services/app.py diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift index bd471c91..a5ccd2a6 100644 --- a/macos/TimeCapsuleSMB/Package.swift +++ b/macos/TimeCapsuleSMB/Package.swift @@ -1,15 +1,38 @@ // 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", platforms: [.macOS(.v13)], products: [ - .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBApp"]) + .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"]) ], targets: [ - .executableTarget(name: "TimeCapsuleSMBApp") + .target( + name: "TimeCapsuleSMBApp", + path: "Sources/TimeCapsuleSMBApp" + ), + .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/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index 9ec5bcf0..27a0f10e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -7,8 +7,12 @@ final class BackendClient: ObservableObject { @Published var isRunning = false @Published var lastExitCode: Int32? - init() { - helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? ".venv/bin/tcapsule" + private let runner: HelperRunner + private var runTask: Task? + + init(runner: HelperRunner = HelperRunner()) { + self.runner = runner + helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" } func clear() { @@ -20,10 +24,10 @@ final class BackendClient: ObservableObject { guard !isRunning else { return } isRunning = true lastExitCode = nil - let helperPath = self.helperPath - Task.detached { - let exitCode = await Self.runHelper( - helperPath: helperPath, + let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) + runTask = Task { + let result = await runner.run( + helperPath: helperPath.isEmpty ? nil : helperPath, operation: operation, params: params ) { event in @@ -31,109 +35,13 @@ final class BackendClient: ObservableObject { self.events.append(event) } } - await MainActor.run { - self.lastExitCode = exitCode - self.isRunning = false - } - } - } - - private static func runHelper( - helperPath: String, - operation: String, - params: [String: JSONValue], - onEvent: @escaping (BackendEvent) -> Void - ) async -> Int32 { - let process = Process() - process.executableURL = helperURL(for: helperPath) - process.arguments = ["api"] - process.environment = helperEnvironment() - - let input = Pipe() - let output = Pipe() - let error = Pipe() - process.standardInput = input - process.standardOutput = output - process.standardError = error - - let decoder = JSONDecoder() - let parser = OutputLineParser(onEvent: onEvent) - output.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - parser.append(data) - } - - do { - try process.run() - let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(params)] - let requestData = try JSONEncoder().encode(JSONValue.object(request)) - input.fileHandleForWriting.write(requestData) - input.fileHandleForWriting.closeFile() - process.waitUntilExit() - output.fileHandleForReading.readabilityHandler = nil - _ = error.fileHandleForReading.readDataToEndOfFile() - return process.terminationStatus - } catch { - let fallback = """ - {"type":"error","operation":"\(operation)","message":"\(error.localizedDescription)"} - """ - if let data = fallback.data(using: .utf8), let event = try? decoder.decode(BackendEvent.self, from: data) { - onEvent(event) - } - return 1 - } - } - - private static func helperURL(for path: String) -> URL { - if path.hasPrefix("/") { - return URL(fileURLWithPath: path) - } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(path) - } - - private static func helperEnvironment() -> [String: String] { - var environment = ProcessInfo.processInfo.environment - guard - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first?.appendingPathComponent("TimeCapsuleSMB", isDirectory: true) - else { - return environment - } - try? FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) - if environment["TCAPSULE_CONFIG"] == nil { - environment["TCAPSULE_CONFIG"] = appSupport.appendingPathComponent(".env").path - } - if environment["TCAPSULE_STATE_DIR"] == nil { - environment["TCAPSULE_STATE_DIR"] = appSupport.path + self.lastExitCode = result.exitCode + self.isRunning = false + self.runTask = nil } - return environment - } -} - -private final class OutputLineParser: @unchecked Sendable { - private let lock = NSLock() - private var buffer = Data() - private let decoder = JSONDecoder() - private let onEvent: (BackendEvent) -> Void - - init(onEvent: @escaping (BackendEvent) -> Void) { - self.onEvent = onEvent } - func append(_ data: Data) { - lock.lock() - defer { lock.unlock() } - buffer.append(data) - while let newline = buffer.firstIndex(of: 0x0A) { - let line = buffer.prefix(upTo: newline) - buffer.removeSubrange(...newline) - guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: line) else { - continue - } - onEvent(event) - } + func cancel() { + runTask?.cancel() } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index ac342031..1feeef25 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct ContentView: View { +public struct ContentView: View { @StateObject private var backend = BackendClient() @State private var selection: Screen = .readiness @State private var host = "root@192.168.x.x" @@ -10,8 +10,11 @@ struct ContentView: View { @State private var nbnsEnabled = true @State private var noReboot = false @State private var dryRun = true + @State private var pendingConfirmation: PendingConfirmation? - var body: some View { + public init() {} + + public var body: some View { NavigationSplitView { List(Screen.allCases, selection: $selection) { screen in Label(screen.title, systemImage: screen.icon) @@ -32,10 +35,26 @@ struct ContentView: View { Label("Clear", systemImage: "trash") } .disabled(backend.isRunning) + Button { + backend.cancel() + } label: { + Label("Cancel", systemImage: "xmark.circle") + } + .disabled(!backend.isRunning) } } } .frame(minWidth: 980, minHeight: 680) + .alert(item: $pendingConfirmation) { confirmation in + Alert( + title: Text(confirmation.title), + message: Text(confirmation.message), + primaryButton: .destructive(Text(confirmation.actionTitle)) { + backend.run(operation: confirmation.operation, params: confirmation.params) + }, + secondaryButton: .cancel() + ) + } } @ViewBuilder @@ -72,12 +91,15 @@ struct ContentView: View { Toggle("No Reboot", isOn: $noReboot) Toggle("Dry Run", isOn: $dryRun) Button { - backend.run(operation: "deploy", params: [ - "dry_run": .bool(dryRun), - "yes": .bool(true), - "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled) - ]) + if dryRun { + backend.run(operation: "deploy", params: [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "nbns_enabled": .bool(nbnsEnabled) + ]) + } else { + pendingConfirmation = .deploy(noReboot: noReboot, nbnsEnabled: nbnsEnabled) + } } label: { Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") } @@ -91,16 +113,25 @@ struct ContentView: View { CommandPanel(title: "Maintenance") { TextField("Repair xattrs path", text: $repairPath) TextField("fsck volume, optional", text: $volume) + Toggle("No Reboot", isOn: $noReboot) HStack { - runButton("Activate", icon: "power", operation: "activate", params: ["yes": .bool(true)]) + Button { + pendingConfirmation = .activate() + } label: { + Label("Activate", systemImage: "power") + } + .disabled(backend.isRunning) runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: ["dry_run": .bool(true)]) + Button { + pendingConfirmation = .uninstall(noReboot: noReboot) + } label: { + Label("Uninstall", systemImage: "xmark.bin.fill") + } + .disabled(backend.isRunning) } HStack { Button { - backend.run(operation: "fsck", params: [ - "yes": .bool(true), - "volume": .string(volume) - ]) + pendingConfirmation = .fsck(volume: volume, noReboot: noReboot) } label: { Label("Run fsck", systemImage: "externaldrive.badge.checkmark") } @@ -114,6 +145,12 @@ struct ContentView: View { Label("Scan xattrs", systemImage: "wand.and.stars") } .disabled(backend.isRunning || repairPath.isEmpty) + Button { + pendingConfirmation = .repairXattrs(path: repairPath) + } label: { + Label("Repair xattrs", systemImage: "wand.and.stars.inverse") + } + .disabled(backend.isRunning || repairPath.isEmpty) } } case .advanced: @@ -208,4 +245,3 @@ private struct EventList: View { } } } - diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift new file mode 100644 index 00000000..9ee981dd --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift @@ -0,0 +1,195 @@ +import Foundation + +public struct HelperResolution: Equatable { + public let executableURL: URL + public let distributionRootURL: URL? + 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.path) + if isExecutable(candidate) { + return HelperResolution( + executableURL: candidate, + distributionRootURL: distributionRoot(for: candidate), + attemptedPaths: attempts + ) + } + } + throw HelperLocatorError.notFound(attempts) + } + + public func helperEnvironment(for resolution: HelperResolution) -> [String: String] { + var output = environment + if let appSupport = applicationSupportDirectory() { + try? fileManager.createDirectory(at: appSupport, withIntermediateDirectories: true) + 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 + } + return output + } + + 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), + 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() -> [URL] { + var candidates: [URL] = [] + if let helper = bundle.url(forResource: "tcapsule", withExtension: nil, subdirectory: "Helpers") { + candidates.append(helper) + } + if let helper = bundle.url(forResource: "tcapsule", withExtension: nil) { + candidates.append(helper) + } + return candidates + } + + private func devHelperCandidates() -> [URL] { + 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 { $0.appendingPathComponent(".venv/bin/tcapsule") } + } + + private func distributionRoot(for helperURL: URL) -> URL? { + if let explicit = normalized(environment["TCAPSULE_DISTRIBUTION_ROOT"]) { + return url(forPath: explicit) + } + if let repo = repoRoot(containing: helperURL) { + return repo + } + if let bundled = bundle.resourceURL?.appendingPathComponent("Distribution"), isDirectory(bundled) { + return bundled + } + return nil + } + + 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? { + fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("TimeCapsuleSMB", isDirectory: true) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift new file mode 100644 index 00000000..79654aa8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -0,0 +1,171 @@ +import Darwin +import Foundation + +public struct HelperRunResult: Equatable { + public let exitCode: Int32 + public let sawTerminalEvent: Bool + public let stderr: String +} + +public final class HelperRunner { + private let locator: HelperLocator + private let stderrLimit: Int + + public init(locator: HelperLocator = HelperLocator(), stderrLimit: Int = 64 * 1024) { + self.locator = locator + self.stderrLimit = stderrLimit + } + + public func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + onEvent: @escaping (BackendEvent) -> Void + ) async -> HelperRunResult { + let terminalTracker = TerminalEventTracker() + let eventSink: (BackendEvent) -> Void = { event in + terminalTracker.record(event) + onEvent(event) + } + + let resolution: HelperResolution + do { + resolution = try locator.resolve(helperPath: helperPath) + } catch { + eventSink(BackendEvent.error(operation: operation, code: "helper_not_found", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let process = Process() + process.executableURL = resolution.executableURL + process.arguments = ["api"] + process.environment = locator.helperEnvironment(for: resolution) + + let input = Pipe() + let output = Pipe() + let error = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = error + + let parser = OutputLineParser(onEvent: eventSink) + do { + try process.run() + } catch { + eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + } + + let stdoutTask = Task.detached { + Self.readOutput(output.fileHandleForReading, parser: parser) + } + let stderrTask = Task.detached { + Self.readCapped(error.fileHandleForReading, limit: self.stderrLimit) + } + + do { + let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(params)] + let requestData = try JSONEncoder().encode(JSONValue.object(request)) + try input.fileHandleForWriting.write(contentsOf: requestData) + try input.fileHandleForWriting.close() + } catch { + try? input.fileHandleForWriting.close() + terminate(process) + eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) + await stdoutTask.value + let stderr = await stderrTask.value + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) + } + + var cancelled = false + while process.isRunning { + if Task.isCancelled { + cancelled = true + terminate(process) + break + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + + await stdoutTask.value + let stderrText = await stderrTask.value + let sawTerminalEvent = terminalTracker.sawTerminalEvent + if cancelled { + eventSink(BackendEvent.error( + operation: operation, + code: "cancelled", + message: "Operation cancelled.", + debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) + )) + } else if !sawTerminalEvent { + eventSink(BackendEvent.error( + operation: operation, + code: "missing_terminal_event", + message: "Helper exited without a result or error event.", + debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) + )) + } + + return HelperRunResult( + exitCode: cancelled ? 130 : process.terminationStatus, + sawTerminalEvent: terminalTracker.sawTerminalEvent, + stderr: stderrText + ) + } + + private static func readOutput(_ handle: FileHandle, parser: OutputLineParser) { + while true { + let data = handle.availableData + if data.isEmpty { + parser.finish() + return + } + parser.append(data) + } + } + + private static func readCapped(_ handle: FileHandle, limit: Int) -> String { + var output = Data() + while true { + let data = handle.availableData + if data.isEmpty { + break + } + if output.count < limit { + output.append(data.prefix(limit - output.count)) + } + } + return String(data: output, encoding: .utf8) ?? "" + } + + private func terminate(_ process: Process) { + process.terminate() + for _ in 0..<10 { + if !process.isRunning { + return + } + Thread.sleep(forTimeInterval: 0.1) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + } +} + +private final class TerminalEventTracker: @unchecked Sendable { + private let lock = NSLock() + private var seen = false + + var sawTerminalEvent: Bool { + lock.lock() + defer { lock.unlock() } + return seen + } + + func record(_ event: BackendEvent) { + guard event.type == "result" || event.type == "error" else { return } + lock.lock() + seen = true + lock.unlock() + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index b9a70e53..bd2598fa 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -1,6 +1,6 @@ import Foundation -enum JSONValue: Codable, Hashable { +public enum JSONValue: Codable, Hashable { case string(String) case number(Double) case bool(Bool) @@ -8,7 +8,7 @@ enum JSONValue: Codable, Hashable { case array([JSONValue]) case null - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null @@ -25,7 +25,7 @@ enum JSONValue: Codable, Hashable { } } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .string(let value): @@ -43,7 +43,7 @@ enum JSONValue: Codable, Hashable { } } - var displayText: String { + public var displayText: String { switch self { case .string(let value): return value @@ -65,22 +65,73 @@ enum JSONValue: Codable, Hashable { } } -struct BackendEvent: Decodable, Identifiable { - let id = UUID() - let type: String - let operation: String - let stage: String? - let level: String? - let message: String? - let status: String? - let ok: Bool? - let payload: JSONValue? - let details: JSONValue? - let debug: JSONValue? +public struct BackendEvent: Decodable, Identifiable { + 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 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 + ) { + 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 + } + + public static func error( + operation: String, + code: String, + message: String, + debug: JSONValue? = nil + ) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: code, + message: message, + debug: debug + ) + } enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case requestId = "request_id" case type case operation + case code case stage case level case message @@ -91,7 +142,7 @@ struct BackendEvent: Decodable, Identifiable { case debug } - var summary: String { + public var summary: String { switch type { case "stage": return stage.map { "\(operation): \($0)" } ?? operation @@ -106,4 +157,3 @@ struct BackendEvent: Decodable, Identifiable { } } } - diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift new file mode 100644 index 00000000..4e702be2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift @@ -0,0 +1,42 @@ +import Foundation + +public final class OutputLineParser: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + private let decoder = JSONDecoder() + private let onEvent: (BackendEvent) -> Void + + public init(onEvent: @escaping (BackendEvent) -> Void) { + self.onEvent = onEvent + } + + public func append(_ data: Data) { + lock.lock() + defer { lock.unlock() } + buffer.append(data) + consumeCompleteLines() + } + + public func finish() { + lock.lock() + defer { lock.unlock() } + guard !buffer.isEmpty else { return } + emit(buffer) + buffer.removeAll() + } + + private func consumeCompleteLines() { + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer.prefix(upTo: newline) + buffer.removeSubrange(...newline) + emit(line) + } + } + + private func emit(_ line: Data.SubSequence) { + guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: Data(line)) else { + return + } + onEvent(event) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift new file mode 100644 index 00000000..09bbb35f --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -0,0 +1,86 @@ +import Foundation + +struct PendingConfirmation: Identifiable { + let id = UUID() + let title: String + let message: String + let actionTitle: String + let operation: String + let params: [String: JSONValue] + + static func deploy(noReboot: Bool, nbnsEnabled: Bool) -> PendingConfirmation { + PendingConfirmation( + title: noReboot ? "Deploy Without Reboot?" : "Deploy And Reboot?", + message: noReboot + ? "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device." + : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately.", + actionTitle: noReboot ? "Deploy" : "Deploy And Allow Reboot", + operation: "deploy", + params: [ + "dry_run": .bool(false), + "confirm_deploy": .bool(true), + "confirm_reboot": .bool(!noReboot), + "confirm_netbsd4_activation": .bool(true), + "no_reboot": .bool(noReboot), + "nbns_enabled": .bool(nbnsEnabled) + ] + ) + } + + static func activate() -> PendingConfirmation { + PendingConfirmation( + title: "Activate NetBSD 4 Runtime?", + message: "This will restart the deployed Samba runtime on an older NetBSD 4 device.", + actionTitle: "Activate", + operation: "activate", + params: ["confirm_netbsd4_activation": .bool(true)] + ) + } + + static func fsck(volume: String, noReboot: Bool) -> PendingConfirmation { + PendingConfirmation( + title: noReboot ? "Run Disk Repair Without Reboot?" : "Run Disk Repair And Reboot?", + message: noReboot + ? "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward." + : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot.", + actionTitle: "Run fsck", + operation: "fsck", + params: [ + "confirm_fsck": .bool(true), + "no_reboot": .bool(noReboot), + "volume": .string(volume) + ] + ) + } + + static func uninstall(noReboot: Bool) -> PendingConfirmation { + PendingConfirmation( + title: noReboot ? "Uninstall Without Reboot?" : "Uninstall And Reboot?", + message: noReboot + ? "This will remove the managed TimeCapsuleSMB payload without rebooting the device." + : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot.", + actionTitle: "Uninstall", + operation: "uninstall", + params: [ + "dry_run": .bool(false), + "confirm_uninstall": .bool(true), + "confirm_reboot": .bool(!noReboot), + "no_reboot": .bool(noReboot) + ] + ) + } + + static func repairXattrs(path: String) -> PendingConfirmation { + PendingConfirmation( + title: "Repair Extended Attributes?", + message: "This will repair extended attributes at the selected mounted SMB path.", + actionTitle: "Repair xattrs", + operation: "repair-xattrs", + params: [ + "path": .string(path), + "dry_run": .bool(false), + "confirm_repair": .bool(true) + ] + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift similarity index 64% rename from macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift rename to macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift index 390cb570..b3620ecd 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/TimeCapsuleSMBApp.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -1,11 +1,11 @@ import SwiftUI +import TimeCapsuleSMBApp @main -struct TimeCapsuleSMBApp: App { +struct TimeCapsuleSMBExecutable: App { var body: some Scene { WindowGroup { ContentView() } } } - diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift new file mode 100644 index 00000000..8f80a4e5 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -0,0 +1,37 @@ +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"}} + """.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")])) + } + + func testJSONValueRoundTripsNestedObjects() throws { + let value = JSONValue.object([ + "operation": .string("paths"), + "params": .object([ + "dry_run": .bool(true), + "mount_wait": .number(30), + "items": .array([.string("one"), .null]) + ]) + ]) + + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + + XCTAssertEqual(decoded, value) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift new file mode 100644 index 00000000..0aaf26bc --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -0,0 +1,69 @@ +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) + XCTAssertNotNil(environment["TCAPSULE_CONFIG"]) + XCTAssertNotNil(environment["TCAPSULE_STATE_DIR"]) + } + + 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(environment["TCAPSULE_DISTRIBUTION_ROOT"], repo.path) + } + + 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) + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift new file mode 100644 index 00000000..31ca3583 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class HelperRunnerTests: XCTestCase { + func testRunnerStreamsEventsFromHelper() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"stage","operation":"paths","stage":"start"}' + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"paths","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { + recorder.append($0) + } + + let events = recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.map(\.type), ["stage", "result"]) + XCTAssertEqual(events.last?.ok, true) + } + + 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: [:]) { + recorder.append($0) + } + + let events = recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "missing_terminal_event") + XCTAssertEqual(events.last?.debug, .object(["stderr": .string("stderr detail\n")])) + } + + func testRunnerReportsMissingHelper() async { + let locator = HelperLocator(environment: [:], currentDirectory: URL(fileURLWithPath: NSTemporaryDirectory()), bundle: .main, fileManager: .default) + let runner = HelperRunner(locator: locator) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: "/missing/tcapsule", operation: "paths", params: [:]) { + recorder.append($0) + } + + XCTAssertEqual(result.exitCode, 1) + XCTAssertEqual(recorder.events.last?.type, "error") + XCTAssertEqual(recorder.events.last?.code, "helper_not_found") + } + + 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 final class EventRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [BackendEvent] = [] + + var events: [BackendEvent] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ event: BackendEvent) { + lock.lock() + storage.append(event) + lock.unlock() + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift new file mode 100644 index 00000000..93c87319 --- /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 events: [BackendEvent] = [] + let parser = OutputLineParser { events.append($0) } + + parser.append(Data(#"{"type":"stage","operation":"paths","stage":"resolve"#.utf8)) + parser.append(Data(#"_paths"}"#.utf8)) + parser.append(Data("\nnot-json\n".utf8)) + parser.append(Data(#"{"type":"result","operation":"paths","ok":true,"payload":{}}"#.utf8)) + 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/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift new file mode 100644 index 00000000..2861050b --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class PendingConfirmationTests: XCTestCase { + func testDeployConfirmationCarriesDeployAndRebootConsent() { + let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true) + + XCTAssertEqual(confirmation.operation, "deploy") + XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) + XCTAssertEqual(confirmation.params["confirm_deploy"], .bool(true)) + XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(true)) + XCTAssertEqual(confirmation.params["confirm_netbsd4_activation"], .bool(true)) + XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) + XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) + } + + func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { + let confirmation = PendingConfirmation.uninstall(noReboot: true) + + XCTAssertEqual(confirmation.operation, "uninstall") + XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) + XCTAssertEqual(confirmation.params["confirm_uninstall"], .bool(true)) + XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(false)) + XCTAssertEqual(confirmation.params["no_reboot"], .bool(true)) + } + + func testMaintenanceConfirmationsCarryExplicitOperationConsent() { + let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true) + let repair = PendingConfirmation.repairXattrs(path: "/Volumes/Data") + + XCTAssertEqual(fsck.operation, "fsck") + XCTAssertEqual(fsck.params["confirm_fsck"], .bool(true)) + XCTAssertEqual(fsck.params["no_reboot"], .bool(true)) + XCTAssertEqual(fsck.params["volume"], .string("Data")) + + XCTAssertEqual(repair.operation, "repair-xattrs") + XCTAssertEqual(repair.params["path"], .string("/Volumes/Data")) + XCTAssertEqual(repair.params["dry_run"], .bool(false)) + XCTAssertEqual(repair.params["confirm_repair"], .bool(true)) + } +} 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/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index 258d9db8..2accfd9e 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Callable @@ -31,9 +32,13 @@ 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 = {"type": self.type, "operation": self.operation} + 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 @@ -42,10 +47,29 @@ def to_json_line(self) -> str: class EventSink: - def __init__(self, emit: Callable[[AppEvent], None]) -> None: + 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 + + 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 stage(self, operation: str, stage: str) -> None: @@ -71,8 +95,15 @@ def check( 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, *, debug: object | None = None) -> None: - fields: dict[str, object] = {"message": message} + def error( + self, + operation: str, + message: str, + *, + code: str = "operation_failed", + debug: object | None = None, + ) -> None: + fields: dict[str, object] = {"code": code, "message": message} if debug is not None: fields["debug"] = debug self.emit(AppEvent("error", operation, fields)) diff --git a/src/timecapsulesmb/app/helper.py b/src/timecapsulesmb/app/helper.py index 69209238..bf2ace48 100644 --- a/src/timecapsulesmb/app/helper.py +++ b/src/timecapsulesmb/app/helper.py @@ -3,6 +3,7 @@ import argparse import json import sys +import uuid from typing import Optional, TextIO from timecapsulesmb.app.events import AppEvent, EventSink @@ -25,23 +26,22 @@ def main(argv: Optional[list[str]] = None) -> int: help="Also write request parsing errors to stderr for local debugging.", ) args = parser.parse_args(argv) - sink = _sink_for_stream(sys.stdout) + sink = _sink_for_stream(sys.stdout).with_request_id(str(uuid.uuid4())) raw = sys.stdin.read() try: request = json.loads(raw) except json.JSONDecodeError as exc: message = f"invalid JSON request: {exc.msg}" - sink.error("api", message, debug={"pos": exc.pos}) + sink.error("api", message, code="invalid_request", debug={"pos": exc.pos}) if args.pretty_error: - print(message, file=sys.stderr) + print("invalid JSON request", file=sys.stderr) return 1 if not isinstance(request, dict): - sink.error("api", "request must be a JSON object") + sink.error("api", "request must be a JSON object", code="invalid_request") return 1 return run_api_request(request, sink) if __name__ == "__main__": raise SystemExit(main()) - diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py new file mode 100644 index 00000000..4d658550 --- /dev/null +++ b/src/timecapsulesmb/app/operations.py @@ -0,0 +1,863 @@ +from __future__ import annotations + +import argparse +import shlex +import sys +import tempfile +import uuid +from collections.abc import Callable +from contextlib import ExitStack +from pathlib import Path + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli +from timecapsulesmb.cli.deploy import render_flash_runtime_config +from timecapsulesmb.cli.doctor import build_doctor_error +from timecapsulesmb.cli.fsck import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + build_remote_fsck_script, + select_fsck_target, + _target_from_volume, +) +from timecapsulesmb.cli.runtime import ( + load_env_config, + load_optional_env_config, + resolve_env_connection, + resolve_validated_managed_target, + ssh_target_link_local_resolution_error, +) +from timecapsulesmb.core.config import ( + DEFAULTS, + MANAGED_PAYLOAD_DIR_NAME, + AppConfig, + airport_family_display_name_from_identity, + parse_bool, + parse_env_file, + write_env_file, +) +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts +from timecapsulesmb.deploy.artifacts import validate_artifacts +from timecapsulesmb.deploy.auth import render_smbpasswd +from timecapsulesmb.deploy.boot_assets import boot_asset_path +from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable +from timecapsulesmb.deploy.executor import ( + flush_remote_filesystem_writes, + remote_request_reboot, + remote_request_shutdown_reboot, + remote_uninstall_payload, + run_remote_actions, + upload_deployment_payload, +) +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_START_SAMBA_SOURCE, + PACKAGED_WATCHDOG_SOURCE, + build_deployment_plan, + build_netbsd4_activation_plan, + build_uninstall_plan, +) +from timecapsulesmb.deploy.verify import ( + managed_runtime_ready, + render_managed_runtime_verification, + render_post_uninstall_verification, + verify_managed_runtime, + verify_post_uninstall, +) +from timecapsulesmb.device.compat import ( + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, + require_compatibility, +) +from timecapsulesmb.device.probe import ( + probe_connection_state, + probe_managed_runtime_conn, + wait_for_ssh_state_conn, +) +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, + build_dry_run_payload_home, + mounted_mast_volumes_conn, + read_mast_volumes_conn, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot, + discovered_record_root_host, + discovery_record_to_jsonable, + service_instance_to_jsonable, +) +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + paths_to_jsonable, + validate_install, +) +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param as _bool_param, + config_path as _config_path, + confirm_param as _confirm_param, + int_param as _int_param, + jsonable as _jsonable, + require_string_param as _require_string_param, + string_param as _string_param, +) +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh + + +REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." +DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." +) +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." +) +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 + + +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]: + return { + "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], + "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], + } + + +def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "discover" + timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) + sink.stage(operation, "bonjour_discovery") + snapshot = discover_snapshot(timeout=timeout) + return OperationResult(True, _snapshot_payload(snapshot)) + + +def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "paths" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "summarize_artifacts") + return OperationResult(True, paths_to_jsonable(app_paths)) + + +def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "validate-install" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=_config_path(params)) + sink.stage(operation, "validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + sink.check( + operation, + status="PASS" if check.ok else "FAIL", + message=check.message, + details=check.details, + ) + return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) + + +def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "configure" + sink.stage(operation, "load_existing_config") + app_paths = resolve_app_paths(config_path=_config_path(params)) + env_path = app_paths.config_path + existing = parse_env_file(env_path) + configure_id = str(uuid.uuid4()) + ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") + password = _require_string_param(params, "password") + if not host: + raise AppOperationError("missing required parameter: host", code="validation_failed") + + resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) + if resolution_error is not None: + raise AppOperationError(resolution_error, code="config_error") + + values = { + "TC_HOST": host, + "TC_PASSWORD": password, + "TC_SSH_OPTS": ssh_opts, + "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if _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"])), + ) else "false", + "TC_ANY_PROTOCOL": "true" if _bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ) else "false", + "TC_CONFIGURE_ID": configure_id, + } + + sink.stage(operation, "ssh_probe") + connection = SshConnection(host, password, ssh_opts) + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not _bool_param(params, "enable_ssh", True): + raise AppOperationError("SSH is not reachable and enable_ssh is false.", code="remote_error") + sink.stage(operation, "acp_enable_ssh") + try: + enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc + except ACPError as exc: + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc + + sink.stage(operation, "wait_for_ssh_after_acp") + if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): + raise AppOperationError("SSH did not open after enabling via ACP.", code="remote_error") + sink.stage(operation, "ssh_probe_after_acp") + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_authenticated: + raise AppOperationError( + probe.error or "The provided AirPort SSH target and password did not work.", + code="auth_failed", + ) + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + + selected_props = _selected_record_properties(params) + observed_syap = None if compatibility is None else compatibility.exact_syap + observed_model = None if compatibility is None else compatibility.exact_model + if observed_syap is None: + observed_syap = selected_props.get("syAP") or None + + sink.stage(operation, "write_env") + env_path.parent.mkdir(parents=True, exist_ok=True) + write_env_file(env_path, values) + return OperationResult(True, { + "config_path": str(env_path), + "host": host, + "configure_id": configure_id, + "ssh_authenticated": True, + "device_syap": observed_syap, + "device_model": observed_model, + "compatibility": _jsonable(compatibility) if compatibility is not None else None, + }) + + +def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: + from timecapsulesmb.cli.flows import wait_for_tcp_port_state + + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + verbose=False, + service_name="SSH port", + ) + + +def _require_supported_payload(target, *, allow_unsupported: bool) -> object: + probe_state = target.probe_state + if probe_state is None: + raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") + compatibility = require_compatibility( + probe_state.compatibility, + fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + if not compatibility.supported and not allow_unsupported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + if not compatibility.payload_family: + raise AppOperationError("No deployable payload is available for this detected device.", code="unsupported_device") + return compatibility + + +def _load_config_and_target( + operation: str, + params: dict[str, object], + sink: EventSink, + *, + profile: str, + include_probe: bool, +) -> tuple[AppConfig, object]: + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile=profile, + include_probe=include_probe, + ) + return config, target + + +def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "deploy" + nbns_enabled = _bool_param(params, "nbns_enabled", True) + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + confirm_deploy = _confirm_param(params, "confirm_deploy") + confirm_reboot = _confirm_param(params, "confirm_reboot") + confirm_netbsd4_activation = _confirm_param(params, "confirm_netbsd4_activation") + mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + allow_unsupported = _bool_param(params, "allow_unsupported") + debug_logging = _bool_param(params, "debug_logging") + + if not dry_run and not confirm_deploy: + raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") + + config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=_config_path(params)) + + sink.stage(operation, "validate_artifacts") + failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] + if failures: + raise AppOperationError("; ".join(failures), code="validation_failed") + + sink.stage(operation, "check_compatibility") + compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) + if not dry_run: + if is_netbsd4 and not confirm_netbsd4_activation: + raise AppOperationError( + "NetBSD 4 deploy requires explicit activation confirmation.", + code="confirmation_required", + ) + if not is_netbsd4 and not no_reboot and not confirm_reboot: + 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, + ) + raise AppOperationError( + f"Deploy requires confirmation to reboot the {device_name}.", + code="confirmation_required", + ) + + if dry_run: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + else: + sink.stage(operation, "read_mast") + mast_discovery = wait_for_mast_volumes_conn( + connection, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + if not mast_discovery.volumes: + raise AppOperationError( + f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " + f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart.", + code="remote_error", + ) + sink.stage(operation, "select_payload_home") + selection = select_payload_home_with_diagnostics_conn( + connection, + mast_discovery.volumes, + MANAGED_PAYLOAD_DIR_NAME, + wait_seconds=mount_wait, + ) + if selection.payload_home is None: + raise AppOperationError( + f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.", + code="remote_error", + ) + payload_home = selection.payload_home + + sink.stage(operation, "build_deployment_plan") + plan = build_deployment_plan( + connection.host, + payload_home, + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + if dry_run: + return OperationResult(True, deployment_plan_to_jsonable(plan)) + + sink.stage(operation, "pre_upload_actions") + run_remote_actions(connection, plan.pre_upload_actions) + sink.stage(operation, "prepare_deployment_files") + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + tmpdir = Path(tmp) + generated_flash_config = tmpdir / "tcapsulesmb.conf" + generated_smbpasswd = tmpdir / "smbpasswd" + generated_username_map = tmpdir / "username.map" + generated_flash_config.write_text(flash_config_text) + smbpasswd_text, username_map_text = render_smbpasswd(connection.password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + upload_sources = { + BINARY_SMBD_SOURCE: plan.smbd_path, + BINARY_MDNS_SOURCE: plan.mdns_path, + BINARY_NBNS_SOURCE: plan.nbns_path, + GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, + GENERATED_USERNAME_MAP_SOURCE: generated_username_map, + GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, + PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), + PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), + PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), + } + sink.stage(operation, "upload_payload") + upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) + + sink.stage(operation, "post_upload_actions") + run_remote_actions(connection, plan.post_upload_actions) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) + sink.stage(operation, "flush_payload_upload") + sink.log(operation, "Flushing deployed payload to disk...") + flush_remote_filesystem_writes(connection) + _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) + + if is_netbsd4: + sink.stage(operation, "netbsd4_activation") + run_remote_actions(connection, plan.activation_actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, { + "payload_dir": plan.payload_dir, + "netbsd4": True, + "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + }) + + if no_reboot: + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) + + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + ) + _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) + + +def _verify_payload_upload( + operation: str, + sink: EventSink, + connection: SshConnection, + payload_home, + *, + wait_seconds: int, + post_sync: bool = False, +) -> None: + sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) + sink.log(operation, verification.detail) + if not verification.ok: + raise AppOperationError( + f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}", + code="remote_error", + ) + + +def _verify_runtime( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, +) -> None: + sink.stage(operation, stage) + verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification( + verification, + heading="Waiting for managed runtime to finish starting...", + ): + sink.log(operation, line) + if not managed_runtime_ready(verification): + raise AppOperationError( + f"Managed runtime did not become ready. {verification.detail.strip()}".strip(), + code="remote_error", + ) + + +def _request_reboot_and_wait( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + reboot_no_down_message: str, + down_timeout_seconds: int = 60, + up_timeout_seconds: int = 240, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + _request_ssh_reboot(operation, sink, connection, shutdown=False) + else: + _request_ssh_reboot(operation, sink, connection, shutdown=True) + + sink.stage(operation, "wait_for_reboot_down") + sink.log(operation, "Waiting for the device to go down...") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + sink.log(operation, "Waiting for the device to come back up...") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + sink.log(operation, "Device is back online.") + + +def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: + try: + if shutdown: + remote_request_shutdown_reboot(connection) + else: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + return + sink.log(operation, "SSH reboot requested.") + + +def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "activate" + confirm_activation = _confirm_param(params, "confirm_netbsd4_activation") + dry_run = _bool_param(params, "dry_run") + _, target = _load_config_and_target(operation, params, sink, 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", + ) + sink.stage(operation, "build_activation_plan") + plan = build_netbsd4_activation_plan() + if dry_run: + return OperationResult(True, _jsonable(plan)) + if not confirm_activation: + raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, {"already_active": True}) + sink.stage(operation, "run_activation") + run_remote_actions(connection, plan.actions) + _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) + + +def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "uninstall" + dry_run = _bool_param(params, "dry_run") + no_reboot = _bool_param(params, "no_reboot") + confirm_uninstall = _confirm_param(params, "confirm_uninstall") + confirm_reboot = _confirm_param(params, "confirm_reboot") + if not dry_run and not confirm_uninstall: + raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") + if not dry_run and not no_reboot and not confirm_reboot: + raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + if dry_run: + volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] + payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] + else: + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_mast_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + volume_roots = [volume.volume_root for volume in mounted_volumes] + payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] + sink.stage(operation, "build_uninstall_plan") + plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) + if dry_run: + return OperationResult(True, uninstall_plan_to_jsonable(plan)) + sink.stage(operation, "uninstall_payload") + remote_uninstall_payload(connection, plan) + if no_reboot: + return OperationResult(True, {"rebooted": False, "verified": False}) + _request_reboot_and_wait( + operation, + sink, + connection, + strategy="acp_then_ssh", + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + ) + sink.stage(operation, "verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + sink.log(operation, line) + if not verification: + raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") + return OperationResult(True, {"rebooted": True, "verified": True}) + + +def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "fsck" + confirm_fsck = _confirm_param(params, "confirm_fsck") + no_reboot = _bool_param(params, "no_reboot") + no_wait = _bool_param(params, "no_wait") + if not confirm_fsck: + raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + sink.stage(operation, "select_fsck_volume") + try: + target = select_fsck_target( + tuple(_target_from_volume(volume) for volume in mounted_volumes), + _string_param(params, "volume") or None, + prompt=False, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + sink.stage(operation, "run_fsck") + script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + ) + if proc.stdout: + for line in proc.stdout.splitlines(): + sink.log(operation, line) + if no_reboot: + return OperationResult(proc.returncode == 0, { + "device": target.device, + "mountpoint": target.mountpoint, + "returncode": proc.returncode, + }) + if no_wait: + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) + _observe_reboot_cycle( + operation, + sink, + connection, + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) + + +def _observe_reboot_cycle( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + reboot_no_down_message: str, + down_timeout_seconds: int, + up_timeout_seconds: int, +) -> None: + sink.stage(operation, "wait_for_reboot_down") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + + +class _RepairContext: + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self.sink.stage(self.operation, stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + dry_run = _bool_param(params, "dry_run") + confirm_repair = _confirm_param(params, "confirm_repair") + if not dry_run and not confirm_repair: + raise AppOperationError( + "repair-xattrs requires dry_run or explicit confirmation.", + code="confirmation_required", + ) + if sys.platform != "darwin": + raise AppOperationError( + "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", + code="validation_failed", + ) + config = load_optional_env_config(env_path=_config_path(params)) + args = argparse.Namespace( + path=Path(str(params["path"])) if params.get("path") else None, + dry_run=dry_run, + yes=confirm_repair, + recursive=_bool_param(params, "recursive", True), + max_depth=params.get("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 args.max_depth is not None: + args.max_depth = int(args.max_depth) + context = _RepairContext(operation, sink) + try: + result = repair_xattrs_cli.run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + raise AppOperationError(message, code="operation_failed") from exc + return OperationResult(result.returncode == 0, { + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "summary": _jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error, + }) + + +def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "doctor" + sink.stage(operation, "load_config") + config = load_env_config(env_path=_config_path(params)) + app_paths = resolve_app_paths(config_path=_config_path(params)) + connection = None + if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + sink.check(operation, status=result.status, message=result.message, details=result.details) + + sink.stage(operation, "run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=_bool_param(params, "skip_ssh"), + skip_bonjour=_bool_param(params, "skip_bonjour"), + skip_smb=_bool_param(params, "skip_smb"), + on_result=on_result, + debug_fields=debug_fields, + ) + payload = { + "fatal": fatal, + "results": [_jsonable(result) for result in results], + "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", + } + if fatal: + payload["error"] = build_doctor_error(results, debug_fields) + return OperationResult(not fatal, payload) + + +OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { + "activate": activate_operation, + "configure": configure_operation, + "deploy": deploy_operation, + "discover": discover_operation, + "doctor": doctor_operation, + "fsck": fsck_operation, + "paths": paths_operation, + "repair-xattrs": repair_xattrs_operation, + "uninstall": uninstall_operation, + "validate-install": validate_install_operation, +} diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index e01d82b0..4c82cb40 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -1,898 +1,69 @@ from __future__ import annotations -import argparse -import io -import shlex -import sys -import tempfile -import uuid -from contextlib import ExitStack, redirect_stdout -from dataclasses import asdict, dataclass, is_dataclass -from pathlib import Path -from typing import Callable +from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact -from timecapsulesmb.checks.doctor import run_doctor_checks -from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli -from timecapsulesmb.cli.deploy import render_flash_runtime_config -from timecapsulesmb.cli.doctor import build_doctor_error -from timecapsulesmb.cli.fsck import ( - FSCK_REBOOT_NO_DOWN_MESSAGE, - FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - build_remote_fsck_script, - select_fsck_target, - _target_from_volume, +from timecapsulesmb.app.operations import ( + OPERATIONS, + AppOperationError, + OperationResult, + activate_operation, + configure_operation, + deploy_operation, + discover_operation, + doctor_operation, + fsck_operation, + paths_operation, + repair_xattrs_operation, + uninstall_operation, + validate_install_operation, ) -from timecapsulesmb.cli.runtime import ( - load_env_config, - load_optional_env_config, - resolve_env_connection, - resolve_validated_managed_target, - ssh_target_link_local_resolution_error, -) -from timecapsulesmb.core.config import ( - DEFAULTS, - MANAGED_PAYLOAD_DIR_NAME, - AppConfig, - airport_family_display_name_from_identity, - parse_bool, - parse_env_file, - write_env_file, -) -from timecapsulesmb.core.errors import system_exit_message -from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP -from timecapsulesmb.core.net import extract_host -from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts -from timecapsulesmb.deploy.artifacts import validate_artifacts -from timecapsulesmb.deploy.auth import render_smbpasswd -from timecapsulesmb.deploy.boot_assets import boot_asset_path -from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable -from timecapsulesmb.deploy.executor import ( - flush_remote_filesystem_writes, - remote_request_reboot, - remote_request_shutdown_reboot, - remote_uninstall_payload, - run_remote_actions, - upload_deployment_payload, -) -from timecapsulesmb.deploy.planner import ( - BINARY_MDNS_SOURCE, - BINARY_NBNS_SOURCE, - BINARY_SMBD_SOURCE, - DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - GENERATED_FLASH_CONFIG_SOURCE, - GENERATED_SMBPASSWD_SOURCE, - GENERATED_USERNAME_MAP_SOURCE, - PACKAGED_COMMON_SH_SOURCE, - PACKAGED_DFREE_SH_SOURCE, - PACKAGED_RC_LOCAL_SOURCE, - PACKAGED_START_SAMBA_SOURCE, - PACKAGED_WATCHDOG_SOURCE, - build_deployment_plan, - build_netbsd4_activation_plan, - build_uninstall_plan, -) -from timecapsulesmb.deploy.verify import ( - managed_runtime_ready, - render_managed_runtime_verification, - render_post_uninstall_verification, - verify_managed_runtime, - verify_post_uninstall, -) -from timecapsulesmb.device.compat import ( - is_netbsd4_payload_family, - payload_family_description, - render_compatibility_message, - require_compatibility, -) -from timecapsulesmb.device.probe import ( - probe_connection_state, - probe_managed_runtime_conn, - wait_for_ssh_state_conn, -) -from timecapsulesmb.device.storage import ( - MAST_DISCOVERY_ATTEMPTS, - MAST_DISCOVERY_DELAY_SECONDS, - UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, - build_dry_run_payload_home, - mounted_mast_volumes_conn, - read_mast_volumes_conn, - select_payload_home_with_diagnostics_conn, - verify_payload_home_conn, - wait_for_mast_volumes_conn, -) -from timecapsulesmb.discovery.bonjour import ( - DEFAULT_BROWSE_TIMEOUT_SEC, - BonjourDiscoverySnapshot, - BonjourResolvedService, - discover_snapshot, - discovered_record_root_host, - discovery_record_to_jsonable, - service_instance_to_jsonable, -) -from timecapsulesmb.install_validation import ( - install_checks_to_jsonable, - install_ok, - paths_to_jsonable, - validate_install, -) -from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot -from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh - - -REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." -DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." -) -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." -) -ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 - - -class AppOperationError(RuntimeError): - def __init__(self, message: str, *, debug: object | None = None) -> None: - super().__init__(message) - self.debug = debug - - -@dataclass(frozen=True) -class OperationResult: - ok: bool - payload: object | None = None - - -def _jsonable(value: object) -> object: - if is_dataclass(value): - return _jsonable(asdict(value)) - if isinstance(value, 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): - return value.strip().lower() in {"1", "true", "yes", "y"} - return bool(value) - - -def _int_param(params: dict[str, object], name: str, default: int) -> int: - value = params.get(name, default) - try: - parsed = int(value) - except (TypeError, ValueError) as exc: - raise AppOperationError(f"{name} must be an integer") from exc - if parsed < 0: - raise AppOperationError(f"{name} must be 0 or greater") - return parsed +from timecapsulesmb.core.config import ConfigError +from timecapsulesmb.transport.errors import TransportError -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 _request_operation(request: dict[str, object]) -> str: + return str(request.get("operation") or "") -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}") - return value - - -def _selected_record_properties(params: dict[str, object]) -> dict[str, str]: - selected = params.get("selected_record") - if not isinstance(selected, dict): +def _request_params(request: dict[str, object]) -> object: + if "params" not in request or request.get("params") is None: 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]: - return { - "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], - "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], - } - - -def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "discover" - timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) - sink.stage(operation, "bonjour_discovery") - snapshot = discover_snapshot(timeout=timeout) - return OperationResult(True, _snapshot_payload(snapshot)) - - -def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "paths" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "summarize_artifacts") - return OperationResult(True, paths_to_jsonable(app_paths)) - - -def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "validate-install" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "validate_install") - checks = validate_install(app_paths) - ok = install_ok(checks) - for check in checks: - sink.check( - operation, - status="PASS" if check.ok else "FAIL", - message=check.message, - details=check.details, - ) - return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) - - -def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "configure" - sink.stage(operation, "load_existing_config") - app_paths = resolve_app_paths(config_path=_config_path(params)) - env_path = app_paths.config_path - existing = parse_env_file(env_path) - configure_id = str(uuid.uuid4()) - ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) - host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") - password = _require_string_param(params, "password") - if not host: - raise AppOperationError("missing required parameter: host") - - resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) - if resolution_error is not None: - raise AppOperationError(resolution_error) - - values = { - "TC_HOST": host, - "TC_PASSWORD": password, - "TC_SSH_OPTS": ssh_opts, - "TC_INTERNAL_SHARE_USE_DISK_ROOT": "true" if _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"])), - ) else "false", - "TC_ANY_PROTOCOL": "true" if _bool_param( - params, - "any_protocol", - parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), - ) else "false", - "TC_CONFIGURE_ID": configure_id, - } - - sink.stage(operation, "ssh_probe") - connection = SshConnection(host, password, ssh_opts) - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_port_reachable: - if not _bool_param(params, "enable_ssh", True): - raise AppOperationError("SSH is not reachable and enable_ssh is false.") - sink.stage(operation, "acp_enable_ssh") - try: - enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) - except ACPAuthError as exc: - raise AppOperationError("The AirPort admin password did not work.", debug=str(exc)) from exc - except ACPError as exc: - raise AppOperationError(f"Failed to enable SSH via ACP: {exc}") from exc - - sink.stage(operation, "wait_for_ssh_after_acp") - if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): - raise AppOperationError("SSH did not open after enabling via ACP.") - sink.stage(operation, "ssh_probe_after_acp") - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_authenticated: - raise AppOperationError(probe.error or "The provided AirPort SSH target and password did not work.") - - compatibility = probed_state.compatibility - if compatibility is not None and not compatibility.supported: - raise AppOperationError(render_compatibility_message(compatibility)) - - selected_props = _selected_record_properties(params) - observed_syap = None if compatibility is None else compatibility.exact_syap - observed_model = None if compatibility is None else compatibility.exact_model - if observed_syap is None: - observed_syap = selected_props.get("syAP") or None - - sink.stage(operation, "write_env") - env_path.parent.mkdir(parents=True, exist_ok=True) - write_env_file(env_path, values) - return OperationResult(True, { - "config_path": str(env_path), - "host": host, - "configure_id": configure_id, - "ssh_authenticated": True, - "device_syap": observed_syap, - "device_model": observed_model, - "compatibility": _jsonable(compatibility) if compatibility is not None else None, - }) - - -def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: - from timecapsulesmb.cli.flows import wait_for_tcp_port_state - - return wait_for_tcp_port_state( - extract_host(host), - 22, - expected_state=True, - timeout_seconds=timeout_seconds, - verbose=False, - service_name="SSH port", - ) - - -def _require_supported_payload(target, *, allow_unsupported: bool) -> object: - probe_state = target.probe_state - if probe_state is None: - raise AppOperationError("Failed to determine remote device OS compatibility.") - compatibility = require_compatibility( - probe_state.compatibility, - fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", - ) - if not compatibility.supported and not allow_unsupported: - raise AppOperationError(render_compatibility_message(compatibility)) - if not compatibility.payload_family: - raise AppOperationError("No deployable payload is available for this detected device.") - return compatibility - - -def _load_config_and_target( - operation: str, - params: dict[str, object], - sink: EventSink, - *, - profile: str, - include_probe: bool, -) -> tuple[AppConfig, object]: - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_managed_target") - target = resolve_validated_managed_target( - config, - command_name=operation, - profile=profile, - include_probe=include_probe, - ) - return config, target - - -def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "deploy" - nbns_enabled = _bool_param(params, "nbns_enabled", True) - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - yes = _bool_param(params, "yes", True) - mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) - allow_unsupported = _bool_param(params, "allow_unsupported") - debug_logging = _bool_param(params, "debug_logging") - - config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) - connection = target.connection - app_paths = resolve_app_paths(config_path=_config_path(params)) - - sink.stage(operation, "validate_artifacts") - failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] - if failures: - raise AppOperationError("; ".join(failures)) - - sink.stage(operation, "check_compatibility") - compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) - payload_family = compatibility.payload_family - is_netbsd4 = is_netbsd4_payload_family(payload_family) - sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") - resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) - - if dry_run: - payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) - else: - sink.stage(operation, "read_mast") - mast_discovery = wait_for_mast_volumes_conn( - connection, - attempts=MAST_DISCOVERY_ATTEMPTS, - delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, - ) - if not mast_discovery.volumes: - raise AppOperationError( - f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " - f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart." - ) - sink.stage(operation, "select_payload_home") - selection = select_payload_home_with_diagnostics_conn( - connection, - mast_discovery.volumes, - MANAGED_PAYLOAD_DIR_NAME, - wait_seconds=mount_wait, - ) - if selection.payload_home is None: - raise AppOperationError(f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.") - payload_home = selection.payload_home - - sink.stage(operation, "build_deployment_plan") - plan = build_deployment_plan( - connection.host, - payload_home, - resolved_artifacts["smbd"].absolute_path, - resolved_artifacts["mdns-advertiser"].absolute_path, - resolved_artifacts["nbns-advertiser"].absolute_path, - activate_netbsd4=is_netbsd4, - reboot_after_deploy=not no_reboot, - apple_mount_wait_seconds=mount_wait, - ) - if dry_run: - return OperationResult(True, deployment_plan_to_jsonable(plan)) - - if is_netbsd4 and not yes: - raise AppOperationError("NetBSD 4 deploy requires explicit confirmation.") - - sink.stage(operation, "pre_upload_actions") - run_remote_actions(connection, plan.pre_upload_actions) - sink.stage(operation, "prepare_deployment_files") - flash_config_text = render_flash_runtime_config( - config, - payload_home, - nbns_enabled=nbns_enabled, - debug_logging=debug_logging, - ) - with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: - tmpdir = Path(tmp) - generated_flash_config = tmpdir / "tcapsulesmb.conf" - generated_smbpasswd = tmpdir / "smbpasswd" - generated_username_map = tmpdir / "username.map" - generated_flash_config.write_text(flash_config_text) - smbpasswd_text, username_map_text = render_smbpasswd(connection.password) - generated_smbpasswd.write_text(smbpasswd_text) - generated_username_map.write_text(username_map_text) - upload_sources = { - BINARY_SMBD_SOURCE: plan.smbd_path, - BINARY_MDNS_SOURCE: plan.mdns_path, - BINARY_NBNS_SOURCE: plan.nbns_path, - GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, - GENERATED_USERNAME_MAP_SOURCE: generated_username_map, - GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, - PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), - PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), - PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), - PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), - PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), - } - sink.stage(operation, "upload_payload") - upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) - - sink.stage(operation, "post_upload_actions") - run_remote_actions(connection, plan.post_upload_actions) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) - sink.stage(operation, "flush_payload_upload") - sink.log(operation, "Flushing deployed payload to disk...") - flush_remote_filesystem_writes(connection) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) - - if is_netbsd4: - sink.stage(operation, "netbsd4_activation") - run_remote_actions(connection, plan.activation_actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, { - "payload_dir": plan.payload_dir, - "netbsd4": True, - "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", - }) - - if no_reboot: - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) - if not yes: - 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, - ) - raise AppOperationError(f"Deploy requires confirmation to reboot the {device_name}.") - - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="ssh_shutdown_then_reboot", - reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, - ) - _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) - - -def _verify_payload_upload( - operation: str, - sink: EventSink, - connection: SshConnection, - payload_home, - *, - wait_seconds: int, - post_sync: bool = False, -) -> None: - sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") - verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) - sink.log(operation, verification.detail) - if not verification.ok: - raise AppOperationError(f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}") - - -def _verify_runtime( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - stage: str, - timeout_seconds: int, -) -> None: - sink.stage(operation, stage) - verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) - for line in render_managed_runtime_verification( - verification, - heading="Waiting for managed runtime to finish starting...", - ): - sink.log(operation, line) - if not managed_runtime_ready(verification): - raise AppOperationError(f"Managed runtime did not become ready. {verification.detail.strip()}".strip()) - - -def _request_reboot_and_wait( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - strategy: str, - reboot_no_down_message: str, - down_timeout_seconds: int = 60, - up_timeout_seconds: int = 240, -) -> None: - sink.stage(operation, "reboot") - if strategy == "acp_then_ssh": - try: - acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) - sink.log(operation, "ACP reboot requested.") - except ACPError as exc: - sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") - _request_ssh_reboot(operation, sink, connection, shutdown=False) - else: - _request_ssh_reboot(operation, sink, connection, shutdown=True) - - sink.stage(operation, "wait_for_reboot_down") - sink.log(operation, "Waiting for the device to go down...") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message) - sink.stage(operation, "wait_for_reboot_up") - sink.log(operation, "Waiting for the device to come back up...") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) - sink.log(operation, "Device is back online.") - - -def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: - try: - if shutdown: - remote_request_shutdown_reboot(connection) - else: - remote_request_reboot(connection) - except SshCommandTimeout as exc: - sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") - return - except SshError as exc: - sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") - return - sink.log(operation, "SSH reboot requested.") - - -def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "activate" - yes = _bool_param(params, "yes", True) - dry_run = _bool_param(params, "dry_run") - _, target = _load_config_and_target(operation, params, sink, 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.") - sink.stage(operation, "build_activation_plan") - plan = build_netbsd4_activation_plan() - if dry_run: - return OperationResult(True, _jsonable(plan)) - if not yes: - raise AppOperationError("NetBSD4 activation requires explicit confirmation.") - connection = target.connection - sink.stage(operation, "probe_runtime") - if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: - return OperationResult(True, {"already_active": True}) - sink.stage(operation, "run_activation") - run_remote_actions(connection, plan.actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) - - -def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "uninstall" - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - yes = _bool_param(params, "yes", True) - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - if dry_run: - volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] - payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] - else: - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_mast_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - volume_roots = [volume.volume_root for volume in mounted_volumes] - payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] - sink.stage(operation, "build_uninstall_plan") - plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) - if dry_run: - return OperationResult(True, uninstall_plan_to_jsonable(plan)) - sink.stage(operation, "uninstall_payload") - remote_uninstall_payload(connection, plan) - if no_reboot: - return OperationResult(True, {"rebooted": False, "verified": False}) - if not yes: - raise AppOperationError("Uninstall requires explicit confirmation to reboot.") - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="acp_then_ssh", - reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, - ) - sink.stage(operation, "verify_post_uninstall") - verification = verify_post_uninstall(connection, plan) - for line in render_post_uninstall_verification(verification): - sink.log(operation, line) - if not verification: - raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.") - return OperationResult(True, {"rebooted": True, "verified": True}) - - -def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "fsck" - yes = _bool_param(params, "yes", True) - no_reboot = _bool_param(params, "no_reboot") - no_wait = _bool_param(params, "no_wait") - if not yes: - raise AppOperationError("fsck requires explicit confirmation.") - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_hfs_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - sink.stage(operation, "select_fsck_volume") - try: - target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), - _string_param(params, "volume") or None, - prompt=False, - ) - except RuntimeError as exc: - raise AppOperationError(str(exc)) from exc - sink.stage(operation, "run_fsck") - script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) - proc = run_ssh( - connection, - f"/bin/sh -c {shlex.quote(script)}", - check=False, - timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - ) - if proc.stdout: - for line in proc.stdout.splitlines(): - sink.log(operation, line) - if no_reboot: - return OperationResult(proc.returncode == 0, { - "device": target.device, - "mountpoint": target.mountpoint, - "returncode": proc.returncode, - }) - if no_wait: - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) - _observe_reboot_cycle( - operation, - sink, - connection, - reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, - down_timeout_seconds=90, - up_timeout_seconds=420, - ) - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) - - -def _observe_reboot_cycle( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - reboot_no_down_message: str, - down_timeout_seconds: int, - up_timeout_seconds: int, -) -> None: - sink.stage(operation, "wait_for_reboot_down") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message) - sink.stage(operation, "wait_for_reboot_up") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE) - - -class _RepairContext: - def __init__(self, operation: str, sink: EventSink) -> None: - self.operation = operation - self.sink = sink - self.result = "failure" - self.error: str | None = None - - def set_stage(self, stage: str) -> None: - self.sink.stage(self.operation, stage) - - def update_fields(self, **_fields: object) -> None: - pass - - def succeed(self) -> None: - self.result = "success" - - def fail_with_error(self, message: str) -> None: - self.result = "failure" - self.error = message - - -def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "repair-xattrs" - dry_run = _bool_param(params, "dry_run") - yes = _bool_param(params, "yes") - if not dry_run and not yes: - raise AppOperationError("repair-xattrs requires dry_run or explicit confirmation.") - if sys.platform != "darwin": - raise AppOperationError("repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.") - config = load_optional_env_config(env_path=_config_path(params)) - args = argparse.Namespace( - path=Path(str(params["path"])) if params.get("path") else None, - dry_run=dry_run, - yes=yes, - recursive=_bool_param(params, "recursive", True), - max_depth=params.get("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 args.max_depth is not None: - args.max_depth = int(args.max_depth) - context = _RepairContext(operation, sink) - output = io.StringIO() - with redirect_stdout(output): - try: - rc = repair_xattrs_cli.run_repair(args, context, config) - except SystemExit as exc: - message = system_exit_message(exc) or "repair-xattrs failed" - raise AppOperationError(message) from exc - for line in output.getvalue().splitlines(): - sink.log(operation, line) - return OperationResult(rc == 0, {"returncode": rc, "telemetry_result": context.result, "error": context.error}) - - -def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "doctor" - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - app_paths = resolve_app_paths(config_path=_config_path(params)) - connection = None - if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - debug_fields: dict[str, object] = {} - - def on_result(result: CheckResult) -> None: - sink.check(operation, status=result.status, message=result.message, details=result.details) - - sink.stage(operation, "run_checks") - results, fatal = run_doctor_checks( - config, - repo_root=app_paths.distribution_root, - connection=connection, - skip_ssh=_bool_param(params, "skip_ssh"), - skip_bonjour=_bool_param(params, "skip_bonjour"), - skip_smb=_bool_param(params, "skip_smb"), - on_result=on_result, - debug_fields=debug_fields, - ) - payload = { - "fatal": fatal, - "results": [_jsonable(result) for result in results], - "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", - } - if fatal: - payload["error"] = build_doctor_error(results, debug_fields) - return OperationResult(not fatal, payload) - - -OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { - "activate": activate_operation, - "configure": configure_operation, - "deploy": deploy_operation, - "discover": discover_operation, - "doctor": doctor_operation, - "fsck": fsck_operation, - "paths": paths_operation, - "repair-xattrs": repair_xattrs_operation, - "uninstall": uninstall_operation, - "validate-install": validate_install_operation, -} + return request.get("params") def run_api_request(request: dict[str, object], sink: EventSink) -> int: - operation = str(request.get("operation") or "") - params = request.get("params") or {} + request_id = request.get("request_id") + if request_id is not None and str(request_id).strip(): + sink = sink.with_request_id(str(request_id)) + + operation = _request_operation(request) + params = _request_params(request) if not operation: - sink.error("api", "missing required field: operation") + sink.error("api", "missing required field: operation", code="invalid_request") return 1 if not isinstance(params, dict): - sink.error(operation, "params must be a JSON object") + sink.error(operation, "params must be a JSON object", code="invalid_request") return 1 - handler = OPERATIONS.get(operation) + handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) if handler is None: - sink.error(operation, f"unknown operation: {operation}", debug={"known_operations": sorted(OPERATIONS)}) + sink.error(operation, f"unknown operation: {operation}", code="unknown_operation", debug={"known_operations": sorted(OPERATIONS)}) return 1 try: result = handler(params, sink) except AppOperationError as exc: - sink.error(operation, str(exc), debug=redact(exc.debug) if exc.debug is not None else None) + sink.error(operation, str(exc), code=exc.code, debug=redact(exc.debug) if exc.debug is not None else None) + return 1 + except ConfigError as exc: + sink.error(operation, str(exc), code="config_error") + return 1 + except TransportError as exc: + sink.error(operation, str(exc), code="remote_error") return 1 except (SystemExit, KeyboardInterrupt): raise except Exception as exc: - sink.error(operation, f"{type(exc).__name__}: {exc}") + sink.error(operation, f"{type(exc).__name__}: {exc}", code="operation_failed") return 1 sink.result(operation, ok=result.ok, payload=result.payload) return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index bb00930a..2b60abd5 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -2,8 +2,9 @@ import argparse import sys +from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Callable, Optional from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.runtime import add_config_argument, confirm as confirm_prompt, load_optional_env_config @@ -44,15 +45,33 @@ from timecapsulesmb.telemetry import TelemetryClient -def print_candidates(candidates: list[RepairCandidate], *, dry_run: bool) -> None: +@dataclass(frozen=True) +class RepairRunResult: + returncode: int + root: Path + findings: list[RepairFinding] + candidates: list[RepairCandidate] + summary: RepairSummary + report: str | None = None + + +def render_candidate_lines(candidates: list[RepairCandidate], *, dry_run: bool) -> list[str]: verb = "Would repair" if dry_run else "Repairable" + lines: list[str] = [] for candidate in candidates: actions = ", ".join(candidate.actions) or "none" flags = f", flags: {candidate.flags}" if candidate.flags else "" - print(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + lines.append(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + return lines -def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: +def print_candidates(candidates: list[RepairCandidate], *, dry_run: bool) -> None: + for line in render_candidate_lines(candidates, dry_run=dry_run): + print(line) + + +def render_diagnostic_lines(findings: list[RepairFinding], *, verbose: bool) -> list[str]: + lines: list[str] = [] for finding in findings: if finding.repairable: continue @@ -62,30 +81,61 @@ def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: detail += f" flags={finding.flags}" if finding.xattr_error: detail += f" xattr_error={finding.xattr_error}" - print(f"WARN {detail}") + lines.append(f"WARN {detail}") + return lines -def print_summary(summary: RepairSummary, *, dry_run: bool) -> None: - print("") - print("Summary:") - print(f" scanned paths: {summary.scanned}") - print(f" scanned files: {summary.scanned_files}") - print(f" scanned directories: {summary.scanned_dirs}") - print(f" skipped: {summary.skipped}") - print(f" unreadable xattrs: {summary.unreadable}") - print(f" not repairable: {summary.not_repairable}") - print(f" repairable: {summary.repairable}") - print(f" permission repairs: {summary.permission_repairable}") +def print_diagnostics(findings: list[RepairFinding], *, verbose: bool) -> None: + for line in render_diagnostic_lines(findings, verbose=verbose): + print(line) + + +def render_summary_lines(summary: RepairSummary, *, dry_run: bool) -> list[str]: + lines = [ + "", + "Summary:", + f" scanned paths: {summary.scanned}", + f" scanned files: {summary.scanned_files}", + f" scanned directories: {summary.scanned_dirs}", + f" skipped: {summary.skipped}", + f" unreadable xattrs: {summary.unreadable}", + f" not repairable: {summary.not_repairable}", + f" repairable: {summary.repairable}", + f" permission repairs: {summary.permission_repairable}", + ] if not dry_run: - print(f" repaired: {summary.repaired}") - print(f" failed: {summary.failed}") + lines.extend([ + f" repaired: {summary.repaired}", + f" failed: {summary.failed}", + ]) + return lines + + +def print_summary(summary: RepairSummary, *, dry_run: bool) -> None: + for line in render_summary_lines(summary, dry_run=dry_run): + print(line) def confirm(prompt_text: str) -> bool: return confirm_prompt(prompt_text, default=False, eof_default=False, interrupt_default=False) -def run_repair(args: argparse.Namespace, command_context: CommandContext, config: AppConfig) -> int: +def _emit_lines(emit: Callable[[str], None], lines: list[str]) -> None: + for line in lines: + emit(line) + + +def run_repair_structured( + args: argparse.Namespace, + command_context: CommandContext, + config: AppConfig, + *, + emit_log: Callable[[str], None] | None = None, +) -> RepairRunResult: + def emit(message: str) -> None: + if emit_log is not None: + emit_log(message) + command_context.set_stage("resolve_scan_root") command_context.update_fields( dry_run=args.dry_run, @@ -117,7 +167,7 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config summary = RepairSummary() command_context.update_fields(repair_root=str(root)) command_context.set_stage("scan_findings") - print(f"Scanning {root}") + emit(f"Scanning {root}") try: findings = find_findings( root, @@ -146,61 +196,69 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config ) if not findings: - print("No repairable files found.") - print_summary(summary, dry_run=True) + emit("No repairable files found.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) command_context.succeed() - return 0 + return RepairRunResult(0, root, findings, candidates, summary) command_context.set_stage("report_findings") - print_diagnostics(findings, verbose=args.verbose) + _emit_lines(emit, render_diagnostic_lines(findings, verbose=args.verbose)) if candidates: - print_candidates(candidates, dry_run=args.dry_run) + _emit_lines(emit, render_candidate_lines(candidates, dry_run=args.dry_run)) if args.dry_run: - print_summary(summary, dry_run=True) - print("No changes made.") - command_context.fail_with_error(build_repair_report(findings)) - return 0 + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + emit("No changes made.") + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) if not candidates: - print("No known-safe repairs are available for the detected issues.") - print_summary(summary, dry_run=True) - command_context.fail_with_error(build_repair_report(findings)) - return 1 + emit("No known-safe repairs are available for the detected issues.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) command_context.set_stage("confirm_repair") if not args.yes and not confirm(f"Repair {len(candidates)} paths with known-safe fixes?"): - print("No changes made.") - print_summary(summary, dry_run=True) - command_context.fail_with_error(build_repair_report(findings)) - return 0 + emit("No changes made.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) command_context.set_stage("repair_findings") failed_findings: list[RepairFinding] = [] for finding, candidate in zip(repairs, candidates): - print(f"Repairing: {candidate.path}") + emit(f"Repairing: {candidate.path}") if repair_candidate(candidate): summary.repaired += 1 if ACTION_CLEAR_ARCH_FLAG in candidate.actions: - print(f"PASS xattr now readable: {candidate.path}") + emit(f"PASS xattr now readable: {candidate.path}") if ACTION_FIX_PERMISSIONS in candidate.actions: - print(f"PASS permissions repaired: {candidate.path}") + emit(f"PASS permissions repaired: {candidate.path}") else: summary.failed += 1 failed_findings.append(finding) if ACTION_CLEAR_ARCH_FLAG in candidate.actions: - print(f"FAIL repair did not make xattr readable: {candidate.path}") + emit(f"FAIL repair did not make xattr readable: {candidate.path}") else: - print(f"FAIL repair did not fix detected issue: {candidate.path}") + emit(f"FAIL repair did not fix detected issue: {candidate.path}") unresolved = unresolved_findings_after_success(findings) + failed_findings command_context.update_fields(repaired_count=summary.repaired, repair_failed_count=summary.failed) - print_summary(summary, dry_run=False) + _emit_lines(emit, render_summary_lines(summary, dry_run=False)) if unresolved: - command_context.fail_with_error(build_repair_report(findings, failed=unresolved)) - return 1 + report = build_repair_report(findings, failed=unresolved) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) command_context.succeed() - return 0 + return RepairRunResult(0, root, findings, candidates, summary) + + +def run_repair(args: argparse.Namespace, command_context: CommandContext, config: AppConfig) -> int: + return run_repair_structured(args, command_context, config, emit_log=print).returncode def main(argv: Optional[list[str]] = None) -> int: 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/app.py b/src/timecapsulesmb/services/app.py new file mode 100644 index 00000000..1468a5ec --- /dev/null +++ b/src/timecapsulesmb/services/app.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, is_dataclass +from pathlib import Path + + +class AppOperationError(RuntimeError): + def __init__( + self, + message: str, + *, + code: str = "operation_failed", + debug: object | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.debug = debug + + +@dataclass(frozen=True) +class OperationResult: + ok: bool + payload: object | None = None + + +def jsonable(value: object) -> object: + if is_dataclass(value): + return jsonable(asdict(value)) + if isinstance(value, 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): + return value.strip().lower() in {"1", "true", "yes", "y"} + return bool(value) + + +def confirm_param(params: dict[str, object], name: str) -> bool: + if name in params: + return bool_param(params, name) + return bool_param(params, "yes") + + +def int_param(params: dict[str, object], name: str, default: int) -> int: + value = params.get(name, default) + 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 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 diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 975d2de1..c8db3d84 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -17,13 +17,16 @@ sys.path.insert(0, str(SRC_ROOT)) from timecapsulesmb.app.events import AppEvent, EventSink -from timecapsulesmb.app import helper, service +from timecapsulesmb import repair_xattrs as repair_xattrs_domain +from timecapsulesmb.app import helper, operations, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.core.config import AppConfig, ConfigError from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance +from timecapsulesmb.integrations.acp import ACPAuthError +from timecapsulesmb.transport.errors import TransportError from timecapsulesmb.transport.ssh import SshConnection @@ -51,6 +54,21 @@ def supported_compatibility(payload_family: str = "netbsd6_samba4") -> DeviceCom ) +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( @@ -68,7 +86,44 @@ def probed_state() -> ProbedDeviceState: ) +def netbsd4_probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=True, + ssh_authenticated=True, + error=None, + os_name="NetBSD", + os_release="4.0", + arch="powerpc", + elf_endianness="big", + airport_model="TimeCapsule6,116", + airport_syap="116", + ), + compatibility=supported_compatibility("netbsd4be_samba4"), + ) + + +def unreachable_probed_state() -> ProbedDeviceState: + return ProbedDeviceState( + probe_result=ProbeResult( + ssh_port_reachable=False, + ssh_authenticated=False, + error="connection refused", + os_name="", + os_release="", + arch="", + elf_endianness="", + ), + compatibility=None, + ) + + class AppApiTests(unittest.TestCase): + def assert_single_terminal_event(self, collector: CollectingSink, event_type: str) -> dict[str, object]: + terminals = collector.events_of_type("result") + collector.events_of_type("error") + self.assertEqual([event["type"] for event in terminals], [event_type]) + return terminals[0] + def test_event_redacts_password_fields(self) -> None: event = AppEvent("result", "configure", { "ok": True, @@ -90,6 +145,37 @@ def test_result_event_preserves_falsey_payloads(self) -> None: result = collector.events_of_type("result")[0] self.assertEqual(result["payload"], []) + self.assertEqual(result["schema_version"], 1) + self.assertTrue(result["request_id"]) + + def test_request_id_propagates_to_every_event(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"request_id": "req-123", "operation": "paths", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + self.assertTrue(collector.events) + self.assertEqual({event["request_id"] for event in collector.events}, {"req-123"}) + self.assert_single_terminal_event(collector, "result") + + def test_missing_params_defaults_to_empty_object(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "paths"}, collector.sink) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertEqual(result["operation"], "paths") + + def test_missing_operation_emits_invalid_request_error(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["operation"], "api") + self.assertEqual(error["code"], "invalid_request") def test_unknown_operation_emits_error_without_result(self) -> None: collector = CollectingSink() @@ -97,8 +183,37 @@ def test_unknown_operation_emits_error_without_result(self) -> None: rc = service.run_api_request({"operation": "nope", "params": {}}, collector.sink) self.assertEqual(rc, 1) - self.assertEqual(len(collector.events_of_type("error")), 1) - self.assertEqual(collector.events_of_type("result"), []) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "unknown_operation") + + def test_non_object_params_emits_invalid_request_error(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "paths", "params": []}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "invalid_request") + + def test_dispatcher_maps_recoverable_and_unexpected_error_states(self) -> None: + cases = ( + ("config-error", ConfigError("bad config"), "config_error"), + ("transport-error", TransportError("remote failed"), "remote_error"), + ("unexpected-error", RuntimeError("boom"), "operation_failed"), + ) + for operation, exception, code in cases: + with self.subTest(code=code): + collector = CollectingSink() + + def fail(_params, _sink, exc=exception): + raise exc + + with mock.patch.dict(service.OPERATIONS, {operation: fail}): + rc = service.run_api_request({"operation": operation, "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], code) def test_discover_operation_returns_snapshot_payload(self) -> None: collector = CollectingSink() @@ -116,7 +231,7 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: ], ) - with mock.patch("timecapsulesmb.app.service.discover_snapshot", return_value=snapshot): + with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot): rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) self.assertEqual(rc, 0) @@ -128,7 +243,7 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.service.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -147,6 +262,54 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_events) + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.operations.enable_ssh", side_effect=ACPAuthError("bad password")): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "badpw", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertFalse(config_path.exists()) + self.assertEqual(collector.events_of_type("error")[0]["code"], "auth_failed") + self.assertNotIn("badpw", json.dumps(collector.events)) + + def test_configure_reports_unsupported_device(self) -> None: + collector = CollectingSink() + unsupported_state = ProbedDeviceState( + probe_result=probed_state().probe_result, + compatibility=unsupported_compatibility(), + ) + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.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_doctor_streams_check_events(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -155,10 +318,10 @@ 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.service.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.service.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.service.run_doctor_checks", side_effect=fake_run_doctor_checks): + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) self.assertEqual(rc, 0) @@ -167,6 +330,27 @@ def fake_run_doctor_checks(*_args, **kwargs): self.assertEqual(checks[0]["status"], "PASS") self.assertEqual(checks[0]["details"], {"port": 445}) + 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.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): + rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error"), []) + result = collector.events_of_type("result")[0] + self.assertEqual(result["ok"], False) + self.assertTrue(result["payload"]["fatal"]) + self.assertNotIn("pw", json.dumps(collector.events)) + def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -177,12 +361,12 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.service.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.service.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.service.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.service.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.service.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.service.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": True, "yes": True}}, collector.sink, @@ -193,6 +377,283 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> self.assertEqual(result["payload"]["host"], "root@10.0.0.2") self.assertEqual(result["payload"]["reboot_required"], True) + 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.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + remote_actions.assert_not_called() + + def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4-netbsd4be/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns-netbsd4be/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + read_mast.assert_not_called() + remote_actions.assert_not_called() + + def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + load_config.assert_not_called() + + def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): + with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "no_reboot": True, + "confirm_deploy": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + upload.assert_called_once() + wait.assert_not_called() + self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) + + def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "remote_error") + + def test_activate_requires_explicit_confirmation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace( + connection=connection, + probe_state=ProbedDeviceState( + probe_result=probed_state().probe_result, + compatibility=supported_compatibility("netbsd4le_samba4"), + ), + ) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request({"operation": "activate", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + remote_actions.assert_not_called() + + def test_activate_accepts_yes_alias_for_confirmation(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + rc = service.run_api_request( + {"operation": "activate", "params": {"yes": True}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + result = self.assert_single_terminal_event(collector, "result") + self.assertEqual(result["payload"], {"already_active": True}) + 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.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: + with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload") as uninstall: + rc = service.run_api_request({"operation": "uninstall", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_connection.assert_not_called() + uninstall.assert_not_called() + + def test_uninstall_requires_reboot_confirmation_before_remote_connection(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + rc = service.run_api_request( + {"operation": "uninstall", "params": {"confirm_uninstall": True}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + load_config.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.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.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"]) + uninstall.assert_not_called() + + 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.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: + rc = service.run_api_request({"operation": "fsck", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_connection.assert_not_called() + + def test_repair_xattrs_uses_structured_runner(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1, scanned_files=1, unreadable=1, repairable=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[SimpleNamespace(path=Path("/Volumes/Data/broken"))], + candidates=[SimpleNamespace(path=Path("/Volumes/Data/broken"))], + summary=summary, + report="detected issues", + ) + + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + runner.assert_called_once() + self.assertEqual(collector.events_of_type("result")[0]["payload"]["finding_count"], 1) + + def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "confirmation_required") + runner.assert_not_called() + def test_helper_reads_request_and_writes_ndjson(self) -> None: output = io.StringIO() fake_stdin = io.StringIO('{"operation":"paths","params":{}}') @@ -206,6 +667,36 @@ def test_helper_reads_request_and_writes_ndjson(self) -> None: line = json.loads(output.getvalue()) self.assertEqual(line["type"], "result") self.assertEqual(line["operation"], "paths") + self.assertEqual(line["schema_version"], 1) + self.assertTrue(line["request_id"]) + + def test_helper_rejects_invalid_json_without_leaking_pretty_error_details(self) -> None: + output = io.StringIO() + error_output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO('{"operation":"paths","password":"secret"')): + with redirect_stdout(output): + with mock.patch.object(sys, "stderr", error_output): + rc = helper.main(["--pretty-error"]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["code"], "invalid_request") + self.assertNotIn("secret", error_output.getvalue()) + + def test_helper_rejects_top_level_non_object_json(self) -> None: + output = io.StringIO() + with mock.patch.object(sys, "stdin", io.StringIO('["paths"]')): + with redirect_stdout(output): + rc = helper.main([]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["operation"], "api") + self.assertEqual(event["code"], "invalid_request") + self.assertEqual(event["schema_version"], 1) + self.assertTrue(event["request_id"]) def test_api_command_is_registered(self) -> None: self.assertIs(cli_main.COMMANDS["api"], helper.main) From 308b78bce6c40aa268d5f1788d5bed0f367b34c5 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 22:05:07 -0700 Subject: [PATCH 03/20] Improve app API diagnostics, env preservation, and helper robustness --- .../HelperRunnerTests.swift | 27 ++++++ src/timecapsulesmb/app/operations.py | 51 ++++++++++-- src/timecapsulesmb/app/service.py | 8 +- src/timecapsulesmb/core/config.py | 23 +++++ tests/test_app_api.py | 83 ++++++++++++++++++- tests/test_config.py | 36 ++++++++ 6 files changed, 217 insertions(+), 11 deletions(-) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index 31ca3583..3495cbf8 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -50,6 +50,33 @@ final class HelperRunnerTests: XCTestCase { 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: [:]) { + recorder.append($0) + } + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr.count, 64 * 1024) + XCTAssertEqual(recorder.events.last?.type, "result") + XCTAssertEqual(recorder.events.last?.ok, true) + } + func testRunnerReportsMissingHelper() async { let locator = HelperLocator(environment: [:], currentDirectory: URL(fileURLWithPath: NSTemporaryDirectory()), bundle: .main, fileManager: .default) let runner = HelperRunner(locator: locator) diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py index 4d658550..bf6b854b 100644 --- a/src/timecapsulesmb/app/operations.py +++ b/src/timecapsulesmb/app/operations.py @@ -6,7 +6,7 @@ import tempfile import uuid from collections.abc import Callable -from contextlib import ExitStack +from contextlib import ExitStack, redirect_stderr, redirect_stdout from pathlib import Path from timecapsulesmb.app.events import EventSink @@ -36,6 +36,7 @@ airport_family_display_name_from_identity, parse_bool, parse_env_file, + preserved_env_file_values, write_env_file, ) from timecapsulesmb.core.errors import system_exit_message @@ -227,7 +228,8 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation if resolution_error is not None: raise AppOperationError(resolution_error, code="config_error") - values = { + values = preserved_env_file_values(existing) + values.update({ "TC_HOST": host, "TC_PASSWORD": password, "TC_SSH_OPTS": ssh_opts, @@ -242,7 +244,7 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), ) else "false", "TC_CONFIGURE_ID": configure_id, - } + }) sink.stage(operation, "ssh_probe") connection = SshConnection(host, password, ssh_opts) @@ -763,6 +765,31 @@ def fail_with_error(self, message: str) -> None: self.error = message +class _StreamLogCapture: + def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: + self.operation = operation + self.sink = sink + self.level = level + self._buffer = "" + + def write(self, text: str) -> int: + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def _emit(self, line: str) -> None: + message = line.rstrip("\r") + if message: + self.sink.log(self.operation, message, level=self.level) + + def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" dry_run = _bool_param(params, "dry_run") @@ -792,16 +819,22 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera if args.max_depth is not None: args.max_depth = int(args.max_depth) context = _RepairContext(operation, sink) + stdout_capture = _StreamLogCapture(operation, sink, level="info") + stderr_capture = _StreamLogCapture(operation, sink, level="warning") try: - result = repair_xattrs_cli.run_repair_structured( - args, - context, - config, - emit_log=lambda message: sink.log(operation, message), - ) + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = repair_xattrs_cli.run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) except SystemExit as exc: message = system_exit_message(exc) or "repair-xattrs failed" raise AppOperationError(message, code="operation_failed") from exc + finally: + stdout_capture.flush() + stderr_capture.flush() return OperationResult(result.returncode == 0, { "returncode": result.returncode, "root": str(result.root), diff --git a/src/timecapsulesmb/app/service.py b/src/timecapsulesmb/app/service.py index 4c82cb40..d434ab14 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import traceback from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact @@ -63,7 +64,12 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: except (SystemExit, KeyboardInterrupt): raise except Exception as exc: - sink.error(operation, f"{type(exc).__name__}: {exc}", code="operation_failed") + sink.error( + operation, + f"{type(exc).__name__}: {exc}", + code="operation_failed", + debug={"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))}, + ) return 1 sink.result(operation, ok=result.ok, payload=result.payload) return 0 if result.ok else 1 diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 1d23bda3..8f3d612b 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -85,6 +85,19 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_ANY_PROTOCOL", "TC_CONFIGURE_ID", ] +ENV_FILE_OMIT_KEYS = frozenset({ + # Runtime-derived/deprecated naming keys may still exist in older .env + # files, but new configure writes should not keep them alive. + "TC_AIRPORT_SYAP", + "TC_MDNS_DEVICE_MODEL", + "TC_MDNS_HOST_LABEL", + "TC_MDNS_INSTANCE_NAME", + "TC_NETBIOS_NAME", + "TC_NET_IFACE", + "NET_IPV4_HINT", + "TC_SAMBA_USER", + "TC_PAYLOAD_DIR_NAME", +}) CONFIG_HEADER = """# Local user/device configuration for TimeCapsuleSMB. # Generated by tcapsule configure @@ -640,9 +653,19 @@ def render_env_text(values: dict[str, str]) -> str: for key in ENV_FILE_KEYS: rendered_value = values.get(key, DEFAULTS.get(key, "")) lines.append(f"{key}={shell_quote(rendered_value)}") + extra_keys = sorted(key for key in values if key not in ENV_FILE_KEYS and key not in ENV_FILE_OMIT_KEYS) + if extra_keys: + lines.append("") + lines.append("# Preserved custom settings") + for key in extra_keys: + lines.append(f"{key}={shell_quote(values[key])}") lines.append("") return "\n".join(lines) +def preserved_env_file_values(values: dict[str, str]) -> dict[str, str]: + return {key: value for key, value in values.items() if key not in ENV_FILE_OMIT_KEYS} + + def write_env_file(path: Path, values: dict[str, str]) -> None: path.write_text(render_env_text(values)) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index c8db3d84..5c754e10 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -21,7 +21,7 @@ from timecapsulesmb.app import helper, operations, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.core.config import AppConfig, ConfigError +from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance @@ -215,6 +215,21 @@ def fail(_params, _sink, exc=exception): error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], code) + def test_dispatcher_includes_traceback_for_unexpected_errors(self) -> None: + collector = CollectingSink() + + def fail(_params, _sink): + raise RuntimeError("boom") + + with mock.patch.dict(service.OPERATIONS, {"boom": fail}): + rc = service.run_api_request({"operation": "boom", "params": {}}, collector.sink) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "operation_failed") + self.assertIn("Traceback", error["debug"]["traceback"]) + self.assertIn("RuntimeError: boom", error["debug"]["traceback"]) + def test_discover_operation_returns_snapshot_payload(self) -> None: collector = CollectingSink() snapshot = BonjourDiscoverySnapshot( @@ -262,6 +277,39 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_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_SAMBA_USER=old-admin\n" + "TC_PAYLOAD_DIR_NAME=old-payload\n" + ) + with mock.patch("timecapsulesmb.app.operations.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"], "newpw") + self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") + self.assertNotIn("TC_SAMBA_USER", values) + self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: @@ -637,6 +685,39 @@ def test_repair_xattrs_uses_structured_runner(self) -> None: runner.assert_called_once() self.assertEqual(collector.events_of_type("result")[0]["payload"]["finding_count"], 1) + def test_repair_xattrs_captures_direct_stdout_and_stderr_logs(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[], + candidates=[], + summary=summary, + report=None, + ) + + def fake_runner(*_args, **_kwargs): + print("stdout detail") + print("stderr detail", file=sys.stderr) + return repair_result + + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", side_effect=fake_runner): + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": True}, + }, + collector.sink, + ) + + logs = collector.events_of_type("log") + self.assertEqual(rc, 0) + self.assertIn({"info": "stdout detail"}, [{log["level"]: log["message"]} for log in logs]) + self.assertIn({"warning": "stderr detail"}, [{log["level"]: log["message"]} for log in logs]) + def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() diff --git a/tests/test_config.py b/tests/test_config.py index 1a20280d..74c6c9e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,6 +25,7 @@ parse_bool, parse_env_file, parse_env_value, + preserved_env_file_values, require_valid_app_config, render_env_text, validate_app_config, @@ -148,6 +149,41 @@ def test_render_env_text_contains_config_keys(self) -> None: self.assertIn("TC_ANY_PROTOCOL=false", rendered) self.assertIn("TC_CONFIGURE_ID=12345678-1234-1234-1234-123456789012", rendered) + def test_render_env_text_preserves_custom_settings_but_omits_deprecated_keys(self) -> None: + values = dict(DEFAULTS) + values.update({ + "TC_PASSWORD": "secret", + "TC_CUSTOM_SETTING": "kept value", + "CUSTOM_FLAG": "", + "TC_SAMBA_USER": "admin", + "TC_PAYLOAD_DIR_NAME": "samba4", + "TC_MDNS_INSTANCE_NAME": "old-name", + }) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + path.write_text(render_env_text(values)) + reparsed = parse_env_file(path) + + self.assertEqual(reparsed["TC_CUSTOM_SETTING"], "kept value") + self.assertEqual(reparsed["CUSTOM_FLAG"], "") + self.assertNotIn("TC_SAMBA_USER", reparsed) + self.assertNotIn("TC_PAYLOAD_DIR_NAME", reparsed) + self.assertNotIn("TC_MDNS_INSTANCE_NAME", reparsed) + + def test_preserved_env_file_values_filters_deprecated_runtime_keys(self) -> None: + values = { + "TC_HOST": "root@10.0.0.2", + "TC_CUSTOM_SETTING": "kept", + "TC_AIRPORT_SYAP": "119", + "TC_MDNS_DEVICE_MODEL": "TimeCapsule8,119", + "NET_IPV4_HINT": "10.0.0.2", + } + + preserved = preserved_env_file_values(values) + + self.assertEqual(preserved, {"TC_HOST": "root@10.0.0.2", "TC_CUSTOM_SETTING": "kept"}) + def test_env_example_does_not_include_runtime_derived_settings(self) -> None: values = parse_env_file(REPO_ROOT / ".env.example") self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) From 175195804ba67fd29baed0d04d962d9eed5fea70 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 22:54:41 -0700 Subject: [PATCH 04/20] Stabilize app service contracts and split backend operations --- .../TimeCapsuleSMBApp/ContentView.swift | 34 +- .../TimeCapsuleSMBApp/HelperRunner.swift | 52 +- .../Sources/TimeCapsuleSMBApp/Models.swift | 18 +- .../BackendEventTests.swift | 20 +- .../HelperRunnerTests.swift | 28 + src/timecapsulesmb/app/contracts.py | 218 ++++ src/timecapsulesmb/app/events.py | 16 +- src/timecapsulesmb/app/helper.py | 16 +- src/timecapsulesmb/app/operations.py | 943 ++---------------- src/timecapsulesmb/app/ops/__init__.py | 30 + src/timecapsulesmb/app/ops/configure.py | 133 +++ src/timecapsulesmb/app/ops/deploy.py | 371 +++++++ src/timecapsulesmb/app/ops/doctor.py | 39 + src/timecapsulesmb/app/ops/maintenance.py | 330 ++++++ src/timecapsulesmb/app/ops/readiness.py | 92 ++ src/timecapsulesmb/app/recovery.py | 288 ++++++ src/timecapsulesmb/app/service.py | 64 +- src/timecapsulesmb/app/stage_policy.py | 107 ++ src/timecapsulesmb/cli/deploy.py | 78 +- src/timecapsulesmb/cli/doctor.py | 3 +- src/timecapsulesmb/cli/fsck.py | 2 +- src/timecapsulesmb/cli/uninstall.py | 7 +- src/timecapsulesmb/services/app.py | 33 + src/timecapsulesmb/services/configure.py | 33 + src/timecapsulesmb/services/deploy.py | 61 ++ src/timecapsulesmb/services/doctor.py | 10 + src/timecapsulesmb/services/maintenance.py | 8 + tests/test_app_api.py | 164 ++- 28 files changed, 2241 insertions(+), 957 deletions(-) create mode 100644 src/timecapsulesmb/app/contracts.py create mode 100644 src/timecapsulesmb/app/ops/__init__.py create mode 100644 src/timecapsulesmb/app/ops/configure.py create mode 100644 src/timecapsulesmb/app/ops/deploy.py create mode 100644 src/timecapsulesmb/app/ops/doctor.py create mode 100644 src/timecapsulesmb/app/ops/maintenance.py create mode 100644 src/timecapsulesmb/app/ops/readiness.py create mode 100644 src/timecapsulesmb/app/recovery.py create mode 100644 src/timecapsulesmb/app/stage_policy.py create mode 100644 src/timecapsulesmb/services/configure.py create mode 100644 src/timecapsulesmb/services/deploy.py create mode 100644 src/timecapsulesmb/services/doctor.py create mode 100644 src/timecapsulesmb/services/maintenance.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 1feeef25..f8e6eddc 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -45,18 +45,34 @@ public struct ContentView: View { } } .frame(minWidth: 980, minHeight: 680) - .alert(item: $pendingConfirmation) { confirmation in - Alert( - title: Text(confirmation.title), - message: Text(confirmation.message), - primaryButton: .destructive(Text(confirmation.actionTitle)) { - backend.run(operation: confirmation.operation, params: confirmation.params) - }, - secondaryButton: .cancel() - ) + .alert( + pendingConfirmation?.title ?? "", + isPresented: confirmationPresented, + presenting: pendingConfirmation + ) { confirmation in + Button(confirmation.actionTitle, role: .destructive) { + backend.run(operation: confirmation.operation, params: confirmation.params) + pendingConfirmation = nil + } + Button("Cancel", role: .cancel) { + pendingConfirmation = nil + } + } message: { confirmation in + Text(confirmation.message) } } + private var confirmationPresented: Binding { + Binding( + get: { pendingConfirmation != nil }, + set: { isPresented in + if !isPresented { + pendingConfirmation = nil + } + } + ) + } + @ViewBuilder private var form: some View { switch selection { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 79654aa8..cbef5bcf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -70,22 +70,21 @@ public final class HelperRunner { try input.fileHandleForWriting.close() } catch { try? input.fileHandleForWriting.close() - terminate(process) + await Self.terminate(process) eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) await stdoutTask.value let stderr = await stderrTask.value return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) } - var cancelled = false - while process.isRunning { - if Task.isCancelled { - cancelled = true - terminate(process) - break + await withTaskCancellationHandler { + await Self.waitForExit(process) + } onCancel: { + Task { + await Self.terminate(process) } - try? await Task.sleep(nanoseconds: 100_000_000) } + let cancelled = Task.isCancelled await stdoutTask.value let stderrText = await stderrTask.value @@ -138,13 +137,29 @@ public final class HelperRunner { return String(data: output, encoding: .utf8) ?? "" } - private func terminate(_ process: Process) { + 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 } - Thread.sleep(forTimeInterval: 0.1) + try? await Task.sleep(nanoseconds: 100_000_000) } if process.isRunning { kill(process.processIdentifier, SIGKILL) @@ -152,6 +167,23 @@ public final class HelperRunner { } } +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 final class TerminalEventTracker: @unchecked Sendable { private let lock = NSLock() private var seen = false diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index bd2598fa..ec67bea4 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -80,6 +80,10 @@ public struct BackendEvent: Decodable, Identifiable { 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, @@ -94,7 +98,11 @@ public struct BackendEvent: Decodable, Identifiable { ok: Bool? = nil, payload: JSONValue? = nil, details: JSONValue? = nil, - debug: JSONValue? = nil + debug: JSONValue? = nil, + recovery: JSONValue? = nil, + risk: String? = nil, + cancellable: Bool? = nil, + description: String? = nil ) { self.schemaVersion = schemaVersion self.requestId = requestId @@ -109,6 +117,10 @@ public struct BackendEvent: Decodable, Identifiable { 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( @@ -140,6 +152,10 @@ public struct BackendEvent: Decodable, Identifiable { case payload case details case debug + case recovery + case risk + case cancellable + case description } public var summary: String { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift index 8f80a4e5..98696fb7 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -5,7 +5,7 @@ import XCTest 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"}} + {"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) @@ -17,6 +17,24 @@ final class BackendEventTests: XCTestCase { 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 testJSONValueRoundTripsNestedObjects() throws { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index 3495cbf8..d595d2a0 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -91,6 +91,34 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(recorder.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: [:]) { + recorder.append($0) + } + } + try await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + let result = await task.value + + XCTAssertEqual(result.exitCode, 130) + XCTAssertEqual(recorder.events.last?.type, "error") + XCTAssertEqual(recorder.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) diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py new file mode 100644 index 00000000..ba8b4221 --- /dev/null +++ b/src/timecapsulesmb/app/contracts.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.doctor import doctor_status_counts + + +SCHEMA_VERSION = 1 + + +def _with_schema(payload: Mapping[str, object]) -> dict[str, object]: + data = dict(payload) + data.setdefault("schema_version", SCHEMA_VERSION) + return data + + +def _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 [] + return _with_schema({ + **raw, + "counts": { + "instances": len(instances), + "resolved": len(resolved), + }, + "summary": f"discovered {len(resolved)} resolved AirPort service(s).", + }) + + +def paths_payload(raw: Mapping[str, object]) -> dict[str, object]: + artifacts = raw.get("artifacts") + artifact_count = len(artifacts) if isinstance(artifacts, list) else 0 + return _with_schema({ + **raw, + "counts": {"artifacts": artifact_count}, + "summary": f"resolved app paths with {artifact_count} artifact path(s).", + }) + + +def install_validation_payload(*, ok: bool, checks: list[object]) -> dict[str, object]: + checks_payload = jsonable(checks) + checks_list = checks_payload if isinstance(checks_payload, list) else [] + pass_count = sum(1 for check in checks_list if isinstance(check, dict) and check.get("ok") is True) + fail_count = sum(1 for check in checks_list if isinstance(check, dict) and check.get("ok") is False) + return _with_schema({ + "ok": ok, + "checks": checks_list, + "counts": { + "checks": len(checks_list), + "pass": pass_count, + "fail": fail_count, + }, + "summary": "install validation passed." if ok else "install validation failed.", + }) + + +def configure_payload( + *, + config_path: str, + host: str, + configure_id: str, + ssh_authenticated: bool, + device_syap: str | None, + device_model: str | None, + compatibility: object | None, +) -> dict[str, object]: + return _with_schema({ + "config_path": config_path, + "host": host, + "configure_id": configure_id, + "ssh_authenticated": ssh_authenticated, + "device_syap": device_syap, + "device_model": device_model, + "compatibility": jsonable(compatibility), + "device": _device_payload(host=host, syap=device_syap, model=device_model), + "summary": "configuration saved and SSH authentication verified.", + }) + + +def deploy_plan_payload(raw: Mapping[str, object], *, payload_family: str | None, netbsd4: bool) -> dict[str, object]: + requires_reboot = bool(raw.get("reboot_required")) + return _with_schema({ + **raw, + "requires_reboot": requires_reboot, + "payload_family": payload_family, + "netbsd4": netbsd4, + "summary": "deployment dry-run plan generated.", + }) + + +def deploy_result_payload( + *, + payload_dir: str, + rebooted: bool | None = None, + netbsd4: bool = False, + message: str | None = None, + payload_family: str | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "payload_dir": payload_dir, + "netbsd4": netbsd4, + "payload_family": payload_family, + "requires_reboot": False if netbsd4 else bool(rebooted), + "summary": "deployment completed.", + } + if rebooted is not None: + payload["rebooted"] = rebooted + 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) -> dict[str, object]: + return _with_schema({ + "rebooted": rebooted, + "verified": verified, + "requires_reboot": rebooted, + "summary": "uninstall completed." if verified else "uninstall completed without post-reboot verification.", + }) + + +def fsck_result_payload( + *, + device: str, + mountpoint: str, + returncode: int | None = None, + waited: bool | None = None, +) -> dict[str, object]: + payload: dict[str, object] = { + "device": device, + "mountpoint": mountpoint, + "summary": "fsck completed.", + } + if returncode is not None: + payload["returncode"] = returncode + if waited is not None: + payload["waited"] = waited + 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) + return _with_schema({ + **raw, + "counts": { + "findings": finding_count, + "repairable": repairable_count, + }, + "summary_text": f"repair-xattrs found {finding_count} issue(s), {repairable_count} repairable.", + }) + + +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 index 2accfd9e..e66df37a 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Callable +from timecapsulesmb.app.stage_policy import stage_policy + SENSITIVE_KEY_PARTS = ("password", "secret", "token") REDACTED = "" @@ -57,6 +59,7 @@ def __init__( self._emit = emit self.request_id = request_id or str(uuid.uuid4()) self.schema_version = schema_version + self._current_stage_by_operation: dict[str, str] = {} def with_request_id(self, request_id: str) -> "EventSink": return EventSink(self._emit, request_id=request_id, schema_version=self.schema_version) @@ -72,8 +75,16 @@ def emit(self, event: AppEvent) -> None: ) self._emit(event) + def current_stage(self, operation: str) -> str | None: + return self._current_stage_by_operation.get(operation) + def stage(self, operation: str, stage: str) -> None: - self.emit(AppEvent("stage", operation, {"stage": stage})) + self._current_stage_by_operation[operation] = stage + fields: dict[str, object] = {"stage": stage} + policy = stage_policy(operation, stage) + if policy is not None: + fields.update(policy.to_jsonable()) + self.emit(AppEvent("stage", operation, fields)) def log(self, operation: str, message: str, *, level: str = "info") -> None: self.emit(AppEvent("log", operation, {"level": level, "message": message})) @@ -102,8 +113,11 @@ def error( *, code: str = "operation_failed", debug: object | None = None, + recovery: object | None = None, ) -> None: fields: dict[str, object] = {"code": code, "message": message} 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 index bf2ace48..f35d0988 100644 --- a/src/timecapsulesmb/app/helper.py +++ b/src/timecapsulesmb/app/helper.py @@ -7,6 +7,7 @@ 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 @@ -33,12 +34,23 @@ def main(argv: Optional[list[str]] = None) -> int: 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}) + 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") + 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) diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py index bf6b854b..95b346ce 100644 --- a/src/timecapsulesmb/app/operations.py +++ b/src/timecapsulesmb/app/operations.py @@ -1,885 +1,160 @@ from __future__ import annotations -import argparse -import shlex -import sys -import tempfile -import uuid +# Compatibility shim for callers that imported or monkeypatched the original +# monolithic module. New code should import from timecapsulesmb.app.ops. + from collections.abc import Callable -from contextlib import ExitStack, redirect_stderr, redirect_stdout -from pathlib import Path from timecapsulesmb.app.events import EventSink -from timecapsulesmb.checks.doctor import run_doctor_checks -from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli -from timecapsulesmb.cli.deploy import render_flash_runtime_config -from timecapsulesmb.cli.doctor import build_doctor_error -from timecapsulesmb.cli.fsck import ( - FSCK_REBOOT_NO_DOWN_MESSAGE, - FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - build_remote_fsck_script, - select_fsck_target, - _target_from_volume, -) -from timecapsulesmb.cli.runtime import ( - load_env_config, - load_optional_env_config, - resolve_env_connection, - resolve_validated_managed_target, - ssh_target_link_local_resolution_error, -) -from timecapsulesmb.core.config import ( - DEFAULTS, - MANAGED_PAYLOAD_DIR_NAME, - AppConfig, - airport_family_display_name_from_identity, - parse_bool, - parse_env_file, - preserved_env_file_values, - write_env_file, -) -from timecapsulesmb.core.errors import system_exit_message -from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP -from timecapsulesmb.core.net import extract_host -from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts -from timecapsulesmb.deploy.artifacts import validate_artifacts -from timecapsulesmb.deploy.auth import render_smbpasswd -from timecapsulesmb.deploy.boot_assets import boot_asset_path -from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable, uninstall_plan_to_jsonable -from timecapsulesmb.deploy.executor import ( - flush_remote_filesystem_writes, - remote_request_reboot, - remote_request_shutdown_reboot, - remote_uninstall_payload, - run_remote_actions, - upload_deployment_payload, -) -from timecapsulesmb.deploy.planner import ( - BINARY_MDNS_SOURCE, - BINARY_NBNS_SOURCE, - BINARY_SMBD_SOURCE, - DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - GENERATED_FLASH_CONFIG_SOURCE, - GENERATED_SMBPASSWD_SOURCE, - GENERATED_USERNAME_MAP_SOURCE, - PACKAGED_COMMON_SH_SOURCE, - PACKAGED_DFREE_SH_SOURCE, - PACKAGED_RC_LOCAL_SOURCE, - PACKAGED_START_SAMBA_SOURCE, - PACKAGED_WATCHDOG_SOURCE, - build_deployment_plan, - build_netbsd4_activation_plan, - build_uninstall_plan, -) -from timecapsulesmb.deploy.verify import ( - managed_runtime_ready, - render_managed_runtime_verification, - render_post_uninstall_verification, - verify_managed_runtime, - verify_post_uninstall, -) -from timecapsulesmb.device.compat import ( - is_netbsd4_payload_family, - payload_family_description, - render_compatibility_message, - require_compatibility, -) -from timecapsulesmb.device.probe import ( - probe_connection_state, - probe_managed_runtime_conn, - wait_for_ssh_state_conn, -) -from timecapsulesmb.device.storage import ( - MAST_DISCOVERY_ATTEMPTS, - MAST_DISCOVERY_DELAY_SECONDS, - UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, - build_dry_run_payload_home, - mounted_mast_volumes_conn, - read_mast_volumes_conn, - select_payload_home_with_diagnostics_conn, - verify_payload_home_conn, - wait_for_mast_volumes_conn, -) -from timecapsulesmb.discovery.bonjour import ( - DEFAULT_BROWSE_TIMEOUT_SEC, - BonjourDiscoverySnapshot, - BonjourResolvedService, - discover_snapshot, - discovered_record_root_host, - discovery_record_to_jsonable, - service_instance_to_jsonable, -) -from timecapsulesmb.install_validation import ( - install_checks_to_jsonable, - install_ok, - paths_to_jsonable, - validate_install, -) -from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh, reboot as acp_reboot +from timecapsulesmb.app.ops import configure as _configure +from timecapsulesmb.app.ops import deploy as _deploy +from timecapsulesmb.app.ops import doctor as _doctor +from timecapsulesmb.app.ops import maintenance as _maintenance +from timecapsulesmb.app.ops import readiness as _readiness +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME +from timecapsulesmb.device.storage import build_dry_run_payload_home from timecapsulesmb.services.app import ( AppOperationError, OperationResult, bool_param as _bool_param, config_path as _config_path, confirm_param as _confirm_param, + float_param as _float_param, int_param as _int_param, jsonable as _jsonable, + optional_int_param as _optional_int_param, require_string_param as _require_string_param, string_param as _string_param, ) -from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError, run_ssh -REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." -DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." -) -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." -) -ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 - - -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]: - return { - "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], - "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], - } +discover_snapshot = _readiness.discover_snapshot + +probe_connection_state = _configure.probe_connection_state +enable_ssh = _configure.enable_ssh + +load_env_config = _deploy.load_env_config +resolve_validated_managed_target = _deploy.resolve_validated_managed_target +resolve_app_paths = _deploy.resolve_app_paths +validate_artifacts = _deploy.validate_artifacts +resolve_payload_artifacts = _deploy.resolve_payload_artifacts +run_remote_actions = _deploy.run_remote_actions +wait_for_mast_volumes_conn = _deploy.wait_for_mast_volumes_conn +select_payload_home_with_diagnostics_conn = _deploy.select_payload_home_with_diagnostics_conn +verify_payload_home_conn = _deploy.verify_payload_home_conn +upload_deployment_payload = _deploy.upload_deployment_payload +flush_remote_filesystem_writes = _deploy.flush_remote_filesystem_writes +wait_for_ssh_state_conn = _deploy.wait_for_ssh_state_conn + +resolve_env_connection = _maintenance.resolve_env_connection +remote_uninstall_payload = _maintenance.remote_uninstall_payload +probe_managed_runtime_conn = _maintenance.probe_managed_runtime_conn +load_optional_env_config = _maintenance.load_optional_env_config +repair_xattrs_cli = _maintenance.repair_xattrs_cli +sys = _maintenance.sys + +run_doctor_checks = _doctor.run_doctor_checks + + +def _sync_compat_bindings() -> None: + _readiness.discover_snapshot = discover_snapshot + _readiness.resolve_app_paths = resolve_app_paths + + _configure.probe_connection_state = probe_connection_state + _configure.enable_ssh = enable_ssh + _configure.resolve_app_paths = resolve_app_paths + + _deploy.load_env_config = load_env_config + _deploy.resolve_validated_managed_target = resolve_validated_managed_target + _deploy.resolve_app_paths = resolve_app_paths + _deploy.validate_artifacts = validate_artifacts + _deploy.resolve_payload_artifacts = resolve_payload_artifacts + _deploy.run_remote_actions = run_remote_actions + _deploy.wait_for_mast_volumes_conn = wait_for_mast_volumes_conn + _deploy.select_payload_home_with_diagnostics_conn = select_payload_home_with_diagnostics_conn + _deploy.verify_payload_home_conn = verify_payload_home_conn + _deploy.upload_deployment_payload = upload_deployment_payload + _deploy.flush_remote_filesystem_writes = flush_remote_filesystem_writes + _deploy.wait_for_ssh_state_conn = wait_for_ssh_state_conn + + _maintenance.load_env_config = load_env_config + _maintenance.resolve_env_connection = resolve_env_connection + _maintenance.remote_uninstall_payload = remote_uninstall_payload + _maintenance.run_remote_actions = run_remote_actions + _maintenance.probe_managed_runtime_conn = probe_managed_runtime_conn + _maintenance.load_optional_env_config = load_optional_env_config + _maintenance.repair_xattrs_cli = repair_xattrs_cli + _maintenance.sys = sys + + _doctor.load_env_config = load_env_config + _doctor.resolve_app_paths = resolve_app_paths + _doctor.resolve_env_connection = resolve_env_connection + _doctor.run_doctor_checks = run_doctor_checks def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "discover" - timeout = float(params.get("timeout", DEFAULT_BROWSE_TIMEOUT_SEC)) - sink.stage(operation, "bonjour_discovery") - snapshot = discover_snapshot(timeout=timeout) - return OperationResult(True, _snapshot_payload(snapshot)) + _sync_compat_bindings() + return _readiness.discover_operation(params, sink) def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "paths" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "summarize_artifacts") - return OperationResult(True, paths_to_jsonable(app_paths)) + _sync_compat_bindings() + return _readiness.paths_operation(params, sink) def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "validate-install" - sink.stage(operation, "resolve_paths") - app_paths = resolve_app_paths(config_path=_config_path(params)) - sink.stage(operation, "validate_install") - checks = validate_install(app_paths) - ok = install_ok(checks) - for check in checks: - sink.check( - operation, - status="PASS" if check.ok else "FAIL", - message=check.message, - details=check.details, - ) - return OperationResult(ok, {"ok": ok, "checks": install_checks_to_jsonable(checks)}) + _sync_compat_bindings() + return _readiness.validate_install_operation(params, sink) def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "configure" - sink.stage(operation, "load_existing_config") - app_paths = resolve_app_paths(config_path=_config_path(params)) - env_path = app_paths.config_path - existing = parse_env_file(env_path) - configure_id = str(uuid.uuid4()) - ssh_opts = _string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) - host = _string_param(params, "host") or _selected_record_host(params) or existing.get("TC_HOST", "") - password = _require_string_param(params, "password") - if not host: - raise AppOperationError("missing required parameter: host", code="validation_failed") - - resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) - if resolution_error is not None: - raise AppOperationError(resolution_error, code="config_error") - - values = 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 _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"])), - ) else "false", - "TC_ANY_PROTOCOL": "true" if _bool_param( - params, - "any_protocol", - parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), - ) else "false", - "TC_CONFIGURE_ID": configure_id, - }) - - sink.stage(operation, "ssh_probe") - connection = SshConnection(host, password, ssh_opts) - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_port_reachable: - if not _bool_param(params, "enable_ssh", True): - raise AppOperationError("SSH is not reachable and enable_ssh is false.", code="remote_error") - sink.stage(operation, "acp_enable_ssh") - try: - enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) - except ACPAuthError as exc: - raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc - except ACPError as exc: - raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc - - sink.stage(operation, "wait_for_ssh_after_acp") - if not _wait_for_ssh_port(host, timeout_seconds=_int_param(params, "ssh_wait_timeout", 180)): - raise AppOperationError("SSH did not open after enabling via ACP.", code="remote_error") - sink.stage(operation, "ssh_probe_after_acp") - probed_state = probe_connection_state(connection) - probe = probed_state.probe_result - - if not probe.ssh_authenticated: - raise AppOperationError( - probe.error or "The provided AirPort SSH target and password did not work.", - code="auth_failed", - ) - - compatibility = probed_state.compatibility - if compatibility is not None and not compatibility.supported: - raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") - - selected_props = _selected_record_properties(params) - observed_syap = None if compatibility is None else compatibility.exact_syap - observed_model = None if compatibility is None else compatibility.exact_model - if observed_syap is None: - observed_syap = selected_props.get("syAP") or None - - sink.stage(operation, "write_env") - env_path.parent.mkdir(parents=True, exist_ok=True) - write_env_file(env_path, values) - return OperationResult(True, { - "config_path": str(env_path), - "host": host, - "configure_id": configure_id, - "ssh_authenticated": True, - "device_syap": observed_syap, - "device_model": observed_model, - "compatibility": _jsonable(compatibility) if compatibility is not None else None, - }) - - -def _wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: - from timecapsulesmb.cli.flows import wait_for_tcp_port_state - - return wait_for_tcp_port_state( - extract_host(host), - 22, - expected_state=True, - timeout_seconds=timeout_seconds, - verbose=False, - service_name="SSH port", - ) - - -def _require_supported_payload(target, *, allow_unsupported: bool) -> object: - probe_state = target.probe_state - if probe_state is None: - raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") - compatibility = require_compatibility( - probe_state.compatibility, - fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", - ) - if not compatibility.supported and not allow_unsupported: - raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") - if not compatibility.payload_family: - raise AppOperationError("No deployable payload is available for this detected device.", code="unsupported_device") - return compatibility - - -def _load_config_and_target( - operation: str, - params: dict[str, object], - sink: EventSink, - *, - profile: str, - include_probe: bool, -) -> tuple[AppConfig, object]: - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_managed_target") - target = resolve_validated_managed_target( - config, - command_name=operation, - profile=profile, - include_probe=include_probe, - ) - return config, target + _sync_compat_bindings() + return _configure.configure_operation(params, sink) def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "deploy" - nbns_enabled = _bool_param(params, "nbns_enabled", True) - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - confirm_deploy = _confirm_param(params, "confirm_deploy") - confirm_reboot = _confirm_param(params, "confirm_reboot") - confirm_netbsd4_activation = _confirm_param(params, "confirm_netbsd4_activation") - mount_wait = _int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) - allow_unsupported = _bool_param(params, "allow_unsupported") - debug_logging = _bool_param(params, "debug_logging") - - if not dry_run and not confirm_deploy: - raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") - - config, target = _load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) - connection = target.connection - app_paths = resolve_app_paths(config_path=_config_path(params)) - - sink.stage(operation, "validate_artifacts") - failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] - if failures: - raise AppOperationError("; ".join(failures), code="validation_failed") - - sink.stage(operation, "check_compatibility") - compatibility = _require_supported_payload(target, allow_unsupported=allow_unsupported) - payload_family = compatibility.payload_family - is_netbsd4 = is_netbsd4_payload_family(payload_family) - sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") - resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) - if not dry_run: - if is_netbsd4 and not confirm_netbsd4_activation: - raise AppOperationError( - "NetBSD 4 deploy requires explicit activation confirmation.", - code="confirmation_required", - ) - if not is_netbsd4 and not no_reboot and not confirm_reboot: - 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, - ) - raise AppOperationError( - f"Deploy requires confirmation to reboot the {device_name}.", - code="confirmation_required", - ) - - if dry_run: - payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) - else: - sink.stage(operation, "read_mast") - mast_discovery = wait_for_mast_volumes_conn( - connection, - attempts=MAST_DISCOVERY_ATTEMPTS, - delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, - ) - if not mast_discovery.volumes: - raise AppOperationError( - f"No deployable HFS disk was found after {MAST_DISCOVERY_ATTEMPTS} MaSt queries " - f"spaced {MAST_DISCOVERY_DELAY_SECONDS} seconds apart.", - code="remote_error", - ) - sink.stage(operation, "select_payload_home") - selection = select_payload_home_with_diagnostics_conn( - connection, - mast_discovery.volumes, - MANAGED_PAYLOAD_DIR_NAME, - wait_seconds=mount_wait, - ) - if selection.payload_home is None: - raise AppOperationError( - f"MaSt found {len(mast_discovery.volumes)} deployable HFS volume(s), but deploy could not write to any of them.", - code="remote_error", - ) - payload_home = selection.payload_home - - sink.stage(operation, "build_deployment_plan") - plan = build_deployment_plan( - connection.host, - payload_home, - resolved_artifacts["smbd"].absolute_path, - resolved_artifacts["mdns-advertiser"].absolute_path, - resolved_artifacts["nbns-advertiser"].absolute_path, - activate_netbsd4=is_netbsd4, - reboot_after_deploy=not no_reboot, - apple_mount_wait_seconds=mount_wait, - ) - if dry_run: - return OperationResult(True, deployment_plan_to_jsonable(plan)) - - sink.stage(operation, "pre_upload_actions") - run_remote_actions(connection, plan.pre_upload_actions) - sink.stage(operation, "prepare_deployment_files") - flash_config_text = render_flash_runtime_config( - config, - payload_home, - nbns_enabled=nbns_enabled, - debug_logging=debug_logging, - ) - with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: - tmpdir = Path(tmp) - generated_flash_config = tmpdir / "tcapsulesmb.conf" - generated_smbpasswd = tmpdir / "smbpasswd" - generated_username_map = tmpdir / "username.map" - generated_flash_config.write_text(flash_config_text) - smbpasswd_text, username_map_text = render_smbpasswd(connection.password) - generated_smbpasswd.write_text(smbpasswd_text) - generated_username_map.write_text(username_map_text) - upload_sources = { - BINARY_SMBD_SOURCE: plan.smbd_path, - BINARY_MDNS_SOURCE: plan.mdns_path, - BINARY_NBNS_SOURCE: plan.nbns_path, - GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, - GENERATED_USERNAME_MAP_SOURCE: generated_username_map, - GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, - PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), - PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), - PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), - PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), - PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), - } - sink.stage(operation, "upload_payload") - upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) - - sink.stage(operation, "post_upload_actions") - run_remote_actions(connection, plan.post_upload_actions) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) - sink.stage(operation, "flush_payload_upload") - sink.log(operation, "Flushing deployed payload to disk...") - flush_remote_filesystem_writes(connection) - _verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) - - if is_netbsd4: - sink.stage(operation, "netbsd4_activation") - run_remote_actions(connection, plan.activation_actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, { - "payload_dir": plan.payload_dir, - "netbsd4": True, - "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", - }) - - if no_reboot: - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": False}) - - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="ssh_shutdown_then_reboot", - reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, - ) - _verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) - return OperationResult(True, {"payload_dir": plan.payload_dir, "rebooted": True}) - - -def _verify_payload_upload( - operation: str, - sink: EventSink, - connection: SshConnection, - payload_home, - *, - wait_seconds: int, - post_sync: bool = False, -) -> None: - sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") - verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) - sink.log(operation, verification.detail) - if not verification.ok: - raise AppOperationError( - f"managed payload verification failed at {payload_home.payload_dir}: {verification.detail}", - code="remote_error", - ) - - -def _verify_runtime( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - stage: str, - timeout_seconds: int, -) -> None: - sink.stage(operation, stage) - verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) - for line in render_managed_runtime_verification( - verification, - heading="Waiting for managed runtime to finish starting...", - ): - sink.log(operation, line) - if not managed_runtime_ready(verification): - raise AppOperationError( - f"Managed runtime did not become ready. {verification.detail.strip()}".strip(), - code="remote_error", - ) - - -def _request_reboot_and_wait( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - strategy: str, - reboot_no_down_message: str, - down_timeout_seconds: int = 60, - up_timeout_seconds: int = 240, -) -> None: - sink.stage(operation, "reboot") - if strategy == "acp_then_ssh": - try: - acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) - sink.log(operation, "ACP reboot requested.") - except ACPError as exc: - sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") - _request_ssh_reboot(operation, sink, connection, shutdown=False) - else: - _request_ssh_reboot(operation, sink, connection, shutdown=True) - - sink.stage(operation, "wait_for_reboot_down") - sink.log(operation, "Waiting for the device to go down...") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message, code="remote_error") - sink.stage(operation, "wait_for_reboot_up") - sink.log(operation, "Waiting for the device to come back up...") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") - sink.log(operation, "Device is back online.") - - -def _request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: - try: - if shutdown: - remote_request_shutdown_reboot(connection) - else: - remote_request_reboot(connection) - except SshCommandTimeout as exc: - sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") - return - except SshError as exc: - sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") - return - sink.log(operation, "SSH reboot requested.") + _sync_compat_bindings() + return _deploy.deploy_operation(params, sink) def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "activate" - confirm_activation = _confirm_param(params, "confirm_netbsd4_activation") - dry_run = _bool_param(params, "dry_run") - _, target = _load_config_and_target(operation, params, sink, 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", - ) - sink.stage(operation, "build_activation_plan") - plan = build_netbsd4_activation_plan() - if dry_run: - return OperationResult(True, _jsonable(plan)) - if not confirm_activation: - raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") - connection = target.connection - sink.stage(operation, "probe_runtime") - if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: - return OperationResult(True, {"already_active": True}) - sink.stage(operation, "run_activation") - run_remote_actions(connection, plan.actions) - _verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) - return OperationResult(True, {"already_active": False, "message": f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}"}) + _sync_compat_bindings() + return _maintenance.activate_operation(params, sink) def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "uninstall" - dry_run = _bool_param(params, "dry_run") - no_reboot = _bool_param(params, "no_reboot") - confirm_uninstall = _confirm_param(params, "confirm_uninstall") - confirm_reboot = _confirm_param(params, "confirm_reboot") - if not dry_run and not confirm_uninstall: - raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") - if not dry_run and not no_reboot and not confirm_reboot: - raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - if dry_run: - volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] - payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] - else: - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_mast_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - volume_roots = [volume.volume_root for volume in mounted_volumes] - payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] - sink.stage(operation, "build_uninstall_plan") - plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) - if dry_run: - return OperationResult(True, uninstall_plan_to_jsonable(plan)) - sink.stage(operation, "uninstall_payload") - remote_uninstall_payload(connection, plan) - if no_reboot: - return OperationResult(True, {"rebooted": False, "verified": False}) - _request_reboot_and_wait( - operation, - sink, - connection, - strategy="acp_then_ssh", - reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, - ) - sink.stage(operation, "verify_post_uninstall") - verification = verify_post_uninstall(connection, plan) - for line in render_post_uninstall_verification(verification): - sink.log(operation, line) - if not verification: - raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") - return OperationResult(True, {"rebooted": True, "verified": True}) + _sync_compat_bindings() + return _maintenance.uninstall_operation(params, sink) def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "fsck" - confirm_fsck = _confirm_param(params, "confirm_fsck") - no_reboot = _bool_param(params, "no_reboot") - no_wait = _bool_param(params, "no_wait") - if not confirm_fsck: - raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - sink.stage(operation, "read_mast") - mast_volumes = read_mast_volumes_conn(connection) - sink.stage(operation, "mount_hfs_volumes") - mounted_volumes = mounted_mast_volumes_conn( - connection, - mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - ) - sink.stage(operation, "select_fsck_volume") - try: - target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), - _string_param(params, "volume") or None, - prompt=False, - ) - except RuntimeError as exc: - raise AppOperationError(str(exc), code="validation_failed") from exc - sink.stage(operation, "run_fsck") - script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) - proc = run_ssh( - connection, - f"/bin/sh -c {shlex.quote(script)}", - check=False, - timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - ) - if proc.stdout: - for line in proc.stdout.splitlines(): - sink.log(operation, line) - if no_reboot: - return OperationResult(proc.returncode == 0, { - "device": target.device, - "mountpoint": target.mountpoint, - "returncode": proc.returncode, - }) - if no_wait: - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": False}) - _observe_reboot_cycle( - operation, - sink, - connection, - reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, - down_timeout_seconds=90, - up_timeout_seconds=420, - ) - return OperationResult(True, {"device": target.device, "mountpoint": target.mountpoint, "waited": True}) - - -def _observe_reboot_cycle( - operation: str, - sink: EventSink, - connection: SshConnection, - *, - reboot_no_down_message: str, - down_timeout_seconds: int, - up_timeout_seconds: int, -) -> None: - sink.stage(operation, "wait_for_reboot_down") - if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): - raise AppOperationError(reboot_no_down_message, code="remote_error") - sink.stage(operation, "wait_for_reboot_up") - if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): - raise AppOperationError(REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") - - -class _RepairContext: - def __init__(self, operation: str, sink: EventSink) -> None: - self.operation = operation - self.sink = sink - self.result = "failure" - self.error: str | None = None - - def set_stage(self, stage: str) -> None: - self.sink.stage(self.operation, stage) - - def update_fields(self, **_fields: object) -> None: - pass - - def succeed(self) -> None: - self.result = "success" - - def fail_with_error(self, message: str) -> None: - self.result = "failure" - self.error = message - - -class _StreamLogCapture: - def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: - self.operation = operation - self.sink = sink - self.level = level - self._buffer = "" - - def write(self, text: str) -> int: - self._buffer += text - while "\n" in self._buffer: - line, self._buffer = self._buffer.split("\n", 1) - self._emit(line) - return len(text) - - def flush(self) -> None: - if self._buffer: - self._emit(self._buffer) - self._buffer = "" - - def _emit(self, line: str) -> None: - message = line.rstrip("\r") - if message: - self.sink.log(self.operation, message, level=self.level) + _sync_compat_bindings() + return _maintenance.fsck_operation(params, sink) def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "repair-xattrs" - dry_run = _bool_param(params, "dry_run") - confirm_repair = _confirm_param(params, "confirm_repair") - if not dry_run and not confirm_repair: - raise AppOperationError( - "repair-xattrs requires dry_run or explicit confirmation.", - code="confirmation_required", - ) - if sys.platform != "darwin": - raise AppOperationError( - "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", - code="validation_failed", - ) - config = load_optional_env_config(env_path=_config_path(params)) - args = argparse.Namespace( - path=Path(str(params["path"])) if params.get("path") else None, - dry_run=dry_run, - yes=confirm_repair, - recursive=_bool_param(params, "recursive", True), - max_depth=params.get("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 args.max_depth is not None: - args.max_depth = int(args.max_depth) - context = _RepairContext(operation, sink) - stdout_capture = _StreamLogCapture(operation, sink, level="info") - stderr_capture = _StreamLogCapture(operation, sink, level="warning") - try: - with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): - result = repair_xattrs_cli.run_repair_structured( - args, - context, - config, - emit_log=lambda message: sink.log(operation, message), - ) - except SystemExit as exc: - message = system_exit_message(exc) or "repair-xattrs failed" - raise AppOperationError(message, code="operation_failed") from exc - finally: - stdout_capture.flush() - stderr_capture.flush() - return OperationResult(result.returncode == 0, { - "returncode": result.returncode, - "root": str(result.root), - "finding_count": len(result.findings), - "repairable_count": len(result.candidates), - "summary": _jsonable(result.summary), - "report": result.report, - "telemetry_result": context.result, - "error": context.error, - }) + _sync_compat_bindings() + return _maintenance.repair_xattrs_operation(params, sink) def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - operation = "doctor" - sink.stage(operation, "load_config") - config = load_env_config(env_path=_config_path(params)) - app_paths = resolve_app_paths(config_path=_config_path(params)) - connection = None - if not _bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): - sink.stage(operation, "resolve_connection") - connection = resolve_env_connection(config, allow_empty_password=True) - debug_fields: dict[str, object] = {} - - def on_result(result: CheckResult) -> None: - sink.check(operation, status=result.status, message=result.message, details=result.details) - - sink.stage(operation, "run_checks") - results, fatal = run_doctor_checks( - config, - repo_root=app_paths.distribution_root, - connection=connection, - skip_ssh=_bool_param(params, "skip_ssh"), - skip_bonjour=_bool_param(params, "skip_bonjour"), - skip_smb=_bool_param(params, "skip_smb"), - on_result=on_result, - debug_fields=debug_fields, - ) - payload = { - "fatal": fatal, - "results": [_jsonable(result) for result in results], - "summary": "doctor found one or more fatal problems." if fatal else "doctor checks passed.", - } - if fatal: - payload["error"] = build_doctor_error(results, debug_fields) - return OperationResult(not fatal, payload) + _sync_compat_bindings() + return _doctor.doctor_operation(params, sink) + + +_selected_record_host = _readiness.selected_record_host +_selected_record_properties = _readiness.selected_record_properties +_snapshot_payload = _readiness.snapshot_payload +_wait_for_ssh_port = _configure.wait_for_ssh_port +_require_supported_payload = _deploy.require_supported_payload +_load_config_and_target = _deploy.load_config_and_target +_verify_payload_upload = _deploy.verify_payload_upload +_verify_runtime = _deploy.verify_runtime +_request_reboot_and_wait = _deploy.request_reboot_and_wait +_request_ssh_reboot = _deploy.request_ssh_reboot +_observe_reboot_cycle = _maintenance.observe_reboot_cycle +_RepairContext = _maintenance.RepairContext +_StreamLogCapture = _maintenance.StreamLogCapture OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py new file mode 100644 index 00000000..b015146d --- /dev/null +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from collections.abc import Callable + +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.configure import configure_operation +from timecapsulesmb.app.ops.deploy import deploy_operation +from timecapsulesmb.app.ops.doctor import doctor_operation +from timecapsulesmb.app.ops.maintenance import ( + activate_operation, + fsck_operation, + repair_xattrs_operation, + uninstall_operation, +) +from timecapsulesmb.app.ops.readiness import discover_operation, paths_operation, validate_install_operation +from timecapsulesmb.services.app import OperationResult + + +OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { + "activate": activate_operation, + "configure": configure_operation, + "deploy": deploy_operation, + "discover": discover_operation, + "doctor": doctor_operation, + "fsck": fsck_operation, + "paths": paths_operation, + "repair-xattrs": repair_xattrs_operation, + "uninstall": uninstall_operation, + "validate-install": validate_install_operation, +} diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py new file mode 100644 index 00000000..d8ec9600 --- /dev/null +++ b/src/timecapsulesmb/app/ops/configure.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import uuid + +from timecapsulesmb.app.contracts import configure_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.readiness import selected_record_host, selected_record_properties +from timecapsulesmb.core.config import ( + DEFAULTS, + parse_bool, + parse_env_file, + write_env_file, +) +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.device.compat import render_compatibility_message +from timecapsulesmb.device.probe import probe_connection_state +from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + int_param, + jsonable, + require_string_param, + string_param, +) +from timecapsulesmb.services.configure import build_configure_env_values +from timecapsulesmb.transport.ssh import SshConnection + +from timecapsulesmb.cli.runtime import ssh_target_link_local_resolution_error + + +def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "configure" + sink.stage(operation, "load_existing_config") + app_paths = resolve_app_paths(config_path=config_path(params)) + env_path = app_paths.config_path + existing = parse_env_file(env_path) + configure_id = str(uuid.uuid4()) + ssh_opts = string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + host = string_param(params, "host") or selected_record_host(params) or existing.get("TC_HOST", "") + password = require_string_param(params, "password") + if not host: + raise AppOperationError("missing required parameter: host", code="validation_failed") + + resolution_error = ssh_target_link_local_resolution_error(host, ssh_opts) + if resolution_error is not None: + raise AppOperationError(resolution_error, code="config_error") + + values = build_configure_env_values( + existing, + host=host, + password=password, + ssh_opts=ssh_opts, + configure_id=configure_id, + internal_share_use_disk_root=bool_param( + params, + "internal_share_use_disk_root", + parse_bool(existing.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"])), + ), + any_protocol=bool_param( + params, + "any_protocol", + parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), + ), + ) + + sink.stage(operation, "ssh_probe") + connection = SshConnection(host, password, ssh_opts) + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_port_reachable: + if not bool_param(params, "enable_ssh", True): + raise AppOperationError("SSH is not reachable and enable_ssh is false.", code="remote_error") + sink.stage(operation, "acp_enable_ssh") + try: + enable_ssh(extract_host(host), password, reboot_device=True, log=lambda message: sink.log(operation, message)) + except ACPAuthError as exc: + raise AppOperationError("The AirPort admin password did not work.", code="auth_failed", debug=str(exc)) from exc + except ACPError as exc: + raise AppOperationError(f"Failed to enable SSH via ACP: {exc}", code="remote_error") from exc + + sink.stage(operation, "wait_for_ssh_after_acp") + if not wait_for_ssh_port(host, timeout_seconds=int_param(params, "ssh_wait_timeout", 180)): + raise AppOperationError("SSH did not open after enabling via ACP.", code="remote_error") + sink.stage(operation, "ssh_probe_after_acp") + probed_state = probe_connection_state(connection) + probe = probed_state.probe_result + + if not probe.ssh_authenticated: + raise AppOperationError( + probe.error or "The provided AirPort SSH target and password did not work.", + code="auth_failed", + ) + + compatibility = probed_state.compatibility + if compatibility is not None and not compatibility.supported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + + selected_props = selected_record_properties(params) + observed_syap = None if compatibility is None else compatibility.exact_syap + observed_model = None if compatibility is None else compatibility.exact_model + if observed_syap is None: + observed_syap = selected_props.get("syAP") or None + + sink.stage(operation, "write_env") + env_path.parent.mkdir(parents=True, exist_ok=True) + write_env_file(env_path, values) + return OperationResult(True, configure_payload( + config_path=str(env_path), + host=host, + configure_id=configure_id, + ssh_authenticated=True, + device_syap=observed_syap, + device_model=observed_model, + compatibility=jsonable(compatibility) if compatibility is not None else None, + )) + + +def wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: + from timecapsulesmb.cli.flows import wait_for_tcp_port_state + + return wait_for_tcp_port_state( + extract_host(host), + 22, + expected_state=True, + timeout_seconds=timeout_seconds, + verbose=False, + service_name="SSH port", + ) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py new file mode 100644 index 00000000..7c7a7a13 --- /dev/null +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +from contextlib import ExitStack +from pathlib import Path +import tempfile + +from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.cli.runtime import load_env_config, resolve_validated_managed_target +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, airport_family_display_name_from_identity +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.core.net import extract_host +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts +from timecapsulesmb.deploy.artifacts import validate_artifacts +from timecapsulesmb.deploy.auth import render_smbpasswd +from timecapsulesmb.deploy.boot_assets import boot_asset_path +from timecapsulesmb.deploy.dry_run import deployment_plan_to_jsonable +from timecapsulesmb.deploy.executor import ( + flush_remote_filesystem_writes, + remote_request_reboot, + remote_request_shutdown_reboot, + run_remote_actions, + upload_deployment_payload, +) +from timecapsulesmb.deploy.planner import ( + BINARY_MDNS_SOURCE, + BINARY_NBNS_SOURCE, + BINARY_SMBD_SOURCE, + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + GENERATED_FLASH_CONFIG_SOURCE, + GENERATED_SMBPASSWD_SOURCE, + GENERATED_USERNAME_MAP_SOURCE, + PACKAGED_COMMON_SH_SOURCE, + PACKAGED_DFREE_SH_SOURCE, + PACKAGED_RC_LOCAL_SOURCE, + PACKAGED_START_SAMBA_SOURCE, + PACKAGED_WATCHDOG_SOURCE, + build_deployment_plan, +) +from timecapsulesmb.deploy.verify import ( + managed_runtime_ready, + render_managed_runtime_verification, + verify_managed_runtime, +) +from timecapsulesmb.device.compat import ( + is_netbsd4_payload_family, + payload_family_description, + render_compatibility_message, + require_compatibility, +) +from timecapsulesmb.device.probe import wait_for_ssh_state_conn +from timecapsulesmb.device.storage import ( + MAST_DISCOVERY_ATTEMPTS, + MAST_DISCOVERY_DELAY_SECONDS, + build_dry_run_payload_home, + select_payload_home_with_diagnostics_conn, + verify_payload_home_conn, + wait_for_mast_volumes_conn, +) +from timecapsulesmb.integrations.acp import ACPError, reboot as acp_reboot +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + confirm_param, + int_param, +) +from timecapsulesmb.services.deploy import ( + DEPLOY_REBOOT_NO_DOWN_MESSAGE, + DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, + no_mast_volumes_message, + no_writable_mast_volumes_message, + payload_verification_error, + render_flash_runtime_config, +) +from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError + + +ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 + + +def require_supported_payload(target, *, allow_unsupported: bool) -> object: + probe_state = target.probe_state + if probe_state is None: + raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") + compatibility = require_compatibility( + probe_state.compatibility, + fallback_error=probe_state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + if not compatibility.supported and not allow_unsupported: + raise AppOperationError(render_compatibility_message(compatibility), code="unsupported_device") + if not compatibility.payload_family: + raise AppOperationError("No deployable payload is available for this detected device.", code="unsupported_device") + return compatibility + + +def load_config_and_target( + operation: str, + params: dict[str, object], + sink: EventSink, + *, + profile: str, + include_probe: bool, +) -> tuple[AppConfig, object]: + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile=profile, + include_probe=include_probe, + ) + return config, target + + +def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "deploy" + nbns_enabled = bool_param(params, "nbns_enabled", True) + dry_run = bool_param(params, "dry_run") + no_reboot = bool_param(params, "no_reboot") + confirm_deploy = confirm_param(params, "confirm_deploy") + confirm_reboot = confirm_param(params, "confirm_reboot") + confirm_netbsd4_activation = confirm_param(params, "confirm_netbsd4_activation") + mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) + allow_unsupported = bool_param(params, "allow_unsupported") + debug_logging = bool_param(params, "debug_logging") + + if not dry_run and not confirm_deploy: + raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") + + config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) + connection = target.connection + app_paths = resolve_app_paths(config_path=config_path(params)) + + sink.stage(operation, "validate_artifacts") + failures = [message for _, ok, message in validate_artifacts(app_paths.distribution_root) if not ok] + if failures: + raise AppOperationError("; ".join(failures), code="validation_failed") + + sink.stage(operation, "check_compatibility") + compatibility = require_supported_payload(target, allow_unsupported=allow_unsupported) + payload_family = compatibility.payload_family + is_netbsd4 = is_netbsd4_payload_family(payload_family) + sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") + resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) + if not dry_run: + if is_netbsd4 and not confirm_netbsd4_activation: + raise AppOperationError( + "NetBSD 4 deploy requires explicit activation confirmation.", + code="confirmation_required", + ) + if not is_netbsd4 and not no_reboot and not confirm_reboot: + 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, + ) + raise AppOperationError( + f"Deploy requires confirmation to reboot the {device_name}.", + code="confirmation_required", + ) + + if dry_run: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + else: + sink.stage(operation, "read_mast") + mast_discovery = wait_for_mast_volumes_conn( + connection, + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ) + if not mast_discovery.volumes: + raise AppOperationError( + no_mast_volumes_message( + attempts=MAST_DISCOVERY_ATTEMPTS, + delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, + ), + code="remote_error", + ) + sink.stage(operation, "select_payload_home") + selection = select_payload_home_with_diagnostics_conn( + connection, + mast_discovery.volumes, + MANAGED_PAYLOAD_DIR_NAME, + wait_seconds=mount_wait, + ) + if selection.payload_home is None: + raise AppOperationError( + no_writable_mast_volumes_message(len(mast_discovery.volumes)), + code="remote_error", + ) + payload_home = selection.payload_home + + sink.stage(operation, "build_deployment_plan") + plan = build_deployment_plan( + connection.host, + payload_home, + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + if dry_run: + return OperationResult(True, deploy_plan_payload( + deployment_plan_to_jsonable(plan), + payload_family=payload_family, + netbsd4=is_netbsd4, + )) + + sink.stage(operation, "pre_upload_actions") + run_remote_actions(connection, plan.pre_upload_actions) + sink.stage(operation, "prepare_deployment_files") + flash_config_text = render_flash_runtime_config( + config, + payload_home, + nbns_enabled=nbns_enabled, + debug_logging=debug_logging, + ) + with tempfile.TemporaryDirectory(prefix="tc-deploy-") as tmp, ExitStack() as boot_assets: + tmpdir = Path(tmp) + generated_flash_config = tmpdir / "tcapsulesmb.conf" + generated_smbpasswd = tmpdir / "smbpasswd" + generated_username_map = tmpdir / "username.map" + generated_flash_config.write_text(flash_config_text) + smbpasswd_text, username_map_text = render_smbpasswd(connection.password) + generated_smbpasswd.write_text(smbpasswd_text) + generated_username_map.write_text(username_map_text) + upload_sources = { + BINARY_SMBD_SOURCE: plan.smbd_path, + BINARY_MDNS_SOURCE: plan.mdns_path, + BINARY_NBNS_SOURCE: plan.nbns_path, + GENERATED_SMBPASSWD_SOURCE: generated_smbpasswd, + GENERATED_USERNAME_MAP_SOURCE: generated_username_map, + GENERATED_FLASH_CONFIG_SOURCE: generated_flash_config, + PACKAGED_RC_LOCAL_SOURCE: boot_assets.enter_context(boot_asset_path("rc.local")), + PACKAGED_COMMON_SH_SOURCE: boot_assets.enter_context(boot_asset_path("common.sh")), + PACKAGED_DFREE_SH_SOURCE: boot_assets.enter_context(boot_asset_path("dfree.sh")), + PACKAGED_START_SAMBA_SOURCE: boot_assets.enter_context(boot_asset_path("start-samba.sh")), + PACKAGED_WATCHDOG_SOURCE: boot_assets.enter_context(boot_asset_path("watchdog.sh")), + } + sink.stage(operation, "upload_payload") + upload_deployment_payload(plan, connection=connection, source_resolver=upload_sources) + + sink.stage(operation, "post_upload_actions") + run_remote_actions(connection, plan.post_upload_actions) + verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait) + sink.stage(operation, "flush_payload_upload") + sink.log(operation, "Flushing deployed payload to disk...") + flush_remote_filesystem_writes(connection) + verify_payload_upload(operation, sink, connection, payload_home, wait_seconds=mount_wait, post_sync=True) + + if is_netbsd4: + sink.stage(operation, "netbsd4_activation") + run_remote_actions(connection, plan.activation_actions) + verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + netbsd4=True, + message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + payload_family=payload_family, + )) + + if no_reboot: + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + rebooted=False, + payload_family=payload_family, + )) + + request_reboot_and_wait( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + reboot_no_down_message=DEPLOY_REBOOT_NO_DOWN_MESSAGE, + ) + verify_runtime(operation, sink, connection, stage="verify_runtime_reboot", timeout_seconds=240) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + rebooted=True, + payload_family=payload_family, + )) + + +def verify_payload_upload( + operation: str, + sink: EventSink, + connection: SshConnection, + payload_home, + *, + wait_seconds: int, + post_sync: bool = False, +) -> None: + sink.stage(operation, "verify_payload_upload_after_sync" if post_sync else "verify_payload_upload") + verification = verify_payload_home_conn(connection, payload_home, wait_seconds=wait_seconds) + sink.log(operation, verification.detail) + if not verification.ok: + raise AppOperationError(payload_verification_error(payload_home, verification), code="remote_error") + + +def verify_runtime( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + stage: str, + timeout_seconds: int, +) -> None: + sink.stage(operation, stage) + verification = verify_managed_runtime(connection, timeout_seconds=timeout_seconds) + for line in render_managed_runtime_verification( + verification, + heading="Waiting for managed runtime to finish starting...", + ): + sink.log(operation, line) + if not managed_runtime_ready(verification): + raise AppOperationError( + f"Managed runtime did not become ready. {verification.detail.strip()}".strip(), + code="remote_error", + ) + + +def request_reboot_and_wait( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + reboot_no_down_message: str, + down_timeout_seconds: int = 60, + up_timeout_seconds: int = 240, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + request_ssh_reboot(operation, sink, connection, shutdown=False) + else: + request_ssh_reboot(operation, sink, connection, shutdown=True) + + sink.stage(operation, "wait_for_reboot_down") + sink.log(operation, "Waiting for the device to go down...") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + sink.log(operation, "Waiting for the device to come back up...") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + sink.log(operation, "Device is back online.") + + +def request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: + try: + if shutdown: + remote_request_shutdown_reboot(connection) + else: + remote_request_reboot(connection) + except SshCommandTimeout as exc: + sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") + return + except SshError as exc: + sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") + return + sink.log(operation, "SSH reboot requested.") diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py new file mode 100644 index 00000000..184be906 --- /dev/null +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from timecapsulesmb.app.contracts import doctor_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.checks.doctor import run_doctor_checks +from timecapsulesmb.checks.models import CheckResult +from timecapsulesmb.cli.doctor import build_doctor_error +from timecapsulesmb.cli.runtime import load_env_config, resolve_env_connection +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.services.app import OperationResult, bool_param, config_path + + +def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "doctor" + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + app_paths = resolve_app_paths(config_path=config_path(params)) + connection = None + if not bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + debug_fields: dict[str, object] = {} + + def on_result(result: CheckResult) -> None: + sink.check(operation, status=result.status, message=result.message, details=result.details) + + sink.stage(operation, "run_checks") + results, fatal = run_doctor_checks( + config, + repo_root=app_paths.distribution_root, + connection=connection, + skip_ssh=bool_param(params, "skip_ssh"), + skip_bonjour=bool_param(params, "skip_bonjour"), + skip_smb=bool_param(params, "skip_smb"), + on_result=on_result, + debug_fields=debug_fields, + ) + error = build_doctor_error(results, debug_fields) if fatal else None + return OperationResult(not fatal, doctor_payload(fatal=fatal, results=results, error=error)) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py new file mode 100644 index 00000000..9799f3b2 --- /dev/null +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import argparse +import shlex +import sys +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +from timecapsulesmb.app.contracts import ( + activation_plan_payload, + activation_result_payload, + fsck_result_payload, + repair_xattrs_payload, + uninstall_plan_payload, + uninstall_result_payload, +) +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.app.ops.deploy import ( + load_config_and_target, + request_reboot_and_wait, + require_supported_payload, + verify_runtime, +) +from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli +from timecapsulesmb.cli.fsck import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + build_remote_fsck_script, + select_fsck_target, + _target_from_volume, +) +from timecapsulesmb.cli.runtime import load_env_config, load_optional_env_config, resolve_env_connection +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME +from timecapsulesmb.core.errors import system_exit_message +from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP +from timecapsulesmb.deploy.dry_run import uninstall_plan_to_jsonable +from timecapsulesmb.deploy.executor import remote_uninstall_payload, run_remote_actions +from timecapsulesmb.deploy.planner import ( + DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + build_netbsd4_activation_plan, + build_uninstall_plan, +) +from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall +from timecapsulesmb.device.compat import is_netbsd4_payload_family +from timecapsulesmb.device.probe import probe_managed_runtime_conn, wait_for_ssh_state_conn +from timecapsulesmb.device.storage import ( + UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER, + mounted_mast_volumes_conn, + read_mast_volumes_conn, +) +from timecapsulesmb.services.app import ( + AppOperationError, + OperationResult, + bool_param, + config_path, + confirm_param, + jsonable, + optional_int_param, + string_param, +) +from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE +from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE, UNINSTALL_REBOOT_NO_DOWN_MESSAGE +from timecapsulesmb.transport.ssh import SshConnection, run_ssh + + +def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "activate" + confirm_activation = confirm_param(params, "confirm_netbsd4_activation") + dry_run = bool_param(params, "dry_run") + _, target = load_config_and_target(operation, params, sink, 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", + ) + sink.stage(operation, "build_activation_plan") + plan = build_netbsd4_activation_plan() + if dry_run: + return OperationResult(True, activation_plan_payload(jsonable(plan))) + if not confirm_activation: + raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, activation_result_payload(already_active=True)) + sink.stage(operation, "run_activation") + run_remote_actions(connection, plan.actions) + verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) + return OperationResult(True, activation_result_payload( + already_active=False, + message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", + )) + + +def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "uninstall" + dry_run = bool_param(params, "dry_run") + no_reboot = bool_param(params, "no_reboot") + confirm_uninstall = confirm_param(params, "confirm_uninstall") + confirm_reboot = confirm_param(params, "confirm_reboot") + if not dry_run and not confirm_uninstall: + raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") + if not dry_run and not no_reboot and not confirm_reboot: + raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + if dry_run: + volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] + payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] + else: + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_mast_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + volume_roots = [volume.volume_root for volume in mounted_volumes] + payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] + sink.stage(operation, "build_uninstall_plan") + plan = build_uninstall_plan(connection.host, volume_roots, payload_dirs, reboot_after_uninstall=not no_reboot) + if dry_run: + return OperationResult(True, uninstall_plan_payload(uninstall_plan_to_jsonable(plan))) + sink.stage(operation, "uninstall_payload") + remote_uninstall_payload(connection, plan) + if no_reboot: + return OperationResult(True, uninstall_result_payload(rebooted=False, verified=False)) + request_reboot_and_wait( + operation, + sink, + connection, + strategy="acp_then_ssh", + reboot_no_down_message=UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + ) + sink.stage(operation, "verify_post_uninstall") + verification = verify_post_uninstall(connection, plan) + for line in render_post_uninstall_verification(verification): + sink.log(operation, line) + if not verification: + raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") + return OperationResult(True, uninstall_result_payload(rebooted=True, verified=True)) + + +def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "fsck" + confirm_fsck = confirm_param(params, "confirm_fsck") + no_reboot = bool_param(params, "no_reboot") + no_wait = bool_param(params, "no_wait") + if not confirm_fsck: + raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") + sink.stage(operation, "load_config") + config = load_env_config(env_path=config_path(params)) + sink.stage(operation, "resolve_connection") + connection = resolve_env_connection(config, allow_empty_password=True) + sink.stage(operation, "read_mast") + mast_volumes = read_mast_volumes_conn(connection) + sink.stage(operation, "mount_hfs_volumes") + mounted_volumes = mounted_mast_volumes_conn( + connection, + mast_volumes, + wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + ) + sink.stage(operation, "select_fsck_volume") + try: + target = select_fsck_target( + tuple(_target_from_volume(volume) for volume in mounted_volumes), + string_param(params, "volume") or None, + prompt=False, + ) + except RuntimeError as exc: + raise AppOperationError(str(exc), code="validation_failed") from exc + sink.stage(operation, "run_fsck") + script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) + proc = run_ssh( + connection, + f"/bin/sh -c {shlex.quote(script)}", + check=False, + timeout=FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, + ) + if proc.stdout: + for line in proc.stdout.splitlines(): + sink.log(operation, line) + if no_reboot: + return OperationResult(proc.returncode == 0, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + returncode=proc.returncode, + )) + if no_wait: + return OperationResult(True, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + waited=False, + )) + observe_reboot_cycle( + operation, + sink, + connection, + reboot_no_down_message=FSCK_REBOOT_NO_DOWN_MESSAGE, + down_timeout_seconds=90, + up_timeout_seconds=420, + ) + return OperationResult(True, fsck_result_payload( + device=target.device, + mountpoint=target.mountpoint, + waited=True, + )) + + +def observe_reboot_cycle( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + reboot_no_down_message: str, + down_timeout_seconds: int, + up_timeout_seconds: int, +) -> None: + sink.stage(operation, "wait_for_reboot_down") + if not wait_for_ssh_state_conn(connection, expected_up=False, timeout_seconds=down_timeout_seconds): + raise AppOperationError(reboot_no_down_message, code="remote_error") + sink.stage(operation, "wait_for_reboot_up") + if not wait_for_ssh_state_conn(connection, expected_up=True, timeout_seconds=up_timeout_seconds): + raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") + + +class RepairContext: + def __init__(self, operation: str, sink: EventSink) -> None: + self.operation = operation + self.sink = sink + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self.sink.stage(self.operation, stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +class StreamLogCapture: + def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: + self.operation = operation + self.sink = sink + self.level = level + self._buffer = "" + + def write(self, text: str) -> int: + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def _emit(self, line: str) -> None: + message = line.rstrip("\r") + if message: + self.sink.log(self.operation, message, level=self.level) + + +def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "repair-xattrs" + dry_run = bool_param(params, "dry_run") + confirm_repair = confirm_param(params, "confirm_repair") + if not dry_run and not confirm_repair: + raise AppOperationError( + "repair-xattrs requires dry_run or explicit confirmation.", + code="confirmation_required", + ) + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + raise AppOperationError( + "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", + code="validation_failed", + ) + sink.stage(operation, "validate_params") + config = load_optional_env_config(env_path=config_path(params)) + args = argparse.Namespace( + path=Path(str(params["path"])) if params.get("path") else None, + dry_run=dry_run, + yes=confirm_repair, + 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"), + ) + context = RepairContext(operation, sink) + stdout_capture = StreamLogCapture(operation, sink, level="info") + stderr_capture = StreamLogCapture(operation, sink, level="warning") + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = repair_xattrs_cli.run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + raise AppOperationError(message, code="operation_failed") from exc + finally: + stdout_capture.flush() + stderr_capture.flush() + return OperationResult(result.returncode == 0, repair_xattrs_payload({ + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "summary": jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error, + })) diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py new file mode 100644 index 00000000..05e10b38 --- /dev/null +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from timecapsulesmb.app.contracts import discover_payload, install_validation_payload, paths_payload +from timecapsulesmb.app.events import EventSink +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.discovery.bonjour import ( + DEFAULT_BROWSE_TIMEOUT_SEC, + BonjourDiscoverySnapshot, + BonjourResolvedService, + discover_snapshot, + discovered_record_root_host, + discovery_record_to_jsonable, + service_instance_to_jsonable, +) +from timecapsulesmb.install_validation import ( + install_checks_to_jsonable, + install_ok, + paths_to_jsonable, + validate_install, +) +from timecapsulesmb.services.app import ( + OperationResult, + config_path, + float_param, +) + + +def selected_record_properties(params: dict[str, object]) -> dict[str, str]: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return {} + properties = selected.get("properties") + if not isinstance(properties, dict): + return {} + return {str(key): str(value) for key, value in properties.items()} + + +def selected_record_host(params: dict[str, object]) -> str: + selected = params.get("selected_record") + if not isinstance(selected, dict): + return "" + record = BonjourResolvedService( + name=str(selected.get("name") or ""), + hostname=str(selected.get("hostname") or ""), + service_type=str(selected.get("service_type") or ""), + port=int(selected.get("port") or 0), + ipv4=tuple(str(ip) for ip in selected.get("ipv4", ()) if ip), + ipv6=tuple(str(ip) for ip in selected.get("ipv6", ()) if ip), + properties=selected_record_properties(params), + fullname=str(selected.get("fullname") or ""), + ) + return discovered_record_root_host(record) or "" + + +def snapshot_payload(snapshot: BonjourDiscoverySnapshot) -> dict[str, object]: + return { + "instances": [service_instance_to_jsonable(instance) for instance in snapshot.instances], + "resolved": [discovery_record_to_jsonable(record) for record in snapshot.resolved], + } + + +def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "discover" + timeout = float_param(params, "timeout", DEFAULT_BROWSE_TIMEOUT_SEC) + sink.stage(operation, "bonjour_discovery") + snapshot = discover_snapshot(timeout=timeout) + return OperationResult(True, discover_payload(snapshot_payload(snapshot))) + + +def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "paths" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "summarize_artifacts") + return OperationResult(True, paths_payload(paths_to_jsonable(app_paths))) + + +def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "validate-install" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "validate_install") + checks = validate_install(app_paths) + ok = install_ok(checks) + for check in checks: + sink.check( + operation, + status="PASS" if check.ok else "FAIL", + message=check.message, + details=check.details, + ) + return OperationResult(ok, install_validation_payload(ok=ok, checks=install_checks_to_jsonable(checks))) diff --git a/src/timecapsulesmb/app/recovery.py b/src/timecapsulesmb/app/recovery.py new file mode 100644 index 00000000..e32e7240 --- /dev/null +++ b/src/timecapsulesmb/app/recovery.py @@ -0,0 +1,288 @@ +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 + docs_anchor: str | None = None + + def to_jsonable(self) -> dict[str, object]: + payload: dict[str, object] = { + "title": self.title, + "message": self.message, + "actions": list(self.actions), + "retryable": self.retryable, + "suggested_operation": self.suggested_operation, + } + if self.docs_anchor: + payload["docs_anchor"] = self.docs_anchor + return payload + + +_DEFAULTS: dict[str, RecoveryInfo] = { + "invalid_request": RecoveryInfo( + "Invalid request", + "The helper request was malformed or had invalid parameter types.", + ("Check the request JSON shape.", "Send params as a JSON object."), + retryable=True, + ), + "unknown_operation": RecoveryInfo( + "Unknown operation", + "The helper does not recognize the requested operation.", + ("Use one of the helper operations exposed by this app version.",), + retryable=False, + ), + "validation_failed": RecoveryInfo( + "Request validation failed", + "One or more operation parameters were missing or invalid.", + ("Review the highlighted fields.", "Retry with valid values."), + retryable=True, + ), + "config_error": RecoveryInfo( + "Configuration error", + "The current .env configuration could not be read or used.", + ("Open the configuration step.", "Verify host, password, and SSH options."), + retryable=True, + suggested_operation="configure", + ), + "auth_failed": RecoveryInfo( + "Authentication failed", + "The Time Capsule rejected the supplied password or SSH credentials.", + ("Re-enter the AirPort admin password.", "Verify that SSH is enabled on the device."), + retryable=True, + suggested_operation="configure", + ), + "unsupported_device": RecoveryInfo( + "Unsupported device", + "The detected AirPort model or OS does not have a deployable payload in this build.", + ("Check the detected model and OS.", "Use the CLI only if you intentionally pass unsupported-device overrides."), + retryable=False, + ), + "confirmation_required": RecoveryInfo( + "Confirmation required", + "This operation changes the device and needs explicit confirmation.", + ("Review the plan.", "Confirm the operation in the app before retrying."), + retryable=True, + ), + "remote_error": RecoveryInfo( + "Remote operation failed", + "The helper could not complete the requested remote device operation.", + ("Check the operation log.", "Run doctor after the device is reachable."), + retryable=True, + suggested_operation="doctor", + ), + "operation_failed": RecoveryInfo( + "Operation failed", + "The helper hit an unexpected failure while running the operation.", + ("Check debug details.", "Retry after fixing the reported cause."), + retryable=True, + ), +} + + +_OPERATION_CODE_RECOVERY: dict[tuple[str, str], RecoveryInfo] = { + ("configure", "auth_failed"): RecoveryInfo( + "AirPort password rejected", + "ACP or SSH authentication failed while configuring the device.", + ("Re-enter the AirPort admin password.", "Confirm the selected device is the intended Time Capsule."), + retryable=True, + suggested_operation="configure", + ), + ("configure", "unsupported_device"): RecoveryInfo( + "Unsupported Time Capsule", + "The SSH probe succeeded, but the detected hardware or OS cannot use a bundled payload.", + ("Review the detected model and OS.", "Use a supported Gen 4 or Gen 5 Time Capsule."), + retryable=False, + ), + ("deploy", "confirmation_required"): RecoveryInfo( + "Deploy confirmation required", + "Deploy needs confirmation before uploading payload files, rebooting, or activating NetBSD4.", + ("Review the deploy plan.", "Confirm deploy and any required reboot or activation prompt."), + retryable=True, + ), + ("deploy", "validation_failed"): RecoveryInfo( + "Deployment validation failed", + "The bundled payload artifacts or deployment inputs are invalid.", + ("Open Readiness.", "Fix missing artifacts or invalid fields before retrying."), + retryable=True, + suggested_operation="validate-install", + ), + ("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, + ), + ("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, + ), + ("fsck", "confirmation_required"): RecoveryInfo( + "fsck confirmation required", + "fsck stops file sharing, unmounts the selected HFS disk, and may reboot the device.", + ("Review the selected volume.", "Confirm fsck before retrying."), + retryable=True, + ), + ("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, + ), + ("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, + ), + ("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, + ), +} + + +_STAGE_RECOVERY: dict[tuple[str, str, str], RecoveryInfo] = { + ("configure", "remote_error", "acp_enable_ssh"): RecoveryInfo( + "ACP SSH enablement failed", + "The helper could not enable SSH through AirPort ACP.", + ("Verify the AirPort admin password.", "Power-cycle the device if AirPort Utility also cannot manage it."), + retryable=True, + suggested_operation="configure", + ), + ("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 Time Capsule.", "Retry deploy after it is reachable."), + retryable=True, + suggested_operation="doctor", + ), + ("deploy", "remote_error", "wait_for_reboot_up"): RecoveryInfo( + "Reboot did not finish", + "The device went down but SSH did not return before the timeout.", + ("Wait a few more minutes.", "Power-cycle the device if needed.", "Run doctor once SSH returns."), + retryable=True, + suggested_operation="doctor", + ), + ("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", + ), + ("deploy", "remote_error", "verify_runtime_activation"): RecoveryInfo( + "Activated runtime not ready", + "The NetBSD4 runtime was started but did not become healthy.", + ("Retry activation.", "Run doctor for detailed runtime checks."), + retryable=True, + suggested_operation="doctor", + ), + ("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", + ), + ("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", + ), + ("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", + ), + ("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", + ), + ("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", + ), + ("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", + ), +} + + +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/service.py b/src/timecapsulesmb/app/service.py index d434ab14..ca796394 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -4,22 +4,10 @@ from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact -from timecapsulesmb.app.operations import ( - OPERATIONS, - AppOperationError, - OperationResult, - activate_operation, - configure_operation, - deploy_operation, - discover_operation, - doctor_operation, - fsck_operation, - paths_operation, - repair_xattrs_operation, - uninstall_operation, - validate_install_operation, -) +from timecapsulesmb.app.operations import OPERATIONS +from timecapsulesmb.app.recovery import recovery_for from timecapsulesmb.core.config import ConfigError +from timecapsulesmb.services.app import AppOperationError, OperationResult from timecapsulesmb.transport.errors import TransportError @@ -41,25 +29,58 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: operation = _request_operation(request) params = _request_params(request) if not operation: - sink.error("api", "missing required field: operation", code="invalid_request") + sink.error( + "api", + "missing required field: operation", + code="invalid_request", + recovery=recovery_for("api", "invalid_request"), + ) return 1 if not isinstance(params, dict): - sink.error(operation, "params must be a JSON object", code="invalid_request") + sink.error( + operation, + "params must be a JSON object", + code="invalid_request", + recovery=recovery_for(operation, "invalid_request"), + ) return 1 handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) if handler is None: - sink.error(operation, f"unknown operation: {operation}", code="unknown_operation", debug={"known_operations": sorted(OPERATIONS)}) + sink.error( + operation, + f"unknown operation: {operation}", + code="unknown_operation", + debug={"known_operations": sorted(OPERATIONS)}, + recovery=recovery_for(operation, "unknown_operation"), + ) return 1 try: result = handler(params, sink) except AppOperationError as exc: - sink.error(operation, str(exc), code=exc.code, debug=redact(exc.debug) if exc.debug is not None else None) + recovery = exc.recovery or recovery_for(operation, exc.code, stage=sink.current_stage(operation)) + sink.error( + operation, + str(exc), + code=exc.code, + debug=redact(exc.debug) if exc.debug is not None else None, + recovery=recovery, + ) return 1 except ConfigError as exc: - sink.error(operation, str(exc), code="config_error") + sink.error( + operation, + str(exc), + code="config_error", + recovery=recovery_for(operation, "config_error", stage=sink.current_stage(operation)), + ) return 1 except TransportError as exc: - sink.error(operation, str(exc), code="remote_error") + sink.error( + operation, + str(exc), + code="remote_error", + recovery=recovery_for(operation, "remote_error", stage=sink.current_stage(operation)), + ) return 1 except (SystemExit, KeyboardInterrupt): raise @@ -69,6 +90,7 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: f"{type(exc).__name__}: {exc}", code="operation_failed", debug={"traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))}, + recovery=recovery_for(operation, "operation_failed", stage=sink.current_stage(operation)), ) return 1 sink.result(operation, ok=result.ok, payload=result.payload) diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py new file mode 100644 index 00000000..263fc478 --- /dev/null +++ b/src/timecapsulesmb/app/stage_policy.py @@ -0,0 +1,107 @@ +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] = { + ("discover", "bonjour_discovery"): StagePolicy(LOCAL_READ, True, "Browse for AirPort Bonjour services."), + ("paths", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve configuration, state, and distribution paths."), + ("paths", "summarize_artifacts"): StagePolicy(LOCAL_READ, True, "Summarize bundled artifact paths."), + ("validate-install", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve app installation paths."), + ("validate-install", "validate_install"): StagePolicy(LOCAL_READ, True, "Validate local helper and artifact prerequisites."), + ("configure", "load_existing_config"): StagePolicy(LOCAL_READ, True, "Read the existing .env configuration."), + ("configure", "ssh_probe"): StagePolicy(REMOTE_READ, True, "Probe SSH reachability and device compatibility."), + ("configure", "acp_enable_ssh"): StagePolicy(REMOTE_WRITE, False, "Request SSH enablement through AirPort ACP."), + ("configure", "wait_for_ssh_after_acp"): StagePolicy(REMOTE_READ, True, "Wait for SSH to open after ACP enablement."), + ("configure", "ssh_probe_after_acp"): StagePolicy(REMOTE_READ, True, "Probe SSH again after ACP enablement."), + ("configure", "write_env"): StagePolicy(LOCAL_WRITE, False, "Write the app .env configuration."), + ("deploy", "load_config"): StagePolicy(LOCAL_READ, True, "Read deployment configuration."), + ("deploy", "resolve_managed_target"): StagePolicy(REMOTE_READ, True, "Resolve and probe the managed Time Capsule target."), + ("deploy", "validate_artifacts"): StagePolicy(LOCAL_READ, True, "Validate bundled payload artifacts."), + ("deploy", "check_compatibility"): StagePolicy(REMOTE_READ, True, "Check detected device compatibility."), + ("deploy", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("deploy", "select_payload_home"): StagePolicy(REMOTE_READ, True, "Select a writable HFS payload location."), + ("deploy", "build_deployment_plan"): StagePolicy(LOCAL_READ, True, "Build the deployment action plan."), + ("deploy", "pre_upload_actions"): StagePolicy(REMOTE_WRITE, False, "Prepare remote directories and stop conflicting processes."), + ("deploy", "prepare_deployment_files"): StagePolicy(LOCAL_WRITE, True, "Generate temporary deployment config files."), + ("deploy", "upload_payload"): StagePolicy(REMOTE_WRITE, False, "Upload managed Samba payload files."), + ("deploy", "post_upload_actions"): StagePolicy(REMOTE_WRITE, False, "Install flash hooks and payload permissions."), + ("deploy", "verify_payload_upload"): StagePolicy(REMOTE_READ, True, "Verify uploaded payload files."), + ("deploy", "flush_payload_upload"): StagePolicy(REMOTE_WRITE, False, "Flush remote filesystem writes."), + ("deploy", "verify_payload_upload_after_sync"): StagePolicy(REMOTE_READ, True, "Verify uploaded payload files after sync."), + ("deploy", "netbsd4_activation"): StagePolicy(REMOTE_WRITE, False, "Start the deployed NetBSD4 runtime."), + ("deploy", "verify_runtime_activation"): StagePolicy(REMOTE_READ, True, "Wait for the activated runtime to become ready."), + ("deploy", "reboot"): StagePolicy(REBOOT, False, "Request a device reboot."), + ("deploy", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after reboot request."), + ("deploy", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after reboot."), + ("deploy", "verify_runtime_reboot"): StagePolicy(REMOTE_READ, True, "Wait for the managed runtime after reboot."), + ("doctor", "load_config"): StagePolicy(LOCAL_READ, True, "Read diagnostic configuration."), + ("doctor", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("doctor", "run_checks"): StagePolicy(REMOTE_READ, True, "Run local and remote diagnostic checks."), + ("activate", "load_config"): StagePolicy(LOCAL_READ, True, "Read activation configuration."), + ("activate", "resolve_managed_target"): StagePolicy(REMOTE_READ, True, "Resolve and probe the NetBSD4 target."), + ("activate", "build_activation_plan"): StagePolicy(LOCAL_READ, True, "Build the NetBSD4 activation action plan."), + ("activate", "probe_runtime"): StagePolicy(REMOTE_READ, True, "Check whether the NetBSD4 runtime is already ready."), + ("activate", "run_activation"): StagePolicy(REMOTE_WRITE, False, "Run NetBSD4 activation commands."), + ("activate", "verify_runtime_activation"): StagePolicy(REMOTE_READ, True, "Wait for the activated runtime to become ready."), + ("uninstall", "load_config"): StagePolicy(LOCAL_READ, True, "Read uninstall configuration."), + ("uninstall", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("uninstall", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("uninstall", "mount_mast_volumes"): StagePolicy(REMOTE_WRITE, False, "Mount HFS volumes before uninstall."), + ("uninstall", "build_uninstall_plan"): StagePolicy(LOCAL_READ, True, "Build the uninstall action plan."), + ("uninstall", "uninstall_payload"): StagePolicy(DESTRUCTIVE, False, "Remove managed payload files and flash hooks."), + ("uninstall", "reboot"): StagePolicy(REBOOT, False, "Request a device reboot."), + ("uninstall", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after reboot request."), + ("uninstall", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after reboot."), + ("uninstall", "verify_post_uninstall"): StagePolicy(REMOTE_READ, True, "Verify managed files are absent after reboot."), + ("fsck", "load_config"): StagePolicy(LOCAL_READ, True, "Read fsck configuration."), + ("fsck", "resolve_connection"): StagePolicy(REMOTE_READ, True, "Resolve the configured SSH connection."), + ("fsck", "read_mast"): StagePolicy(REMOTE_READ, True, "Read mounted HFS volume metadata from MaSt."), + ("fsck", "mount_hfs_volumes"): StagePolicy(REMOTE_WRITE, False, "Mount HFS volumes before fsck."), + ("fsck", "select_fsck_volume"): StagePolicy(REMOTE_READ, True, "Select the HFS volume to repair."), + ("fsck", "run_fsck"): StagePolicy(DESTRUCTIVE, False, "Unmount the selected disk and run fsck_hfs."), + ("fsck", "wait_for_reboot_down"): StagePolicy(REBOOT, True, "Wait for SSH to go down after fsck reboot."), + ("fsck", "wait_for_reboot_up"): StagePolicy(REBOOT, True, "Wait for SSH to return after fsck reboot."), + ("repair-xattrs", "platform_check"): StagePolicy(LOCAL_READ, True, "Verify repair-xattrs is running on macOS."), + ("repair-xattrs", "validate_params"): StagePolicy(LOCAL_READ, True, "Validate repair-xattrs request parameters."), + ("repair-xattrs", "resolve_scan_root"): StagePolicy(LOCAL_READ, True, "Resolve the mounted SMB share scan root."), + ("repair-xattrs", "scan_findings"): StagePolicy(LOCAL_READ, True, "Scan local mounted SMB files for xattr problems."), + ("repair-xattrs", "report_findings"): StagePolicy(LOCAL_READ, True, "Render xattr findings and repair candidates."), + ("repair-xattrs", "confirm_repair"): StagePolicy(LOCAL_READ, True, "Confirm local metadata repairs."), + ("repair-xattrs", "repair_findings"): StagePolicy(DESTRUCTIVE, False, "Repair local file metadata on the mounted SMB share."), +} + + +def stage_policy(operation: str, stage: str) -> StagePolicy | None: + return _POLICIES.get((operation, stage)) diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index 1e52a652..8be80836 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -15,16 +15,11 @@ require_supported_device_compatibility, ) from timecapsulesmb.core.config import ( - DEFAULTS, MANAGED_PAYLOAD_DIR_NAME, - AppConfig, airport_family_display_name_from_identity, - parse_bool, - shell_quote, ) from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP, NETBSD4_REBOOT_GUIDANCE from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.deploy.artifact_resolver import resolve_payload_artifacts from timecapsulesmb.deploy.artifacts import validate_artifacts @@ -36,8 +31,6 @@ BINARY_NBNS_SOURCE, BINARY_SMBD_SOURCE, DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - DEFAULT_ATA_IDLE_SECONDS, - DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, GENERATED_FLASH_CONFIG_SOURCE, GENERATED_SMBPASSWD_SOURCE, GENERATED_USERNAME_MAP_SOURCE, @@ -55,66 +48,21 @@ from timecapsulesmb.device.storage import ( MAST_DISCOVERY_ATTEMPTS, MAST_DISCOVERY_DELAY_SECONDS, - PayloadHome, - PayloadVerificationResult, build_dry_run_payload_home, verify_payload_home_conn, ) +from timecapsulesmb.services.deploy import ( + DEPLOY_REBOOT_NO_DOWN_MESSAGE as REBOOT_NO_DOWN_MESSAGE, + no_mast_volumes_message, + no_writable_mast_volumes_message, + payload_verification_error, + render_flash_runtime_config, +) from timecapsulesmb.device.probe import read_interface_ipv4_addrs_conn from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.cli.util import color_green, color_red -REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." -) - - -def _no_mast_volumes_message(*, attempts: int, delay_seconds: int) -> str: - return ( - f"No deployable HFS disk was found after {attempts} MaSt queries " - f"spaced {delay_seconds} seconds apart." - ) - - -def _no_writable_mast_volumes_message(volume_count: int) -> str: - return f"MaSt found {volume_count} deployable HFS volume(s), but deploy could not write to any of them." - - -def _render_flash_config_assignment(key: str, value: str | int) -> str: - if isinstance(value, int): - return f"{key}={value}" - return f"{key}={shell_quote(value)}" - - -def render_flash_runtime_config( - config: AppConfig, - payload_home: PayloadHome, - *, - nbns_enabled: bool, - debug_logging: bool, - ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, - diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, -) -> str: - internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) - any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) - - values: list[tuple[str, str | int]] = [ - ("TC_CONFIG_VERSION", 2), - ("TC_DEPLOY_RELEASE_TAG", RELEASE_TAG), - ("TC_DEPLOY_CLI_VERSION_CODE", CLI_VERSION_CODE), - ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if parse_bool(internal_root_default) else 0), - ("ANY_PROTOCOL", 1 if parse_bool(any_protocol_default) else 0), - ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), - ("ATA_IDLE_SECONDS", ata_idle_seconds), - ("NBNS_ENABLED", 1 if nbns_enabled else 0), - ("SMBD_DEBUG_LOGGING", 1 if debug_logging else 0), - ("MDNS_DEBUG_LOGGING", 1 if debug_logging else 0), - ] - return "\n".join(_render_flash_config_assignment(key, value) for key, value in values) + "\n" - - def _target_family_display_name(target) -> str: probe = target.probe_state.probe_result if target.probe_state is not None else None return airport_family_display_name_from_identity( @@ -123,10 +71,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 _non_negative_int(value: str) -> int: try: parsed = int(value) @@ -212,7 +156,7 @@ def main(argv: Optional[list[str]] = None) -> int: mast_volumes = mast_discovery.volumes if not mast_volumes: raise SystemExit( - _no_mast_volumes_message( + no_mast_volumes_message( attempts=MAST_DISCOVERY_ATTEMPTS, delay_seconds=MAST_DISCOVERY_DELAY_SECONDS, ) @@ -224,7 +168,7 @@ def main(argv: Optional[list[str]] = None) -> int: wait_seconds=apple_mount_wait_seconds, ) if selection.payload_home is None: - raise SystemExit(_no_writable_mast_volumes_message(len(mast_volumes))) + raise SystemExit(no_writable_mast_volumes_message(len(mast_volumes))) payload_home = selection.payload_home command_context.set_stage("build_deployment_plan") plan = build_deployment_plan( @@ -318,7 +262,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.add_debug_fields(payload_upload_verification=payload_verification.detail) if not payload_verification.ok: - raise SystemExit(_payload_verification_error(payload_home, payload_verification)) + raise SystemExit(payload_verification_error(payload_home, payload_verification)) command_context.set_stage("flush_payload_upload") if not args.json: @@ -336,7 +280,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.add_debug_fields(payload_post_sync_verification=payload_verification.detail) if not payload_verification.ok: - raise SystemExit(_payload_verification_error(payload_home, payload_verification)) + raise SystemExit(payload_verification_error(payload_home, payload_verification)) print(f"Deployed Samba payload to {plan.payload_dir}") print("Updated /mnt/Flash boot files.") diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index 09bb6860..177b52ff 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -11,6 +11,7 @@ from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json from timecapsulesmb.cli.util import color_green, color_red from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.doctor import doctor_status_counts from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.core.paths import resolve_app_paths @@ -283,7 +284,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/fsck.py b/src/timecapsulesmb/cli/fsck.py index c3aa3e0e..9957a258 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -12,11 +12,11 @@ from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.device.storage import MaStVolume +from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE 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" diff --git a/src/timecapsulesmb/cli/uninstall.py b/src/timecapsulesmb/cli/uninstall.py index 771d5cc7..3cf3b0a4 100644 --- a/src/timecapsulesmb/cli/uninstall.py +++ b/src/timecapsulesmb/cli/uninstall.py @@ -13,15 +13,10 @@ from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall from timecapsulesmb.device.storage import UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER from timecapsulesmb.identity import ensure_install_id +from timecapsulesmb.services.maintenance import UNINSTALL_REBOOT_NO_DOWN_MESSAGE as REBOOT_NO_DOWN_MESSAGE from timecapsulesmb.telemetry import TelemetryClient -REBOOT_NO_DOWN_MESSAGE = ( - "Reboot was requested but the device did not go down.\n" - "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." -) - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Remove the managed TimeCapsuleSMB payload from the configured device.") add_config_argument(parser) diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index 1468a5ec..5194855e 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, is_dataclass +import math from pathlib import Path @@ -11,10 +12,12 @@ def __init__( *, 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) @@ -68,6 +71,36 @@ def int_param(params: dict[str, object], name: str, default: int) -> int: return parsed +def optional_int_param(params: dict[str, object], name: str) -> int | None: + value = params.get(name) + if value in (None, ""): + return None + if isinstance(value, bool): + raise AppOperationError(f"{name} must be an integer", code="validation_failed") + 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 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) diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py new file mode 100644 index 00000000..65a086b2 --- /dev/null +++ b/src/timecapsulesmb/services/configure.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from timecapsulesmb.core.config import DEFAULTS, parse_bool, preserved_env_file_values + + +def build_configure_env_values( + existing: dict[str, str], + *, + host: str, + password: str, + ssh_opts: str, + configure_id: str, + internal_share_use_disk_root: bool | None = None, + any_protocol: bool | None = None, +) -> 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_CONFIGURE_ID": configure_id, + }) + return values diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py new file mode 100644 index 00000000..90bd9da2 --- /dev/null +++ b/src/timecapsulesmb/services/deploy.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from timecapsulesmb.core.config import DEFAULTS, AppConfig, parse_bool, shell_quote +from timecapsulesmb.core.release import CLI_VERSION_CODE, RELEASE_TAG +from timecapsulesmb.deploy.planner import DEFAULT_ATA_IDLE_SECONDS, DEFAULT_DISKD_USE_VOLUME_ATTEMPTS +from timecapsulesmb.device.storage import PayloadHome, PayloadVerificationResult + + +DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE = "Timed out waiting for SSH after reboot." +DEPLOY_REBOOT_NO_DOWN_MESSAGE = ( + "Reboot was requested but the device did not go down.\n" + "The deploy stopped the managed runtime before reboot; power-cycle or rerun deploy." +) + + +def no_mast_volumes_message(*, attempts: int, delay_seconds: int) -> str: + return ( + f"No deployable HFS disk was found after {attempts} MaSt queries " + f"spaced {delay_seconds} seconds apart." + ) + + +def no_writable_mast_volumes_message(volume_count: int) -> str: + return f"MaSt found {volume_count} deployable HFS volume(s), but deploy could not write to any of them." + + +def payload_verification_error(payload_home: PayloadHome, result: PayloadVerificationResult) -> str: + return f"managed payload verification failed at {payload_home.payload_dir}: {result.detail}" + + +def _render_flash_config_assignment(key: str, value: str | int) -> str: + if isinstance(value, int): + return f"{key}={value}" + return f"{key}={shell_quote(value)}" + + +def render_flash_runtime_config( + config: AppConfig, + payload_home: PayloadHome, + *, + nbns_enabled: bool, + debug_logging: bool, + ata_idle_seconds: int = DEFAULT_ATA_IDLE_SECONDS, + diskd_use_volume_attempts: int = DEFAULT_DISKD_USE_VOLUME_ATTEMPTS, +) -> str: + internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) + any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) + + values: list[tuple[str, str | int]] = [ + ("TC_CONFIG_VERSION", 2), + ("TC_DEPLOY_RELEASE_TAG", RELEASE_TAG), + ("TC_DEPLOY_CLI_VERSION_CODE", CLI_VERSION_CODE), + ("INTERNAL_SHARE_USE_DISK_ROOT", 1 if parse_bool(internal_root_default) else 0), + ("ANY_PROTOCOL", 1 if parse_bool(any_protocol_default) else 0), + ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), + ("ATA_IDLE_SECONDS", ata_idle_seconds), + ("NBNS_ENABLED", 1 if nbns_enabled else 0), + ("SMBD_DEBUG_LOGGING", 1 if debug_logging else 0), + ("MDNS_DEBUG_LOGGING", 1 if debug_logging else 0), + ] + return "\n".join(_render_flash_config_assignment(key, value) for key, value in values) + "\n" diff --git a/src/timecapsulesmb/services/doctor.py b/src/timecapsulesmb/services/doctor.py new file mode 100644 index 00000000..c8737d37 --- /dev/null +++ b/src/timecapsulesmb/services/doctor.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from timecapsulesmb.checks.models import CheckResult + + +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") + } diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py new file mode 100644 index 00000000..cd260378 --- /dev/null +++ b/src/timecapsulesmb/services/maintenance.py @@ -0,0 +1,8 @@ +from __future__ import annotations + + +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." diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 5c754e10..f0d6a722 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -18,7 +18,7 @@ from timecapsulesmb.app.events import AppEvent, EventSink from timecapsulesmb import repair_xattrs as repair_xattrs_domain -from timecapsulesmb.app import helper, operations, service +from timecapsulesmb.app import contracts, helper, operations, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file @@ -148,6 +148,61 @@ def test_result_event_preserves_falsey_payloads(self) -> None: self.assertEqual(result["schema_version"], 1) self.assertTrue(result["request_id"]) + def test_stage_events_include_policy_metadata(self) -> None: + collector = CollectingSink() + + collector.sink.stage("paths", "resolve_paths") + collector.sink.stage("deploy", "upload_payload") + collector.sink.stage("uninstall", "uninstall_payload") + collector.sink.stage("deploy", "reboot") + + stages = collector.events_of_type("stage") + self.assertEqual(stages[0]["risk"], "local_read") + self.assertTrue(stages[0]["cancellable"]) + self.assertEqual(stages[1]["risk"], "remote_write") + self.assertEqual(stages[2]["risk"], "destructive") + self.assertEqual(stages[3]["risk"], "reboot") + self.assertIn("description", stages[3]) + + def test_contract_builders_keep_stable_representative_shapes(self) -> None: + deploy_plan = contracts.deploy_plan_payload( + {"host": "root@10.0.0.2", "reboot_required": True}, + payload_family="netbsd6_samba4", + netbsd4=False, + ) + self.assertEqual(deploy_plan, { + "host": "root@10.0.0.2", + "reboot_required": True, + "requires_reboot": True, + "payload_family": "netbsd6_samba4", + "netbsd4": False, + "summary": "deployment dry-run plan generated.", + "schema_version": 1, + }) + + doctor = contracts.doctor_payload( + fatal=True, + results=[ + CheckResult("PASS", "ok"), + CheckResult("WARN", "slow"), + CheckResult("FAIL", "bad"), + ], + error="Doctor failures:\nFAIL bad", + ) + self.assertEqual(doctor["counts"], {"PASS": 1, "WARN": 1, "FAIL": 1, "INFO": 0}) + self.assertEqual(doctor["summary"], "doctor found one or more fatal problems.") + self.assertEqual(doctor["schema_version"], 1) + + repair = contracts.repair_xattrs_payload({ + "returncode": 0, + "root": "/Volumes/Data", + "finding_count": 2, + "repairable_count": 1, + "summary": {"scanned": 3}, + }) + self.assertEqual(repair["summary"], {"scanned": 3}) + self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + def test_request_id_propagates_to_every_event(self) -> None: collector = CollectingSink() @@ -176,6 +231,8 @@ def test_missing_operation_emits_invalid_request_error(self) -> None: 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() @@ -185,6 +242,7 @@ def test_unknown_operation_emits_error_without_result(self) -> None: 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() @@ -214,6 +272,7 @@ def fail(_params, _sink, exc=exception): 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() @@ -253,6 +312,38 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: result = collector.events_of_type("result")[0] self.assertEqual(result["payload"]["resolved"][0]["name"], "TC") self.assertEqual(result["payload"]["resolved"][0]["ipv4"], ["10.0.0.2"]) + self.assertEqual(result["payload"]["schema_version"], 1) + self.assertEqual(result["payload"]["counts"], {"instances": 1, "resolved": 1}) + self.assertEqual(result["payload"]["summary"], "discovered 1 resolved AirPort service(s).") + + 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.operations.discover_snapshot") as discover: + rc = service.run_api_request( + {"operation": "discover", "params": {"timeout": timeout}}, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "Request validation failed") + discover.assert_not_called() + + def test_discover_accepts_numeric_timeout_string(self) -> None: + collector = CollectingSink() + snapshot = BonjourDiscoverySnapshot(instances=[], resolved=[]) + + with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot) as discover: + rc = service.run_api_request( + {"operation": "discover", "params": {"timeout": "0.25"}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + discover.assert_called_once_with(timeout=0.25) def test_configure_writes_env_without_leaking_password_to_events(self) -> None: collector = CollectingSink() @@ -331,6 +422,7 @@ def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: 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.assertNotIn("badpw", json.dumps(collector.events)) def test_configure_reports_unsupported_device(self) -> None: @@ -424,6 +516,9 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> result = collector.events_of_type("result")[0] self.assertEqual(result["payload"]["host"], "root@10.0.0.2") self.assertEqual(result["payload"]["reboot_required"], True) + self.assertEqual(result["payload"]["requires_reboot"], True) + self.assertEqual(result["payload"]["payload_family"], "netbsd6_samba4") + self.assertEqual(result["payload"]["schema_version"], 1) def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None: collector = CollectingSink() @@ -560,7 +655,9 @@ def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: ) self.assertEqual(rc, 1) - self.assertEqual(collector.events_of_type("error")[0]["code"], "remote_error") + error = collector.events_of_type("error")[0] + self.assertEqual(error["code"], "remote_error") + self.assertEqual(error["recovery"]["title"], "No HFS volumes found") def test_activate_requires_explicit_confirmation(self) -> None: collector = CollectingSink() @@ -598,7 +695,9 @@ def test_activate_accepts_yes_alias_for_confirmation(self) -> None: self.assertEqual(rc, 0) result = self.assert_single_terminal_event(collector, "result") - self.assertEqual(result["payload"], {"already_active": True}) + 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: @@ -644,6 +743,8 @@ def test_uninstall_dry_run_bypasses_confirmation_and_returns_plan(self) -> None: 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_fsck_requires_confirmation_before_remote_connection(self) -> None: @@ -718,6 +819,62 @@ def fake_runner(*_args, **_kwargs): self.assertIn({"info": "stdout detail"}, [{log["level"]: log["message"]} for log in logs]) self.assertIn({"warning": "stderr detail"}, [{log["level"]: log["message"]} for log in logs]) + def test_repair_xattrs_rejects_invalid_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.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": { + "path": "/Volumes/Data", + "dry_run": True, + "max_depth": max_depth, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "Invalid repair options") + runner.assert_not_called() + + def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: + collector = CollectingSink() + summary = repair_xattrs_domain.RepairSummary(scanned=1) + repair_result = SimpleNamespace( + returncode=0, + root=Path("/Volumes/Data"), + findings=[], + candidates=[], + summary=summary, + report=None, + ) + + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": { + "path": "/Volumes/Data", + "dry_run": True, + "max_depth": "2", + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + args = runner.call_args.args[0] + self.assertEqual(args.max_depth, 2) + def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() @@ -733,6 +890,7 @@ def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], "confirmation_required") + self.assertEqual(error["recovery"]["title"], "Repair confirmation required") runner.assert_not_called() def test_helper_reads_request_and_writes_ndjson(self) -> None: From a10d8153d4afac903d8a4987f50f2d6dfceb1282 Mon Sep 17 00:00:00 2001 From: James Chang Date: Tue, 19 May 2026 23:04:26 -0700 Subject: [PATCH 05/20] Persist debug logging configuration across configure and deploy --- .../TimeCapsuleSMBApp/ContentView.swift | 21 +++++-- .../PendingConfirmation.swift | 5 +- .../PendingConfirmationTests.swift | 3 +- src/timecapsulesmb/app/ops/configure.py | 5 ++ src/timecapsulesmb/cli/configure.py | 7 +++ src/timecapsulesmb/core/config.py | 5 ++ src/timecapsulesmb/services/configure.py | 6 ++ src/timecapsulesmb/services/deploy.py | 6 +- tests/test_app_api.py | 26 ++++++++ tests/test_cli.py | 61 +++++++++++++++++++ tests/test_config.py | 8 +++ tests/test_storage_runtime.py | 13 ++++ 12 files changed, 157 insertions(+), 9 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index f8e6eddc..d84cbd08 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -10,6 +10,8 @@ public struct ContentView: View { @State private var nbnsEnabled = true @State private var noReboot = false @State private var dryRun = true + @State private var configureDebugLogging = false + @State private var deployDebugLogging = false @State private var pendingConfirmation: PendingConfirmation? public init() {} @@ -88,13 +90,18 @@ public struct ContentView: View { CommandPanel(title: "Discover And Connect") { TextField("Host", text: $host) SecureField("Password", text: $password) + Toggle("Enable Debug Logging", isOn: $configureDebugLogging) HStack { runButton("Discover", icon: "network", operation: "discover") Button { - backend.run(operation: "configure", params: [ + var params: [String: JSONValue] = [ "host": .string(host), "password": .string(password) - ]) + ] + if configureDebugLogging { + params["debug_logging"] = .bool(true) + } + backend.run(operation: "configure", params: params) } label: { Label("Configure", systemImage: "lock.open") } @@ -106,15 +113,21 @@ public struct ContentView: View { Toggle("Enable NBNS", isOn: $nbnsEnabled) Toggle("No Reboot", isOn: $noReboot) Toggle("Dry Run", isOn: $dryRun) + Toggle("Force Debug Logging", isOn: $deployDebugLogging) Button { if dryRun { backend.run(operation: "deploy", params: [ "dry_run": .bool(true), "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled) + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(deployDebugLogging) ]) } else { - pendingConfirmation = .deploy(noReboot: noReboot, nbnsEnabled: nbnsEnabled) + pendingConfirmation = .deploy( + noReboot: noReboot, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging + ) } } label: { Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 09bbb35f..3ce7286d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -8,7 +8,7 @@ struct PendingConfirmation: Identifiable { let operation: String let params: [String: JSONValue] - static func deploy(noReboot: Bool, nbnsEnabled: Bool) -> PendingConfirmation { + static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool) -> PendingConfirmation { PendingConfirmation( title: noReboot ? "Deploy Without Reboot?" : "Deploy And Reboot?", message: noReboot @@ -22,7 +22,8 @@ struct PendingConfirmation: Identifiable { "confirm_reboot": .bool(!noReboot), "confirm_netbsd4_activation": .bool(true), "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled) + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging) ] ) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index 2861050b..ab8bb8f7 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -3,7 +3,7 @@ import XCTest final class PendingConfirmationTests: XCTestCase { func testDeployConfirmationCarriesDeployAndRebootConsent() { - let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true) + let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true) XCTAssertEqual(confirmation.operation, "deploy") XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) @@ -12,6 +12,7 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(confirmation.params["confirm_netbsd4_activation"], .bool(true)) XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) + XCTAssertEqual(confirmation.params["debug_logging"], .bool(true)) } func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index d8ec9600..5e9b7b54 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -65,6 +65,11 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation "any_protocol", parse_bool(existing.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"])), ), + debug_logging=bool_param( + params, + "debug_logging", + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])), + ), ) sink.stage(operation, "ssh_probe") diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index ea381c15..f4b10037 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -297,6 +297,7 @@ def main(argv: Optional[list[str]] = None) -> int: add_config_argument(parser) parser.add_argument("--internal-share-use-disk-root", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--any-protocol", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args(argv) ensure_install_id() @@ -357,6 +358,12 @@ def main(argv: Optional[list[str]] = None) -> int: values["TC_ANY_PROTOCOL"] = ( "true" if args.any_protocol or existing_any_protocol else "false" ) + existing_debug_logging = parse_bool( + existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + ) + values["TC_DEBUG_LOGGING"] = ( + "true" if args.debug_logging or existing_debug_logging else "false" + ) command_context.set_stage("bonjour_discovery") try: discovered_record = discover_default_record(existing) diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 8f3d612b..037a1a97 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -75,6 +75,7 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_SSH_OPTS": "-o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa -o KexAlgorithms=+diffie-hellman-group14-sha1 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", "TC_INTERNAL_SHARE_USE_DISK_ROOT": "false", "TC_ANY_PROTOCOL": "false", + "TC_DEBUG_LOGGING": "false", } ENV_FILE_KEYS = [ @@ -83,6 +84,7 @@ def airport_identity_from_values(values: dict[str, str]) -> AirportDeviceIdentit "TC_SSH_OPTS", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", "TC_CONFIGURE_ID", ] ENV_FILE_OMIT_KEYS = frozenset({ @@ -509,6 +511,7 @@ def validate_mdns_device_model_matches_syap(syap: str, device_model: str) -> Opt "TC_MDNS_DEVICE_MODEL": validate_mdns_device_model, "TC_INTERNAL_SHARE_USE_DISK_ROOT": validate_bool, "TC_ANY_PROTOCOL": validate_bool, + "TC_DEBUG_LOGGING": validate_bool, } @@ -524,11 +527,13 @@ class ConfigProfile: CONFIGURE_VALIDATED_KEYS = ( "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", ) MANAGED_VALIDATED_KEYS = ( "TC_HOST", "TC_INTERNAL_SHARE_USE_DISK_ROOT", "TC_ANY_PROTOCOL", + "TC_DEBUG_LOGGING", ) MANAGED_REQUIRED_FILE_KEYS = ( "TC_HOST", diff --git a/src/timecapsulesmb/services/configure.py b/src/timecapsulesmb/services/configure.py index 65a086b2..db652613 100644 --- a/src/timecapsulesmb/services/configure.py +++ b/src/timecapsulesmb/services/configure.py @@ -12,6 +12,7 @@ def build_configure_env_values( configure_id: str, internal_share_use_disk_root: bool | None = None, any_protocol: bool | None = None, + debug_logging: bool | None = None, ) -> dict[str, str]: values = preserved_env_file_values(existing) values.update({ @@ -28,6 +29,11 @@ def build_configure_env_values( if any_protocol is None else any_protocol ) else "false", + "TC_DEBUG_LOGGING": "true" if ( + parse_bool(existing.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"])) + if debug_logging is None + else debug_logging + ) else "false", "TC_CONFIGURE_ID": configure_id, }) return values diff --git a/src/timecapsulesmb/services/deploy.py b/src/timecapsulesmb/services/deploy.py index 90bd9da2..129b3ec6 100644 --- a/src/timecapsulesmb/services/deploy.py +++ b/src/timecapsulesmb/services/deploy.py @@ -45,6 +45,8 @@ def render_flash_runtime_config( ) -> str: internal_root_default = config.get("TC_INTERNAL_SHARE_USE_DISK_ROOT", DEFAULTS["TC_INTERNAL_SHARE_USE_DISK_ROOT"]) any_protocol_default = config.get("TC_ANY_PROTOCOL", DEFAULTS["TC_ANY_PROTOCOL"]) + configured_debug_logging = config.get("TC_DEBUG_LOGGING", DEFAULTS["TC_DEBUG_LOGGING"]) + effective_debug_logging = debug_logging or parse_bool(configured_debug_logging) values: list[tuple[str, str | int]] = [ ("TC_CONFIG_VERSION", 2), @@ -55,7 +57,7 @@ def render_flash_runtime_config( ("DISKD_USE_VOLUME_ATTEMPTS", diskd_use_volume_attempts), ("ATA_IDLE_SECONDS", ata_idle_seconds), ("NBNS_ENABLED", 1 if nbns_enabled else 0), - ("SMBD_DEBUG_LOGGING", 1 if debug_logging else 0), - ("MDNS_DEBUG_LOGGING", 1 if debug_logging else 0), + ("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/tests/test_app_api.py b/tests/test_app_api.py index f0d6a722..ede92e80 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -365,6 +365,7 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: self.assertEqual(rc, 0) self.assertIn("TC_HOST=root@10.0.0.2", config_path.read_text()) self.assertIn("TC_PASSWORD=goodpw", config_path.read_text()) + self.assertIn("TC_DEBUG_LOGGING=false", config_path.read_text()) serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_events) @@ -376,6 +377,7 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s "TC_HOST=root@10.0.0.1\n" "TC_PASSWORD=oldpw\n" "TC_CUSTOM_SETTING='keep me'\n" + "TC_DEBUG_LOGGING=true\n" "TC_SAMBA_USER=old-admin\n" "TC_PAYLOAD_DIR_NAME=old-payload\n" ) @@ -398,9 +400,33 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s self.assertEqual(values["TC_HOST"], "root@10.0.0.2") self.assertEqual(values["TC_PASSWORD"], "newpw") self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") + self.assertEqual(values["TC_DEBUG_LOGGING"], "true") self.assertNotIn("TC_SAMBA_USER", values) self.assertNotIn("TC_PAYLOAD_DIR_NAME", values) + def test_configure_debug_logging_param_writes_true(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "goodpw", + "debug_logging": True, + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(values["TC_DEBUG_LOGGING"], "true") + def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_cli.py b/tests/test_cli.py index 57d7e642..e234c0d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1467,6 +1467,7 @@ def test_configure_writes_values_from_prompts(self) -> None: self.assertNotIn("TC_NET_IFACE", fake_values) self.assertEqual(fake_values["TC_INTERNAL_SHARE_USE_DISK_ROOT"], "false") self.assertEqual(fake_values["TC_ANY_PROTOCOL"], "false") + self.assertEqual(fake_values["TC_DEBUG_LOGGING"], "false") uuid.UUID(fake_values["TC_CONFIGURE_ID"]) telemetry_values = result.mocks.telemetry_factory.call_args.args[0].values self.assertEqual(telemetry_values["TC_CONFIGURE_ID"], fake_values["TC_CONFIGURE_ID"]) @@ -1545,6 +1546,28 @@ def test_configure_hidden_any_protocol_arg_writes_true(self) -> None: self.assertEqual(result.rc, 0) self.assertEqual(result.values["TC_ANY_PROTOCOL"], "true") + def test_configure_hidden_debug_logging_arg_writes_true(self) -> None: + result = self.run_configure_cli( + ["--debug-logging"], + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + + def test_configure_preserves_existing_debug_logging_when_arg_is_omitted(self) -> None: + result = self.run_configure_cli( + existing_values={"TC_DEBUG_LOGGING": "true"}, + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + def test_configure_airport_extreme_keeps_hidden_internal_share_root_default(self) -> None: def fake_prompt(label, default, _secret): if label == "Device SSH target": @@ -1576,6 +1599,7 @@ def fake_prompt(label, default, _secret): self.assertNotIn("TC_MDNS_DEVICE_MODEL", result.values) self.assertEqual(result.values["TC_INTERNAL_SHARE_USE_DISK_ROOT"], "false") self.assertEqual(result.values["TC_ANY_PROTOCOL"], "false") + self.assertEqual(result.values["TC_DEBUG_LOGGING"], "false") def test_configure_ensures_install_id_before_telemetry(self) -> None: prompt_values = iter([ @@ -4433,6 +4457,7 @@ def fake_upload(_plan, *, connection, source_resolver): self.assertIn("NBNS_ENABLED=1\n", flash_config) self.assertIn("ANY_PROTOCOL=0\n", flash_config) self.assertIn("SMBD_DEBUG_LOGGING=1\n", flash_config) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", flash_config) self.assertNotIn("SMB_SAMBA_USER", flash_config) self.assertNotIn("MDNS_DEVICE_MODEL", flash_config) self.assertNotIn("AIRPORT_SYAP", flash_config) @@ -4459,6 +4484,42 @@ def fake_upload(_plan, *, connection, source_resolver): finished = self.telemetry_payload("deploy_finished") self.assertFalse(finished["nbns_enabled"]) + def test_deploy_uses_configured_debug_logging_without_deploy_arg(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver): + captured["flash_config"] = source_resolver[GENERATED_FLASH_CONFIG_SOURCE].read_text() + + result = self.run_deploy_cli( + ["--no-reboot"], + values=self.make_valid_env(TC_DEBUG_LOGGING="true"), + patch_actions=True, + patch_upload=True, + upload_side_effect=fake_upload, + ) + + self.assertEqual(result.rc, 0) + self.assertIn("SMBD_DEBUG_LOGGING=1\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", captured["flash_config"]) + + def test_deploy_leaves_debug_logging_disabled_without_config_or_arg(self) -> None: + captured: dict[str, str] = {} + + def fake_upload(_plan, *, connection, source_resolver): + captured["flash_config"] = source_resolver[GENERATED_FLASH_CONFIG_SOURCE].read_text() + + result = self.run_deploy_cli( + ["--no-reboot"], + values=self.make_valid_env(TC_DEBUG_LOGGING="false"), + patch_actions=True, + patch_upload=True, + upload_side_effect=fake_upload, + ) + + self.assertEqual(result.rc, 0) + self.assertIn("SMBD_DEBUG_LOGGING=0\n", captured["flash_config"]) + self.assertIn("MDNS_DEBUG_LOGGING=0\n", captured["flash_config"]) + def test_deploy_rejects_removed_install_nbns_flag(self) -> None: stderr = io.StringIO() with redirect_stderr(stderr): diff --git a/tests/test_config.py b/tests/test_config.py index 74c6c9e8..f310863c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -147,6 +147,7 @@ def test_render_env_text_contains_config_keys(self) -> None: self.assertNotIn("TC_PAYLOAD_DIR_NAME", rendered) self.assertIn("TC_INTERNAL_SHARE_USE_DISK_ROOT=false", rendered) self.assertIn("TC_ANY_PROTOCOL=false", rendered) + self.assertIn("TC_DEBUG_LOGGING=false", rendered) self.assertIn("TC_CONFIGURE_ID=12345678-1234-1234-1234-123456789012", rendered) def test_render_env_text_preserves_custom_settings_but_omits_deprecated_keys(self) -> None: @@ -509,6 +510,12 @@ def test_validate_app_config_uses_profiles(self) -> None: errors = validate_app_config(config, profile="deploy") self.assertEqual(errors[0].kind, "invalid_value") self.assertEqual(errors[0].key, "TC_ANY_PROTOCOL") + values["TC_ANY_PROTOCOL"] = "false" + values["TC_DEBUG_LOGGING"] = "not-bool" + config = AppConfig.from_values(values, file_values=values) + errors = validate_app_config(config, profile="deploy") + self.assertEqual(errors[0].kind, "invalid_value") + self.assertEqual(errors[0].key, "TC_DEBUG_LOGGING") def test_flash_profile_ignores_deploy_only_settings(self) -> None: values = dict(DEFAULTS) @@ -521,6 +528,7 @@ def test_flash_profile_ignores_deploy_only_settings(self) -> None: values["TC_PAYLOAD_DIR_NAME"] = "/bad" values["TC_INTERNAL_SHARE_USE_DISK_ROOT"] = "not-bool" values["TC_ANY_PROTOCOL"] = "not-bool" + values["TC_DEBUG_LOGGING"] = "not-bool" config = AppConfig.from_values(values, file_values=values) self.assertEqual(validate_app_config(config, profile="flash"), []) diff --git a/tests/test_storage_runtime.py b/tests/test_storage_runtime.py index 2b221d96..cc6198aa 100644 --- a/tests/test_storage_runtime.py +++ b/tests/test_storage_runtime.py @@ -896,6 +896,19 @@ def test_flash_runtime_config_contains_runtime_settings_and_no_share_name(self) self.assertNotIn("MDNS_HOST_LABEL", rendered) self.assertNotIn("TC_SHARE_NAME", rendered) + def test_flash_runtime_config_uses_saved_debug_logging(self) -> None: + config = AppConfig.from_values({"TC_DEBUG_LOGGING": "true"}) + + rendered = render_flash_runtime_config( + config, + PayloadHome("/Volumes/dk2", "/dev/dk2", ".samba4"), + nbns_enabled=True, + debug_logging=False, + ) + + self.assertIn("SMBD_DEBUG_LOGGING=1\n", rendered) + self.assertIn("MDNS_DEBUG_LOGGING=1\n", rendered) + def test_common_runtime_identity_normalizers_match_python(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) From d1566023f29c7f82b7e362adbac1ba8381e6a8a8 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 00:01:25 -0700 Subject: [PATCH 06/20] Add more consistent arg options --- .../TimeCapsuleSMBApp/ContentView.swift | 67 +++- .../PendingConfirmation.swift | 34 +- .../PendingConfirmationTests.swift | 12 +- src/timecapsulesmb/app/contracts.py | 51 ++- src/timecapsulesmb/app/operations.py | 11 +- src/timecapsulesmb/app/ops/deploy.py | 80 ++++- src/timecapsulesmb/app/ops/doctor.py | 5 +- src/timecapsulesmb/app/ops/maintenance.py | 141 ++++---- src/timecapsulesmb/checks/doctor.py | 3 + src/timecapsulesmb/checks/doctor_state.py | 1 + src/timecapsulesmb/checks/doctor_steps.py | 3 + src/timecapsulesmb/cli/activate.py | 13 +- src/timecapsulesmb/cli/configure.py | 12 +- src/timecapsulesmb/cli/deploy.py | 31 +- src/timecapsulesmb/cli/doctor.py | 5 +- src/timecapsulesmb/cli/flash.py | 10 +- src/timecapsulesmb/cli/flows.py | 60 +++- src/timecapsulesmb/cli/fsck.py | 96 ++---- src/timecapsulesmb/cli/repair_xattrs.py | 60 ++++ src/timecapsulesmb/cli/runtime.py | 47 +++ src/timecapsulesmb/cli/set_ssh.py | 86 ++++- src/timecapsulesmb/cli/uninstall.py | 17 +- src/timecapsulesmb/core/config.py | 4 + src/timecapsulesmb/deploy/dry_run.py | 6 + src/timecapsulesmb/services/app.py | 36 +- src/timecapsulesmb/services/maintenance.py | 152 +++++++++ tests/test_app_api.py | 220 +++++++++++- tests/test_cli.py | 320 +++++++++++++++++- 28 files changed, 1349 insertions(+), 234 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index d84cbd08..48fa7c2d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -12,6 +12,9 @@ public struct ContentView: View { @State private var dryRun = true @State private var configureDebugLogging = false @State private var deployDebugLogging = false + @State private var mountWait = "30" + @State private var bonjourTimeout = "6" + @State private var noWait = false @State private var pendingConfirmation: PendingConfirmation? public init() {} @@ -90,9 +93,12 @@ public struct ContentView: View { CommandPanel(title: "Discover And Connect") { TextField("Host", text: $host) SecureField("Password", text: $password) + TextField("Bonjour timeout seconds", text: $bonjourTimeout) Toggle("Enable Debug Logging", isOn: $configureDebugLogging) HStack { - runButton("Discover", icon: "network", operation: "discover") + runButton("Discover", icon: "network", operation: "discover", params: [ + "timeout": numberValue(bonjourTimeout, default: 6) + ]) Button { var params: [String: JSONValue] = [ "host": .string(host), @@ -112,21 +118,27 @@ public struct ContentView: View { CommandPanel(title: "Deploy") { Toggle("Enable NBNS", isOn: $nbnsEnabled) Toggle("No Reboot", isOn: $noReboot) + Toggle("No Wait", isOn: $noWait) Toggle("Dry Run", isOn: $dryRun) Toggle("Force Debug Logging", isOn: $deployDebugLogging) + TextField("Mount wait seconds", text: $mountWait) Button { if dryRun { backend.run(operation: "deploy", params: [ "dry_run": .bool(true), "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(deployDebugLogging) + "debug_logging": .bool(deployDebugLogging), + "mount_wait": numberValue(mountWait, default: 30) ]) } else { pendingConfirmation = .deploy( noReboot: noReboot, nbnsEnabled: nbnsEnabled, - debugLogging: deployDebugLogging + debugLogging: deployDebugLogging, + mountWait: numberDouble(mountWait, default: 30), + noWait: noWait ) } } label: { @@ -136,13 +148,18 @@ public struct ContentView: View { } case .doctor: CommandPanel(title: "Doctor") { - runButton("Run Doctor", icon: "stethoscope", operation: "doctor") + TextField("Bonjour timeout seconds", text: $bonjourTimeout) + runButton("Run Doctor", icon: "stethoscope", operation: "doctor", params: [ + "bonjour_timeout": numberValue(bonjourTimeout, default: 6) + ]) } case .maintenance: CommandPanel(title: "Maintenance") { TextField("Repair xattrs path", text: $repairPath) TextField("fsck volume, optional", text: $volume) + TextField("Mount wait seconds", text: $mountWait) Toggle("No Reboot", isOn: $noReboot) + Toggle("No Wait", isOn: $noWait) HStack { Button { pendingConfirmation = .activate() @@ -150,21 +167,48 @@ public struct ContentView: View { Label("Activate", systemImage: "power") } .disabled(backend.isRunning) - runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: ["dry_run": .bool(true)]) + runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": numberValue(mountWait, default: 30) + ]) Button { - pendingConfirmation = .uninstall(noReboot: noReboot) + pendingConfirmation = .uninstall( + noReboot: noReboot, + mountWait: numberDouble(mountWait, default: 30), + noWait: noWait + ) } label: { Label("Uninstall", systemImage: "xmark.bin.fill") } .disabled(backend.isRunning) } HStack { + runButton("List fsck Volumes", icon: "list.bullet.rectangle", operation: "fsck", params: [ + "list_volumes": .bool(true), + "mount_wait": numberValue(mountWait, default: 30) + ]) + runButton("Plan fsck", icon: "doc.text.magnifyingglass", operation: "fsck", params: [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": numberValue(mountWait, default: 30), + "volume": .string(volume) + ]) Button { - pendingConfirmation = .fsck(volume: volume, noReboot: noReboot) + pendingConfirmation = .fsck( + volume: volume, + noReboot: noReboot, + mountWait: numberDouble(mountWait, default: 30), + noWait: noWait + ) } label: { Label("Run fsck", systemImage: "externaldrive.badge.checkmark") } .disabled(backend.isRunning) + } + HStack { Button { backend.run(operation: "repair-xattrs", params: [ "path": .string(repairPath), @@ -205,6 +249,15 @@ public struct ContentView: View { } .disabled(backend.isRunning) } + + private func numberDouble(_ text: String, default defaultValue: Double) -> Double { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) ?? defaultValue + } + + private func numberValue(_ text: String, default defaultValue: Double) -> JSONValue { + .number(numberDouble(text, default: defaultValue)) + } } private enum Screen: String, CaseIterable, Identifiable { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 3ce7286d..7a777460 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -8,12 +8,14 @@ struct PendingConfirmation: Identifiable { let operation: String let params: [String: JSONValue] - static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool) -> PendingConfirmation { + static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Deploy Without Reboot?" : "Deploy And Reboot?", + title: noReboot ? "Deploy Without Reboot?" : (noWait ? "Deploy And Skip Waiting?" : "Deploy And Reboot?"), message: noReboot ? "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device." - : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately.", + : (noWait + ? "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device." + : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately."), actionTitle: noReboot ? "Deploy" : "Deploy And Allow Reboot", operation: "deploy", params: [ @@ -23,7 +25,9 @@ struct PendingConfirmation: Identifiable { "confirm_netbsd4_activation": .bool(true), "no_reboot": .bool(noReboot), "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(debugLogging) + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait), + "no_wait": .bool(noWait) ] ) } @@ -38,35 +42,43 @@ struct PendingConfirmation: Identifiable { ) } - static func fsck(volume: String, noReboot: Bool) -> PendingConfirmation { + static func fsck(volume: String, noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Run Disk Repair Without Reboot?" : "Run Disk Repair And Reboot?", + title: noReboot ? "Run Disk Repair Without Reboot?" : (noWait ? "Run Disk Repair And Skip Waiting?" : "Run Disk Repair And Reboot?"), message: noReboot ? "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward." - : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot.", + : (noWait + ? "This will run fsck on the selected Time Capsule disk and return after requesting reboot." + : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot."), actionTitle: "Run fsck", operation: "fsck", params: [ "confirm_fsck": .bool(true), "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), "volume": .string(volume) ] ) } - static func uninstall(noReboot: Bool) -> PendingConfirmation { + static func uninstall(noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Uninstall Without Reboot?" : "Uninstall And Reboot?", + title: noReboot ? "Uninstall Without Reboot?" : (noWait ? "Uninstall And Skip Waiting?" : "Uninstall And Reboot?"), message: noReboot ? "This will remove the managed TimeCapsuleSMB payload without rebooting the device." - : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot.", + : (noWait + ? "This will remove the managed TimeCapsuleSMB payload, request reboot, and return without waiting." + : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."), actionTitle: "Uninstall", operation: "uninstall", params: [ "dry_run": .bool(false), "confirm_uninstall": .bool(true), "confirm_reboot": .bool(!noReboot), - "no_reboot": .bool(noReboot) + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) ] ) } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index ab8bb8f7..ec07cb43 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -3,7 +3,7 @@ import XCTest final class PendingConfirmationTests: XCTestCase { func testDeployConfirmationCarriesDeployAndRebootConsent() { - let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true) + let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true, mountWait: 45, noWait: true) XCTAssertEqual(confirmation.operation, "deploy") XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) @@ -13,25 +13,31 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) XCTAssertEqual(confirmation.params["debug_logging"], .bool(true)) + XCTAssertEqual(confirmation.params["mount_wait"], .number(45)) + XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) } func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { - let confirmation = PendingConfirmation.uninstall(noReboot: true) + let confirmation = PendingConfirmation.uninstall(noReboot: true, mountWait: 12, noWait: true) XCTAssertEqual(confirmation.operation, "uninstall") XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) XCTAssertEqual(confirmation.params["confirm_uninstall"], .bool(true)) XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(false)) XCTAssertEqual(confirmation.params["no_reboot"], .bool(true)) + XCTAssertEqual(confirmation.params["mount_wait"], .number(12)) + XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) } func testMaintenanceConfirmationsCarryExplicitOperationConsent() { - let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true) + let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true, mountWait: 18, noWait: true) let repair = PendingConfirmation.repairXattrs(path: "/Volumes/Data") XCTAssertEqual(fsck.operation, "fsck") XCTAssertEqual(fsck.params["confirm_fsck"], .bool(true)) XCTAssertEqual(fsck.params["no_reboot"], .bool(true)) + XCTAssertEqual(fsck.params["mount_wait"], .number(18)) + XCTAssertEqual(fsck.params["no_wait"], .bool(true)) XCTAssertEqual(fsck.params["volume"], .string("Data")) XCTAssertEqual(repair.operation, "repair-xattrs") diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index ba8b4221..f70c319d 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -103,6 +103,9 @@ 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, @@ -111,11 +114,17 @@ def deploy_result_payload( "payload_dir": payload_dir, "netbsd4": netbsd4, "payload_family": payload_family, - "requires_reboot": False if netbsd4 else bool(rebooted), + "requires_reboot": False if netbsd4 else bool(rebooted or reboot_requested), "summary": "deployment completed.", } if rebooted is not None: payload["rebooted"] = rebooted + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested + if waited is not None: + payload["waited"] = waited + if verified is not None: + payload["verified"] = verified if message is not None: payload["message"] = message payload["summary"] = message @@ -158,12 +167,40 @@ def uninstall_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: }) -def uninstall_result_payload(*, rebooted: bool, verified: bool) -> dict[str, object]: - return _with_schema({ +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": rebooted, + "requires_reboot": bool(rebooted or reboot_requested), "summary": "uninstall completed." if verified else "uninstall completed without post-reboot verification.", + } + if reboot_requested is not None: + payload["reboot_requested"] = reboot_requested + if waited is not None: + payload["waited"] = waited + return _with_schema(payload) + + +def fsck_volume_list_payload(raw: Mapping[str, object]) -> dict[str, object]: + targets = raw.get("targets") + target_count = len(targets) if isinstance(targets, list) else 0 + return _with_schema({ + **raw, + "counts": {"targets": target_count}, + "summary": f"found {target_count} mounted HFS volume(s).", + }) + + +def fsck_plan_payload(raw: Mapping[str, object]) -> dict[str, object]: + return _with_schema({ + **raw, + "summary": "fsck dry-run plan generated.", }) @@ -172,7 +209,9 @@ 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, @@ -181,8 +220,12 @@ def fsck_result_payload( } 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) diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py index 95b346ce..ab90ec31 100644 --- a/src/timecapsulesmb/app/operations.py +++ b/src/timecapsulesmb/app/operations.py @@ -48,6 +48,9 @@ resolve_env_connection = _maintenance.resolve_env_connection remote_uninstall_payload = _maintenance.remote_uninstall_payload +read_mast_volumes_conn = _maintenance.read_mast_volumes_conn +mounted_mast_volumes_conn = _maintenance.mounted_mast_volumes_conn +run_ssh = _maintenance.run_ssh probe_managed_runtime_conn = _maintenance.probe_managed_runtime_conn load_optional_env_config = _maintenance.load_optional_env_config repair_xattrs_cli = _maintenance.repair_xattrs_cli @@ -80,6 +83,10 @@ def _sync_compat_bindings() -> None: _maintenance.load_env_config = load_env_config _maintenance.resolve_env_connection = resolve_env_connection _maintenance.remote_uninstall_payload = remote_uninstall_payload + _maintenance.read_mast_volumes_conn = read_mast_volumes_conn + _maintenance.mounted_mast_volumes_conn = mounted_mast_volumes_conn + _maintenance.run_ssh = run_ssh + _maintenance.wait_for_ssh_state_conn = wait_for_ssh_state_conn _maintenance.run_remote_actions = run_remote_actions _maintenance.probe_managed_runtime_conn = probe_managed_runtime_conn _maintenance.load_optional_env_config = load_optional_env_config @@ -153,8 +160,8 @@ def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationRes _request_reboot_and_wait = _deploy.request_reboot_and_wait _request_ssh_reboot = _deploy.request_ssh_reboot _observe_reboot_cycle = _maintenance.observe_reboot_cycle -_RepairContext = _maintenance.RepairContext -_StreamLogCapture = _maintenance.StreamLogCapture +_RepairContext = _maintenance.RepairExecutionContext +_StreamLogCapture = _maintenance.LineLogCapture OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 7c7a7a13..7757082d 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -121,6 +121,7 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes 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") confirm_deploy = confirm_param(params, "confirm_deploy") confirm_reboot = confirm_param(params, "confirm_reboot") confirm_netbsd4_activation = confirm_param(params, "confirm_netbsd4_activation") @@ -260,6 +261,9 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes return OperationResult(True, deploy_result_payload( payload_dir=plan.payload_dir, netbsd4=True, + reboot_requested=False, + waited=False, + verified=True, message=f"NetBSD4 activation complete. {NETBSD4_REBOOT_FOLLOWUP}", payload_family=payload_family, )) @@ -268,6 +272,25 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes return OperationResult(True, deploy_result_payload( payload_dir=plan.payload_dir, rebooted=False, + reboot_requested=False, + waited=False, + verified=False, + payload_family=payload_family, + )) + + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="ssh_shutdown_then_reboot", + require_request_success=True, + ) + return OperationResult(True, deploy_result_payload( + payload_dir=plan.payload_dir, + reboot_requested=True, + waited=False, + verified=False, payload_family=payload_family, )) @@ -282,6 +305,9 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes return OperationResult(True, deploy_result_payload( payload_dir=plan.payload_dir, rebooted=True, + reboot_requested=True, + waited=True, + verified=True, payload_family=payload_family, )) @@ -334,16 +360,7 @@ def request_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> None: - sink.stage(operation, "reboot") - if strategy == "acp_then_ssh": - try: - acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) - sink.log(operation, "ACP reboot requested.") - except ACPError as exc: - sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") - request_ssh_reboot(operation, sink, connection, shutdown=False) - else: - request_ssh_reboot(operation, sink, connection, shutdown=True) + request_reboot(operation, sink, connection, strategy=strategy) sink.stage(operation, "wait_for_reboot_down") sink.log(operation, "Waiting for the device to go down...") @@ -356,7 +373,46 @@ def request_reboot_and_wait( sink.log(operation, "Device is back online.") -def request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnection, *, shutdown: bool) -> None: +def request_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + strategy: str, + require_request_success: bool = False, +) -> None: + sink.stage(operation, "reboot") + if strategy == "acp_then_ssh": + try: + acp_reboot(extract_host(connection.host), connection.password, timeout=ACP_REBOOT_REQUEST_TIMEOUT_SECONDS) + sink.log(operation, "ACP reboot requested.") + except ACPError as exc: + sink.log(operation, f"ACP reboot request failed; trying SSH reboot request: {exc}", level="warning") + request_ssh_reboot( + operation, + sink, + connection, + shutdown=False, + require_request_success=require_request_success, + ) + else: + request_ssh_reboot( + operation, + sink, + connection, + shutdown=True, + require_request_success=require_request_success, + ) + + +def request_ssh_reboot( + operation: str, + sink: EventSink, + connection: SshConnection, + *, + shutdown: bool, + require_request_success: bool = False, +) -> None: try: if shutdown: remote_request_shutdown_reboot(connection) @@ -366,6 +422,8 @@ def request_ssh_reboot(operation: str, sink: EventSink, connection: SshConnectio sink.log(operation, f"SSH reboot request timed out; checking whether the device is rebooting: {exc}", level="warning") return except SshError as exc: + if require_request_success: + raise AppOperationError(f"SSH reboot request failed: {exc}", code="remote_error") from exc sink.log(operation, f"SSH reboot request failed; checking whether the device is rebooting anyway: {exc}", level="warning") return sink.log(operation, "SSH reboot requested.") diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py index 184be906..0996e41c 100644 --- a/src/timecapsulesmb/app/ops/doctor.py +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -7,11 +7,13 @@ from timecapsulesmb.cli.doctor import build_doctor_error from timecapsulesmb.cli.runtime import load_env_config, resolve_env_connection from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.services.app import OperationResult, bool_param, config_path +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC +from timecapsulesmb.services.app import OperationResult, bool_param, config_path, float_param def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "doctor" + bonjour_timeout = float_param(params, "bonjour_timeout", DEFAULT_BROWSE_TIMEOUT_SEC) sink.stage(operation, "load_config") config = load_env_config(env_path=config_path(params)) app_paths = resolve_app_paths(config_path=config_path(params)) @@ -32,6 +34,7 @@ def on_result(result: CheckResult) -> None: skip_ssh=bool_param(params, "skip_ssh"), skip_bonjour=bool_param(params, "skip_bonjour"), skip_smb=bool_param(params, "skip_smb"), + bonjour_timeout=bonjour_timeout, on_result=on_result, debug_fields=debug_fields, ) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 9799f3b2..a5966a29 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -9,7 +9,9 @@ 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, @@ -17,6 +19,7 @@ from timecapsulesmb.app.events import EventSink from timecapsulesmb.app.ops.deploy import ( load_config_and_target, + request_reboot, request_reboot_and_wait, require_supported_payload, verify_runtime, @@ -25,14 +28,12 @@ from timecapsulesmb.cli.fsck import ( FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, build_remote_fsck_script, - select_fsck_target, - _target_from_volume, ) from timecapsulesmb.cli.runtime import load_env_config, load_optional_env_config, resolve_env_connection from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP -from timecapsulesmb.deploy.dry_run import uninstall_plan_to_jsonable +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, @@ -53,12 +54,24 @@ bool_param, config_path, confirm_param, + int_param, jsonable, optional_int_param, string_param, ) from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE -from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE, UNINSTALL_REBOOT_NO_DOWN_MESSAGE +from timecapsulesmb.services.maintenance import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + UNINSTALL_REBOOT_NO_DOWN_MESSAGE, + LineLogCapture, + RepairExecutionContext, + format_fsck_plan, + format_fsck_targets, + fsck_plan_to_jsonable, + fsck_target_from_volume, + fsck_target_to_jsonable, + select_fsck_target, +) from timecapsulesmb.transport.ssh import SshConnection, run_ssh @@ -76,7 +89,7 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR sink.stage(operation, "build_activation_plan") plan = build_netbsd4_activation_plan() if dry_run: - return OperationResult(True, activation_plan_payload(jsonable(plan))) + return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) if not confirm_activation: raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") connection = target.connection @@ -96,6 +109,8 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation 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) confirm_uninstall = confirm_param(params, "confirm_uninstall") confirm_reboot = confirm_param(params, "confirm_reboot") if not dry_run and not confirm_uninstall: @@ -116,7 +131,7 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation mounted_volumes = mounted_mast_volumes_conn( connection, mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + 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] @@ -127,7 +142,26 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation sink.stage(operation, "uninstall_payload") remote_uninstall_payload(connection, plan) if no_reboot: - return OperationResult(True, uninstall_result_payload(rebooted=False, verified=False)) + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=False, + waited=False, + )) + if no_wait: + request_reboot( + operation, + sink, + connection, + strategy="acp_then_ssh", + require_request_success=True, + ) + return OperationResult(True, uninstall_result_payload( + rebooted=False, + verified=False, + reboot_requested=True, + waited=False, + )) request_reboot_and_wait( operation, sink, @@ -141,15 +175,25 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation sink.log(operation, line) if not verification: raise AppOperationError("Managed TimeCapsuleSMB files are still present after reboot.", code="remote_error") - return OperationResult(True, uninstall_result_payload(rebooted=True, verified=True)) + return OperationResult(True, uninstall_result_payload( + rebooted=True, + verified=True, + reboot_requested=True, + waited=True, + )) def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "fsck" + dry_run = bool_param(params, "dry_run") + list_volumes = bool_param(params, "list_volumes") confirm_fsck = confirm_param(params, "confirm_fsck") no_reboot = bool_param(params, "no_reboot") no_wait = bool_param(params, "no_wait") - if not confirm_fsck: + 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 and not confirm_fsck: raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") sink.stage(operation, "load_config") config = load_env_config(env_path=config_path(params)) @@ -161,17 +205,33 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul mounted_volumes = mounted_mast_volumes_conn( connection, mast_volumes, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=mount_wait, ) + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) + if list_volumes: + sink.stage(operation, "list_fsck_volumes") + sink.log(operation, format_fsck_targets(targets)) + return OperationResult(True, fsck_volume_list_payload({ + "targets": [fsck_target_to_jsonable(target) for target in targets], + })) + sink.stage(operation, "select_fsck_volume") try: target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), + targets, string_param(params, "volume") or None, prompt=False, ) except RuntimeError as exc: raise AppOperationError(str(exc), code="validation_failed") from exc + if dry_run: + sink.log(operation, format_fsck_plan(target, reboot=not no_reboot, wait=not no_wait)) + return OperationResult(True, fsck_plan_payload(fsck_plan_to_jsonable( + target, + reboot=not no_reboot, + wait=not no_wait, + ))) + sink.stage(operation, "run_fsck") script = build_remote_fsck_script(target.device, target.mountpoint, reboot=not no_reboot) proc = run_ssh( @@ -188,12 +248,17 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul device=target.device, mountpoint=target.mountpoint, returncode=proc.returncode, + reboot_requested=False, + waited=False, + verified=False, )) if no_wait: return OperationResult(True, fsck_result_payload( device=target.device, mountpoint=target.mountpoint, + reboot_requested=True, waited=False, + verified=False, )) observe_reboot_cycle( operation, @@ -206,7 +271,9 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul return OperationResult(True, fsck_result_payload( device=target.device, mountpoint=target.mountpoint, + reboot_requested=True, waited=True, + verified=True, )) @@ -227,52 +294,6 @@ def observe_reboot_cycle( raise AppOperationError(DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, code="remote_error") -class RepairContext: - def __init__(self, operation: str, sink: EventSink) -> None: - self.operation = operation - self.sink = sink - self.result = "failure" - self.error: str | None = None - - def set_stage(self, stage: str) -> None: - self.sink.stage(self.operation, stage) - - def update_fields(self, **_fields: object) -> None: - pass - - def succeed(self) -> None: - self.result = "success" - - def fail_with_error(self, message: str) -> None: - self.result = "failure" - self.error = message - - -class StreamLogCapture: - def __init__(self, operation: str, sink: EventSink, *, level: str) -> None: - self.operation = operation - self.sink = sink - self.level = level - self._buffer = "" - - def write(self, text: str) -> int: - self._buffer += text - while "\n" in self._buffer: - line, self._buffer = self._buffer.split("\n", 1) - self._emit(line) - return len(text) - - def flush(self) -> None: - if self._buffer: - self._emit(self._buffer) - self._buffer = "" - - def _emit(self, line: str) -> None: - message = line.rstrip("\r") - if message: - self.sink.log(self.operation, message, level=self.level) - - def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" dry_run = bool_param(params, "dry_run") @@ -301,9 +322,9 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera fix_permissions=bool_param(params, "fix_permissions"), verbose=bool_param(params, "verbose"), ) - context = RepairContext(operation, sink) - stdout_capture = StreamLogCapture(operation, sink, level="info") - stderr_capture = StreamLogCapture(operation, sink, level="warning") + context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) + stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) + stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) try: with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): result = repair_xattrs_cli.run_repair_structured( diff --git a/src/timecapsulesmb/checks/doctor.py b/src/timecapsulesmb/checks/doctor.py index 57204046..30098fd9 100644 --- a/src/timecapsulesmb/checks/doctor.py +++ b/src/timecapsulesmb/checks/doctor.py @@ -32,6 +32,7 @@ from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.core.config import AppConfig from timecapsulesmb.device.probe import ProbedDeviceState, RemoteInterfaceProbeResult +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.transport.ssh import SshConnection @@ -45,6 +46,7 @@ def run_doctor_checks( skip_ssh: bool = False, skip_bonjour: bool = False, skip_smb: bool = False, + bonjour_timeout: float = DEFAULT_BROWSE_TIMEOUT_SEC, on_result: Optional[Callable[[CheckResult], None]] = None, debug_fields: dict[str, object] | None = None, ) -> tuple[list[CheckResult], bool]: @@ -52,6 +54,7 @@ def run_doctor_checks( skip_ssh=skip_ssh, skip_bonjour=skip_bonjour, skip_smb=skip_smb, + bonjour_timeout=bonjour_timeout, ) inputs = DoctorInputs( config=config, diff --git a/src/timecapsulesmb/checks/doctor_state.py b/src/timecapsulesmb/checks/doctor_state.py index fd74f1fd..bcf786c7 100644 --- a/src/timecapsulesmb/checks/doctor_state.py +++ b/src/timecapsulesmb/checks/doctor_state.py @@ -27,6 +27,7 @@ class DoctorOptions: skip_ssh: bool skip_bonjour: bool skip_smb: bool + bonjour_timeout: float @dataclass(frozen=True) diff --git a/src/timecapsulesmb/checks/doctor_steps.py b/src/timecapsulesmb/checks/doctor_steps.py index 577b4bba..132cfeaa 100644 --- a/src/timecapsulesmb/checks/doctor_steps.py +++ b/src/timecapsulesmb/checks/doctor_steps.py @@ -267,6 +267,7 @@ def _add_bonjour_results( *, proxied_ssh: bool, skip_bonjour: bool, + bonjour_timeout: float, add_result: Callable[[CheckResult], None], ) -> DoctorBonjourResult: bonjour_instance: str | None = None @@ -301,6 +302,7 @@ def _add_bonjour_results( zeroconf_debug=None, ) smb_snapshot, discovery_error, bonjour_zeroconf_debug = discover_smb_services_detailed( + timeout=bonjour_timeout, include_related=True, target_ip=bonjour_expected.target_ip, ) @@ -777,6 +779,7 @@ def _doctor_check_bonjour(inputs: DoctorInputs, target: DoctorTarget, naming: Ru naming.identity, proxied_ssh=target.proxied_ssh, skip_bonjour=inputs.options.skip_bonjour, + bonjour_timeout=inputs.options.bonjour_timeout, add_result=sink.add, ) diff --git a/src/timecapsulesmb/cli/activate.py b/src/timecapsulesmb/cli/activate.py index 738e853c..35212857 100644 --- a/src/timecapsulesmb/cli/activate.py +++ b/src/timecapsulesmb/cli/activate.py @@ -8,11 +8,12 @@ from timecapsulesmb.cli.runtime import ( add_config_argument, load_env_config, + print_json, require_netbsd4_device_compatibility, ) from timecapsulesmb.core.config import airport_exact_display_name_from_identity from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.deploy.dry_run import format_activation_plan +from timecapsulesmb.deploy.dry_run import activation_plan_to_jsonable, format_activation_plan from timecapsulesmb.deploy.executor import run_remote_actions from timecapsulesmb.deploy.planner import build_netbsd4_activation_plan from timecapsulesmb.device.probe import probe_managed_runtime_conn @@ -37,8 +38,12 @@ def main(argv: Optional[list[str]] = None) -> int: add_config_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before restarting the deployed Samba services") parser.add_argument("--dry-run", action="store_true", help="Print activation actions without making changes") + parser.add_argument("--json", action="store_true", help="Output the dry-run activation plan as JSON") args = parser.parse_args(argv) + if args.json and not args.dry_run: + parser.error("--json currently requires --dry-run") + ensure_install_id() config = load_env_config(env_path=args.config) telemetry = TelemetryClient.from_config(config) @@ -51,6 +56,7 @@ def main(argv: Optional[list[str]] = None) -> int: require_netbsd4_device_compatibility( command_context, command_name="activate", + json_output=args.json, unsupported_message="activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", ) @@ -60,7 +66,10 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.update_fields(activation_action_count=len(plan.actions)) if args.dry_run: - print(format_activation_plan(plan, device_name=device_name)) + if args.json: + print_json(activation_plan_to_jsonable(plan)) + else: + print(format_activation_plan(plan, device_name=device_name)) command_context.succeed() return 0 diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index f4b10037..4de0ab56 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -26,6 +26,7 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import wait_for_tcp_port_state from timecapsulesmb.cli.runtime import ( + add_bonjour_timeout_argument, add_config_argument, confirm as confirm_prompt, ssh_target_link_local_resolution_error, @@ -44,10 +45,8 @@ from timecapsulesmb.discovery.bonjour import ( BonjourResolvedService, AIRPORT_SERVICE, - DEFAULT_BROWSE_TIMEOUT_SEC, discover_resolved_records, discovered_record_root_host, - record_has_service, ) from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import SshConnection @@ -108,9 +107,9 @@ def choose_device(records): return records[idx - 1] -def discover_default_record(existing: dict[str, str]) -> Optional[BonjourResolvedService]: +def discover_default_record(existing: dict[str, str], *, timeout: float) -> Optional[BonjourResolvedService]: print("Attempting to discover Time Capsule/Airport Extreme devices on the local network via mDNS...", flush=True) - records = discover_resolved_records(AIRPORT_SERVICE, timeout=DEFAULT_BROWSE_TIMEOUT_SEC) + records = discover_resolved_records(AIRPORT_SERVICE, timeout=timeout) if not records: print("No Time Capsule/Airport Extreme devices discovered. Falling back to manual SSH target entry.\n", flush=True) return None @@ -298,6 +297,7 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--internal-share-use-disk-root", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--any-protocol", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) + add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) ensure_install_id() @@ -327,7 +327,7 @@ def main(argv: Optional[list[str]] = None) -> int: args=args, configure_id=configure_id, ) as command_context: - command_context.update_fields(configure_id=configure_id) + command_context.update_fields(configure_id=configure_id, bonjour_timeout=args.bonjour_timeout) command_context.set_stage("dependency_check") missing_module = missing_required_python_module(REQUIRED_PYTHON_MODULES) if missing_module is not None: @@ -366,7 +366,7 @@ def main(argv: Optional[list[str]] = None) -> int: ) command_context.set_stage("bonjour_discovery") try: - discovered_record = discover_default_record(existing) + discovered_record = discover_default_record(existing, timeout=args.bonjour_timeout) except Exception as exc: error_text = exception_summary(exc) print(f"Warning: mDNS discovery failed: {error_text}") diff --git a/src/timecapsulesmb/cli/deploy.py b/src/timecapsulesmb/cli/deploy.py index 8be80836..7cde0175 100644 --- a/src/timecapsulesmb/cli/deploy.py +++ b/src/timecapsulesmb/cli/deploy.py @@ -7,8 +7,10 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import request_deploy_reboot_and_wait, verify_managed_runtime_flow +from timecapsulesmb.cli.flows import request_deploy_reboot, request_deploy_reboot_and_wait, verify_managed_runtime_flow from timecapsulesmb.cli.runtime import ( + add_mount_wait_argument, + add_no_wait_argument, add_config_argument, load_env_config, print_json, @@ -30,7 +32,6 @@ BINARY_MDNS_SOURCE, BINARY_NBNS_SOURCE, BINARY_SMBD_SOURCE, - DEFAULT_APPLE_MOUNT_WAIT_SECONDS, GENERATED_FLASH_CONFIG_SOURCE, GENERATED_SMBPASSWD_SOURCE, GENERATED_USERNAME_MAP_SOURCE, @@ -71,32 +72,17 @@ def _target_family_display_name(target) -> str: ) -def _non_negative_int(value: str) -> int: - try: - parsed = int(value) - except ValueError as e: - raise argparse.ArgumentTypeError("must be an integer") from e - if parsed < 0: - raise argparse.ArgumentTypeError("must be 0 or greater") - return parsed - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Deploy the checked-in Samba 4 payload to an AirPort storage device.") add_config_argument(parser) + add_mount_wait_argument(parser) + add_no_wait_argument(parser) parser.add_argument("--no-reboot", action="store_true", help="Do not reboot after deployment") parser.add_argument("--yes", action="store_true", help="Do not prompt before reboot") parser.add_argument("--dry-run", action="store_true", help="Print actions without making changes") parser.add_argument("--json", action="store_true", help="Output the dry-run deployment plan as JSON") parser.add_argument("--allow-unsupported", action="store_true", help="Proceed even if the detected device is not currently supported") parser.add_argument("--no-nbns", action="store_true", help="Disable the bundled NBNS responder on the next boot") - parser.add_argument( - "--mount-wait", - type=_non_negative_int, - default=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, - metavar="SECONDS", - help=f"Seconds for deployment-time diskd.useVolume mount guards to wait before their manual fallback (default: {DEFAULT_APPLE_MOUNT_WAIT_SECONDS})", - ) parser.add_argument("--debug-logging", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args(argv) @@ -323,6 +309,13 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.cancel_with_error("Cancelled by user at reboot confirmation prompt.") return 0 + if args.no_wait: + request_deploy_reboot(connection, command_context, require_request_success=True) + print("Reboot requested; not waiting for the device to go down or come back.") + print(color_green("Deploy Finished.")) + command_context.succeed() + return 0 + if not request_deploy_reboot_and_wait( connection, command_context, diff --git a/src/timecapsulesmb/cli/doctor.py b/src/timecapsulesmb/cli/doctor.py index 177b52ff..2208fc02 100644 --- a/src/timecapsulesmb/cli/doctor.py +++ b/src/timecapsulesmb/cli/doctor.py @@ -8,7 +8,7 @@ from timecapsulesmb.checks.doctor import run_doctor_checks from timecapsulesmb.checks.models import CheckResult from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json +from timecapsulesmb.cli.runtime import add_bonjour_timeout_argument, add_config_argument, load_env_config, print_json from timecapsulesmb.cli.util import color_green, color_red from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.services.doctor import doctor_status_counts @@ -251,6 +251,7 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--skip-bonjour", action="store_true", help="Skip Bonjour browse/resolve checks") parser.add_argument("--skip-smb", action="store_true", help="Skip authenticated SMB listing check") parser.add_argument("--json", action="store_true", help="Output doctor results as JSON") + add_bonjour_timeout_argument(parser) args = parser.parse_args(argv) ensure_install_id() @@ -262,6 +263,7 @@ def main(argv: Optional[list[str]] = None) -> int: skip_ssh=args.skip_ssh, skip_bonjour=args.skip_bonjour, skip_smb=args.skip_smb, + bonjour_timeout=args.bonjour_timeout, json_output=args.json, ) if not args.skip_ssh and config.has_value("TC_HOST"): @@ -280,6 +282,7 @@ def main(argv: Optional[list[str]] = None) -> int: skip_ssh=args.skip_ssh, skip_bonjour=args.skip_bonjour, skip_smb=args.skip_smb, + bonjour_timeout=args.bonjour_timeout, on_result=None if args.json else print_result, debug_fields=doctor_debug, ) diff --git a/src/timecapsulesmb/cli/flash.py b/src/timecapsulesmb/cli/flash.py index bf2b843e..02392450 100644 --- a/src/timecapsulesmb/cli/flash.py +++ b/src/timecapsulesmb/cli/flash.py @@ -19,6 +19,7 @@ from timecapsulesmb.cli.runtime import ( LogCallback, add_config_argument, + add_no_wait_argument, emit_progress, load_env_config, prefixed_logger, @@ -549,6 +550,7 @@ def _build_parser() -> argparse.ArgumentParser: mode_group.add_argument("--download-only", action="store_true", help="Download and validate Apple firmware without writing") parser.add_argument("--yes", action="store_true", help="Do not prompt before --patch or --restore writes") parser.add_argument("--reboot", action="store_true", help="Reboot after a validated --restore write") + add_no_wait_argument(parser) parser.add_argument("--poweroff", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--json", action="store_true", help="Output the flash analysis and plan as JSON") parser.add_argument("--backup-dir", type=Path, default=None, help="Directory where this run's firmware backup should be saved") @@ -580,6 +582,8 @@ def _parse_args(argv: Optional[list[str]]) -> tuple[argparse.Namespace, str]: parser.error("flash --patch cannot use --reboot; power cycle manually after the validated write") if args.reboot and operation != "restore": parser.error("--reboot is only valid with --restore") + if args.no_wait and not (operation == "restore" and args.reboot): + parser.error("--no-wait is only valid with --restore --reboot") if args.poweroff: parser.error("--poweroff is not supported; power cycle manually after a validated patch write") if args.json and operation in WRITE_OPERATIONS: @@ -972,7 +976,11 @@ def _finish_write( command_context.succeed() return 0 - request_ssh_reboot(target.connection, command_context, log=log) + request_ssh_reboot(target.connection, command_context, log=log, require_request_success=args.no_wait) + if args.no_wait: + print("Reboot requested; not waiting for the device to go down or come back.", flush=True) + command_context.succeed() + return 0 if not observe_reboot_cycle( target.connection, command_context, diff --git a/src/timecapsulesmb/cli/flows.py b/src/timecapsulesmb/cli/flows.py index 40a5d458..9e0cdf57 100644 --- a/src/timecapsulesmb/cli/flows.py +++ b/src/timecapsulesmb/cli/flows.py @@ -82,9 +82,7 @@ def request_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_acp_then_ssh(connection, command_context) + request_reboot(connection, command_context) return observe_reboot_cycle( connection, @@ -95,6 +93,17 @@ def request_reboot_and_wait( ) +def request_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + require_request_success: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_acp_then_ssh(connection, command_context, require_request_success=require_request_success) + + def request_deploy_reboot_and_wait( connection: SshConnection, command_context: CommandContext, @@ -103,9 +112,7 @@ def request_deploy_reboot_and_wait( down_timeout_seconds: int = 60, up_timeout_seconds: int = 240, ) -> bool: - command_context.set_stage("reboot") - command_context.update_fields(reboot_was_attempted=True) - _request_reboot_via_ssh_shutdown(connection, command_context) + request_deploy_reboot(connection, command_context) return observe_reboot_cycle( connection, @@ -116,23 +123,53 @@ def request_deploy_reboot_and_wait( ) +def request_deploy_reboot( + connection: SshConnection, + command_context: CommandContext, + *, + require_request_success: bool = False, +) -> None: + command_context.set_stage("reboot") + command_context.update_fields(reboot_was_attempted=True) + _request_reboot_via_ssh_shutdown( + connection, + command_context, + require_request_success=require_request_success, + ) + + def request_ssh_reboot( connection: SshConnection, command_context: CommandContext, *, log: LogCallback = None, + require_request_success: bool = False, ) -> None: command_context.set_stage("reboot") command_context.update_fields(reboot_was_attempted=True) command_context.add_debug_fields(reboot_request_strategy="ssh") - _request_reboot_via_ssh(connection, command_context, log=log) + _request_reboot_via_ssh( + connection, + command_context, + log=log, + require_request_success=require_request_success, + ) -def _request_reboot_acp_then_ssh(connection: SshConnection, command_context: CommandContext) -> None: +def _request_reboot_acp_then_ssh( + connection: SshConnection, + command_context: CommandContext, + *, + require_request_success: bool = False, +) -> None: command_context.add_debug_fields(reboot_request_strategy="acp_then_ssh") if _request_reboot_via_acp(connection, command_context): return - _request_reboot_via_ssh(connection, command_context) + _request_reboot_via_ssh( + connection, + command_context, + require_request_success=require_request_success, + ) def _request_reboot_via_acp(connection: SshConnection, command_context: CommandContext) -> bool: @@ -161,6 +198,7 @@ def _request_reboot_via_ssh_shutdown( command_context: CommandContext, *, log: LogCallback = None, + require_request_success: bool = False, ) -> None: command_context.add_debug_fields(reboot_request_strategy="ssh_shutdown_then_reboot") _request_reboot_via_ssh( @@ -169,6 +207,7 @@ def _request_reboot_via_ssh_shutdown( log=log, request_reboot=remote_request_shutdown_reboot, progress_message="SSH: /sbin/shutdown -r now (fallback /sbin/reboot)", + require_request_success=require_request_success, ) @@ -179,6 +218,7 @@ def _request_reboot_via_ssh( log: LogCallback = None, request_reboot: Callable[[SshConnection], None] | None = None, progress_message: str = "SSH: /sbin/reboot", + require_request_success: bool = False, ) -> None: command_context.add_debug_fields(ssh_reboot_attempted=True) emit_progress(log, progress_message) @@ -199,6 +239,8 @@ def _request_reboot_via_ssh( ssh_reboot_succeeded=False, ssh_reboot_error=system_exit_message(exc), ) + if require_request_success: + raise print("SSH reboot request failed; checking whether the device is rebooting anyway...") return diff --git a/src/timecapsulesmb/cli/fsck.py b/src/timecapsulesmb/cli/fsck.py index 9957a258..c49bbf54 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -2,75 +2,25 @@ 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.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS +from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.identity import ensure_install_id -from timecapsulesmb.device.storage import MaStVolume -from timecapsulesmb.services.maintenance import FSCK_REBOOT_NO_DOWN_MESSAGE +from timecapsulesmb.services.maintenance import ( + FSCK_REBOOT_NO_DOWN_MESSAGE, + format_fsck_plan, + format_fsck_targets, + fsck_target_from_volume, + select_fsck_target, +) from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import run_ssh FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 -NO_MOUNTED_HFS_VOLUMES_MESSAGE = "no mounted HFS volumes found" -MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE = "multiple mounted HFS volumes found; specify --volume to select one" - - -@dataclass(frozen=True) -class FsckTarget: - device: str - mountpoint: str - name: str - builtin: bool - - -def _target_from_volume(volume: MaStVolume) -> FsckTarget: - return FsckTarget( - device=volume.device_path, - mountpoint=volume.volume_root, - name=volume.name, - builtin=volume.builtin, - ) - - -def _normalize_volume_selector(selector: str) -> str: - selector = selector.strip() - if selector.startswith("/dev/"): - return selector.removeprefix("/dev/") - return selector - - -def select_fsck_target(targets: tuple[FsckTarget, ...], selector: str | None, *, prompt: bool = True) -> FsckTarget: - if not targets: - raise RuntimeError(NO_MOUNTED_HFS_VOLUMES_MESSAGE) - if selector: - selected_device = _normalize_volume_selector(selector) - for target in targets: - if target.device == selector or target.device.removeprefix("/dev/") == selected_device: - return target - raise RuntimeError(f"HFS volume not found: {selector}") - if len(targets) == 1: - return targets[0] - if not prompt: - raise RuntimeError(MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE) - - print("Mounted HFS volumes:") - for index, target in enumerate(targets, start=1): - kind = "internal" if target.builtin else "external" - print(f" {index}. {target.device} on {target.mountpoint} ({target.name}, {kind})") - while True: - answer = input("Select a volume to fsck by number: ").strip() - if answer.isdigit(): - index = int(answer) - if 1 <= index <= len(targets): - return targets[index - 1] - print("Please enter a valid volume number.") def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: @@ -98,13 +48,20 @@ def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> s def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Run fsck_hfs on a mounted HFS volume and reboot by default.") add_config_argument(parser) + add_mount_wait_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before running fsck") + parser.add_argument("--dry-run", action="store_true", help="Print the selected fsck target and actions without making changes") + parser.add_argument("--list-volumes", action="store_true", help="List mounted HFS volumes that can be selected for fsck") parser.add_argument("--no-reboot", action="store_true", help="Run fsck only; do not reboot afterward") - parser.add_argument("--no-wait", action="store_true", help="Do not wait for SSH to go down and come back after reboot") + add_no_wait_argument(parser) parser.add_argument("--volume", help="HFS volume device to repair, for example dk2 or /dev/dk2") args = parser.parse_args(argv) - print("Running fsck...") + if args.dry_run and args.list_volumes: + parser.error("--dry-run and --list-volumes are mutually exclusive") + + if not args.dry_run and not args.list_volumes: + print("Running fsck...") ensure_install_id() config = load_env_config(env_path=args.config) @@ -123,15 +80,22 @@ def main(argv: Optional[list[str]] = None) -> int: mounted_volumes = command_context.mount_mast_volumes( connection, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=args.mount_wait, mount_stage="mount_hfs_volumes", ) + targets = tuple(fsck_target_from_volume(volume) for volume in mounted_volumes) + if args.list_volumes: + command_context.set_stage("list_fsck_volumes") + print(format_fsck_targets(targets)) + command_context.succeed() + return 0 + command_context.set_stage("select_fsck_volume") try: target = select_fsck_target( - tuple(_target_from_volume(volume) for volume in mounted_volumes), + targets, args.volume, - prompt=not args.yes, + prompt=not args.yes and not args.dry_run, ) except RuntimeError as exc: raise SystemExit(str(exc)) from exc @@ -139,6 +103,11 @@ def main(argv: Optional[list[str]] = None) -> int: print(f"Target host: {connection.host}") print(f"Mounted HFS volume: {target.device} on {target.mountpoint}") + if args.dry_run: + print(format_fsck_plan(target, reboot=not args.no_reboot, wait=not args.no_wait)) + command_context.succeed() + return 0 + if not args.yes: command_context.set_stage("confirm_fsck") device_name = command_context.optional_airport_display_name(timeout_seconds=0.1) @@ -169,6 +138,7 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.update_fields(reboot_was_attempted=True) if args.no_wait: + print("Reboot requested; not waiting for the device to go down or come back.") command_context.succeed() return 0 diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index 2b60abd5..734df12b 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -2,13 +2,17 @@ import argparse import sys +from contextlib import redirect_stderr, redirect_stdout from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional +from timecapsulesmb.app.contracts import repair_xattrs_payload +from timecapsulesmb.app.events import EventSink from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.runtime import add_config_argument, confirm as confirm_prompt, load_optional_env_config from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.repair_xattrs import ( ACTION_CLEAR_ARCH_FLAG, @@ -42,6 +46,8 @@ xattr_status, xattrs_readable, ) +from timecapsulesmb.services.app import jsonable +from timecapsulesmb.services.maintenance import LineLogCapture, RepairExecutionContext from timecapsulesmb.telemetry import TelemetryClient @@ -261,6 +267,45 @@ def run_repair(args: argparse.Namespace, command_context: CommandContext, config return run_repair_structured(args, command_context, config, emit_log=print).returncode +def _repair_result_payload(result: RepairRunResult, context: RepairExecutionContext | CommandContext) -> dict[str, object]: + return repair_xattrs_payload({ + "returncode": result.returncode, + "root": str(result.root), + "finding_count": len(result.findings), + "repairable_count": len(result.candidates), + "summary": jsonable(result.summary), + "report": result.report, + "telemetry_result": context.result, + "error": context.error if isinstance(context, RepairExecutionContext) else None, + }) + + +def run_repair_json(args: argparse.Namespace, config: AppConfig, sink: EventSink) -> int: + operation = "repair-xattrs" + context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) + stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) + stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + result = run_repair_structured( + args, + context, + config, + emit_log=lambda message: sink.log(operation, message), + ) + except SystemExit as exc: + message = system_exit_message(exc) or "repair-xattrs failed" + sink.error(operation, message, code="operation_failed") + sink.result(operation, ok=False, payload={"error": message}) + return 1 + finally: + stdout_capture.flush() + stderr_capture.flush() + payload = _repair_result_payload(result, context) + sink.result(operation, ok=result.returncode == 0, payload=payload) + return result.returncode + + def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Repair files whose SMB xattr metadata is broken by clearing the macOS arch flag.") add_config_argument(parser) @@ -274,15 +319,30 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--include-time-machine", action="store_true", help="Include Time Machine and bundle-like paths normally skipped") parser.add_argument("--fix-permissions", action="store_true", help="Also repair missing write permissions on scanned files/directories") parser.add_argument("--verbose", action="store_true", help="Print detailed diagnostics for detected issues") + parser.add_argument("--json", action="store_true", help="Emit app-event NDJSON instead of human-readable output") args = parser.parse_args(argv) if args.dry_run and args.yes: parser.error("--dry-run and --yes are mutually exclusive") + if args.json and not args.dry_run and not args.yes: + parser.error("--json repair requires --yes when not using --dry-run") if args.max_depth is not None and args.max_depth < 0: parser.error("--max-depth must be non-negative") ensure_install_id() config = load_optional_env_config(env_path=args.config) + if args.json: + sink = EventSink(lambda event: print(event.to_json_line(), end="")) + operation = "repair-xattrs" + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + message = "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share." + sink.error(operation, message, code="validation_failed") + sink.result(operation, ok=False, payload={"error": message}) + return 1 + sink.stage(operation, "validate_params") + return run_repair_json(args, config, sink) + telemetry = TelemetryClient.from_config(config) with CommandContext(telemetry, "repair-xattrs", "repair_xattrs_started", "repair_xattrs_finished", config=config, args=args) as command_context: command_context.set_stage("platform_check") diff --git a/src/timecapsulesmb/cli/runtime.py b/src/timecapsulesmb/cli/runtime.py index be49d262..702c519d 100644 --- a/src/timecapsulesmb/cli/runtime.py +++ b/src/timecapsulesmb/cli/runtime.py @@ -2,6 +2,7 @@ import argparse import json +import math from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional @@ -28,6 +29,8 @@ probe_remote_interface_conn, read_interface_ipv4_addrs_conn, ) +from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS +from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy @@ -54,6 +57,50 @@ def add_config_argument(parser: argparse.ArgumentParser) -> None: ) +def non_negative_int_arg(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if parsed < 0: + raise argparse.ArgumentTypeError("must be 0 or greater") + return parsed + + +def non_negative_float_arg(value: str) -> float: + try: + parsed = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be a number") from exc + if not math.isfinite(parsed) or parsed < 0: + raise argparse.ArgumentTypeError("must be 0 or greater") + return parsed + + +def add_mount_wait_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--mount-wait", + type=non_negative_int_arg, + default=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + metavar="SECONDS", + help=f"Seconds for diskd.useVolume mount guards to wait before their manual fallback (default: {DEFAULT_APPLE_MOUNT_WAIT_SECONDS})", + ) + + +def add_no_wait_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--no-wait", action="store_true", help="Do not wait for the device to go down and come back after reboot") + + +def add_bonjour_timeout_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--bonjour-timeout", + type=non_negative_float_arg, + default=DEFAULT_BROWSE_TIMEOUT_SEC, + metavar="SECONDS", + help=f"Bonjour browse time in seconds (default: {DEFAULT_BROWSE_TIMEOUT_SEC:g})", + ) + + def config_path_from_args(args: argparse.Namespace) -> Path | None: return getattr(args, "config", None) diff --git a/src/timecapsulesmb/cli/set_ssh.py b/src/timecapsulesmb/cli/set_ssh.py index f6ba21b7..a1314885 100644 --- a/src/timecapsulesmb/cli/set_ssh.py +++ b/src/timecapsulesmb/cli/set_ssh.py @@ -5,7 +5,7 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import wait_for_device_up, wait_for_tcp_port_state -from timecapsulesmb.cli.runtime import LogCallback, add_config_argument, confirm, emit_progress, load_env_config +from timecapsulesmb.cli.runtime import LogCallback, add_config_argument, add_no_wait_argument, confirm, emit_progress, load_env_config from timecapsulesmb.cli.util import color_red from timecapsulesmb.core.config import ConfigError from timecapsulesmb.core.net import extract_host @@ -67,31 +67,58 @@ def disable_ssh_over_ssh( def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Use the configured device target from .env to enable SSH via ACP or disable SSH over SSH.") add_config_argument(parser) + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument("--enable", action="store_true", help="Enable SSH via ACP if it is not already reachable") + mode_group.add_argument("--disable", action="store_true", help="Disable SSH over SSH if it is currently reachable") + mode_group.add_argument("--status", action="store_true", help="Report whether SSH is reachable without changing device state") + parser.add_argument("--yes", action="store_true", help="Skip the legacy prompt when SSH is already enabled") + add_no_wait_argument(parser) args = parser.parse_args(argv) + if args.status and args.no_wait: + parser.error("--no-wait is not valid with --status") + ensure_install_id() config = load_env_config(env_path=args.config, defaults={}) telemetry = TelemetryClient.from_config(config) with CommandContext(telemetry, "set-ssh", "set_ssh_started", "set_ssh_finished", config=config, args=args) as command_context: command_context.set_stage("load_config") try: - command_context.require_valid_config(profile="set_ssh") + command_context.require_valid_config(profile="set_ssh_status" if args.status else "set_ssh") except ConfigError as exc: message = str(exc) or f"Missing {config.path} settings. Run '.venv/bin/tcapsule configure' first." command_context.update_fields(set_ssh_action="missing_config") print(message) command_context.fail_with_error(message) return 1 - connection = command_context.resolve_env_connection() - acp_host = extract_host(connection.host) - password = connection.password + connection = None if args.status else command_context.resolve_env_connection() + target_host = config.require("TC_HOST") if args.status else connection.host + acp_host = extract_host(target_host) + password = "" if connection is None else connection.password - print(f"Using configured target from {config.path}: {connection.host}") + print(f"Using configured target from {config.path}: {target_host}") print(f"Probing SSH on {acp_host}:22 ...") command_context.set_stage("probe_ssh") ssh_open = tcp_open(acp_host, 22) command_context.update_fields(ssh_initially_reachable=ssh_open) - if not ssh_open: + + 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 + should_enable = args.enable or (not args.disable and not ssh_open) + should_disable = args.disable or (not args.enable and ssh_open) + + if should_enable: + if ssh_open: + command_context.update_fields(set_ssh_action="enable_noop", ssh_final_reachable=True) + print("SSH already enabled.") + command_context.succeed() + return 0 + command_context.update_fields(set_ssh_action="enable_ssh") print("SSH not reachable. Attempting to enable via ACP...") try: @@ -105,23 +132,43 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.fail_with_error(message) return 1 + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH enable requested; not waiting for SSH to open.") + command_context.succeed() + return 0 + command_context.set_stage("wait_for_ssh_enabled") if not wait_for_tcp_port_state(acp_host, 22, expected_state=True, service_name="SSH port"): command_context.update_fields(ssh_final_reachable=False) command_context.fail_with_error("SSH did not open after enabling via ACP.") return 1 command_context.update_fields(ssh_final_reachable=True) - else: + + print("SSH is configured. You can connect as 'root' using the AirPort admin password.") + command_context.succeed() + return 0 + + if should_disable: + if not ssh_open: + command_context.update_fields(set_ssh_action="disable_noop", ssh_final_reachable=False) + print("SSH already disabled.") + command_context.succeed() + return 0 + command_context.set_stage("prompt_disable_ssh") - should_disable = confirm( - "SSH already enabled. Disable?", - default=False, - eof_default=False, - interrupt_default=False, - ) + if not args.disable and not args.yes: + should_disable = confirm( + "SSH already enabled. Disable?", + default=False, + eof_default=False, + interrupt_default=False, + ) if not should_disable: command_context.update_fields(set_ssh_action="leave_enabled", ssh_final_reachable=True) print("Leaving SSH enabled.") + command_context.succeed() + return 0 if should_disable: command_context.update_fields(set_ssh_action="disable_ssh") @@ -136,6 +183,12 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.fail_with_error(message) return 1 + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH disable requested; not waiting for reboot or verifying SSH stays closed.") + command_context.succeed() + return 0 + print("Device is starting reboot now, waiting for it to shut down...") command_context.set_stage("wait_for_ssh_down") if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): @@ -175,7 +228,6 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 - print("SSH is configured. You can connect as 'root' using the AirPort admin password.") - command_context.succeed() - return 0 + command_context.fail_with_error("No set-ssh action selected.") + return 1 return 1 diff --git a/src/timecapsulesmb/cli/uninstall.py b/src/timecapsulesmb/cli/uninstall.py index 3cf3b0a4..db8ec11e 100644 --- a/src/timecapsulesmb/cli/uninstall.py +++ b/src/timecapsulesmb/cli/uninstall.py @@ -4,12 +4,12 @@ from typing import Optional from timecapsulesmb.cli.context import CommandContext -from timecapsulesmb.cli.flows import request_reboot_and_wait -from timecapsulesmb.cli.runtime import add_config_argument, load_env_config, print_json +from timecapsulesmb.cli.flows import request_reboot, request_reboot_and_wait +from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config, print_json from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME from timecapsulesmb.deploy.dry_run import format_uninstall_plan, uninstall_plan_to_jsonable from timecapsulesmb.deploy.executor import remote_uninstall_payload -from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS, build_uninstall_plan +from timecapsulesmb.deploy.planner import build_uninstall_plan from timecapsulesmb.deploy.verify import render_post_uninstall_verification, verify_post_uninstall from timecapsulesmb.device.storage import UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER from timecapsulesmb.identity import ensure_install_id @@ -20,6 +20,8 @@ def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Remove the managed TimeCapsuleSMB payload from the configured device.") add_config_argument(parser) + add_mount_wait_argument(parser) + add_no_wait_argument(parser) parser.add_argument("--yes", action="store_true", help="Do not prompt before reboot") parser.add_argument("--no-reboot", action="store_true", help="Remove files but do not reboot the device") parser.add_argument("--dry-run", action="store_true", help="Print actions without making changes") @@ -54,7 +56,7 @@ def main(argv: Optional[list[str]] = None) -> int: else: mounted_volumes = command_context.mount_mast_volumes( connection, - wait_seconds=DEFAULT_APPLE_MOUNT_WAIT_SECONDS, + wait_seconds=args.mount_wait, ) volume_roots = [volume.volume_root for volume in mounted_volumes] payload_dirs = [f"{volume_root}/{MANAGED_PAYLOAD_DIR_NAME}" for volume_root in volume_roots] @@ -100,6 +102,13 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 + if args.no_wait: + request_reboot(connection, command_context, require_request_success=True) + print("Reboot requested; not waiting for the device to go down or come back.") + print("Post-uninstall verification skipped.") + command_context.succeed() + return 0 + if not request_reboot_and_wait( connection, command_context, diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 037a1a97..ec4941f6 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -575,6 +575,10 @@ class ConfigProfile: required_file_values=("TC_HOST", "TC_PASSWORD"), validated_keys=("TC_HOST",), ), + "set_ssh_status": ConfigProfile( + required_file_values=("TC_HOST",), + validated_keys=("TC_HOST",), + ), "flash": ConfigProfile( required_file_values=FLASH_REQUIRED_FILE_KEYS, validated_keys=FLASH_VALIDATED_KEYS, diff --git a/src/timecapsulesmb/deploy/dry_run.py b/src/timecapsulesmb/deploy/dry_run.py index 5689c380..e91c4bfa 100644 --- a/src/timecapsulesmb/deploy/dry_run.py +++ b/src/timecapsulesmb/deploy/dry_run.py @@ -88,6 +88,12 @@ def deployment_plan_to_jsonable(plan: DeploymentPlan) -> dict[str, object]: return data +def activation_plan_to_jsonable(plan: ActivationPlan) -> dict[str, object]: + data = asdict(plan) + data["actions"] = remote_actions_to_jsonable(plan.actions) + return data + + def format_activation_plan(plan: ActivationPlan, *, device_name: str = "AirPort storage device") -> str: lines: list[str] = [] lines.append("Dry run: NetBSD4 activation plan") diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index 5194855e..c9e75bd0 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -62,30 +62,46 @@ def confirm_param(params: dict[str, object], name: str) -> bool: def int_param(params: dict[str, object], name: str, default: int) -> int: value = params.get(name, default) - try: + 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) - except (TypeError, ValueError) as exc: - raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + 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 +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") - try: + 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) - except (TypeError, ValueError) as exc: - raise AppOperationError(f"{name} must be an integer", code="validation_failed") from exc + 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): diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py index cd260378..67889e5e 100644 --- a/src/timecapsulesmb/services/maintenance.py +++ b/src/timecapsulesmb/services/maintenance.py @@ -1,8 +1,160 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Callable + +from timecapsulesmb.device.storage import MaStVolume + UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( "Reboot was requested but the device did not go down.\n" "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." ) FSCK_REBOOT_NO_DOWN_MESSAGE = "fsck requested reboot from the device, but SSH did not go down." + +NO_MOUNTED_HFS_VOLUMES_MESSAGE = "no mounted HFS volumes found" +MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE = "multiple mounted HFS volumes found; specify --volume to select one" + + +@dataclass(frozen=True) +class FsckTarget: + device: str + mountpoint: str + name: str + builtin: bool + + +def fsck_target_from_volume(volume: MaStVolume) -> FsckTarget: + return FsckTarget( + device=volume.device_path, + mountpoint=volume.volume_root, + name=volume.name, + builtin=volume.builtin, + ) + + +def normalize_volume_selector(selector: str) -> str: + selector = selector.strip() + if selector.startswith("/dev/"): + return selector.removeprefix("/dev/") + return selector + + +def select_fsck_target(targets: tuple[FsckTarget, ...], selector: str | None, *, prompt: bool = True) -> FsckTarget: + if not targets: + raise RuntimeError(NO_MOUNTED_HFS_VOLUMES_MESSAGE) + if selector: + selected_device = normalize_volume_selector(selector) + for target in targets: + if target.device == selector or target.device.removeprefix("/dev/") == selected_device: + return target + raise RuntimeError(f"HFS volume not found: {selector}") + if len(targets) == 1: + return targets[0] + if not prompt: + raise RuntimeError(MULTIPLE_MOUNTED_HFS_VOLUMES_MESSAGE) + + print(format_fsck_targets(targets)) + while True: + answer = input("Select a volume to fsck by number: ").strip() + if answer.isdigit(): + index = int(answer) + if 1 <= index <= len(targets): + return targets[index - 1] + print("Please enter a valid volume number.") + + +def fsck_target_to_jsonable(target: FsckTarget) -> dict[str, object]: + return { + "device": target.device, + "mountpoint": target.mountpoint, + "name": target.name, + "builtin": target.builtin, + } + + +def format_fsck_targets(targets: tuple[FsckTarget, ...]) -> str: + lines = ["Mounted HFS volumes:"] + if not targets: + lines.append(" none") + return "\n".join(lines) + for index, target in enumerate(targets, start=1): + kind = "internal" if target.builtin else "external" + lines.append(f" {index}. {target.device} on {target.mountpoint} ({target.name}, {kind})") + return "\n".join(lines) + + +def fsck_plan_to_jsonable(target: FsckTarget, *, reboot: bool, wait: bool) -> dict[str, object]: + return { + "target": fsck_target_to_jsonable(target), + "device": target.device, + "mountpoint": target.mountpoint, + "reboot_required": reboot, + "wait_after_reboot": bool(reboot and wait), + } + + +def format_fsck_plan(target: FsckTarget, *, reboot: bool, wait: bool) -> str: + lines = [ + "Dry run: fsck plan", + "", + "Target:", + f" device: {target.device}", + f" mountpoint: {target.mountpoint}", + f" name: {target.name}", + f" type: {'internal' if target.builtin else 'external'}", + "", + "Actions:", + " stop managed file sharing processes", + f" unmount: {target.mountpoint}", + f" run: /sbin/fsck_hfs -fy {target.device}", + "", + "Reboot:", + f" {'yes' if reboot else 'no'}", + ] + if reboot: + lines.append(f" follow-up: {'wait for SSH down, then SSH up' if wait else 'do not wait'}") + return "\n".join(lines) + + +class RepairExecutionContext: + def __init__(self, stage_callback: Callable[[str], None]) -> None: + self._stage_callback = stage_callback + self.result = "failure" + self.error: str | None = None + + def set_stage(self, stage: str) -> None: + self._stage_callback(stage) + + def update_fields(self, **_fields: object) -> None: + pass + + def succeed(self) -> None: + self.result = "success" + + def fail_with_error(self, message: str) -> None: + self.result = "failure" + self.error = message + + +class LineLogCapture: + def __init__(self, emit_line: Callable[[str], None]) -> None: + self._emit_line = emit_line + self._buffer = "" + + def write(self, text: str) -> int: + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def _emit(self, line: str) -> None: + message = line.rstrip("\r") + if message: + self._emit_line(message) diff --git a/tests/test_app_api.py b/tests/test_app_api.py index ede92e80..1a40d9de 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -24,9 +24,10 @@ from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState +from timecapsulesmb.device.storage import MaStVolume from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance from timecapsulesmb.integrations.acp import ACPAuthError -from timecapsulesmb.transport.errors import TransportError +from timecapsulesmb.transport.errors import SshError, TransportError from timecapsulesmb.transport.ssh import SshConnection @@ -496,6 +497,22 @@ def fake_run_doctor_checks(*_args, **kwargs): self.assertEqual(checks[0]["status"], "PASS") self.assertEqual(checks[0]["details"], {"port": 445}) + def test_doctor_passes_bonjour_timeout_to_checks(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", return_value=([], False)) as checks: + rc = service.run_api_request( + {"operation": "doctor", "params": {"bonjour_timeout": "2.75"}}, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(checks.call_args.kwargs["bonjour_timeout"], 2.75) + def test_doctor_fatal_returns_nonzero_result_without_error_event(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -652,6 +669,100 @@ def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: wait.assert_not_called() self.assertEqual(collector.events_of_type("result")[0]["payload"]["rebooted"], False) + def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): + with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot") as reboot: + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + reboot.assert_called_once() + wait.assert_not_called() + verify_runtime.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["reboot_requested"], True) + self.assertEqual(payload["waited"], False) + self.assertEqual(payload["verified"], False) + + def test_deploy_no_wait_reports_reboot_request_failure(self) -> None: + collector = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): + with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot: + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": False, + "confirm_deploy": True, + "confirm_reboot": True, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + reboot.assert_called_once() + wait.assert_not_called() + verify_runtime.assert_not_called() + errors = collector.events_of_type("error") + self.assertEqual(errors[0]["code"], "remote_error") + self.assertIn("ssh command failed with rc=255", errors[0]["message"]) + self.assertEqual(collector.events_of_type("result"), []) + def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -773,6 +884,43 @@ def test_uninstall_dry_run_bypasses_confirmation_and_returns_plan(self) -> None: self.assertEqual(result["payload"]["schema_version"], 1) uninstall.assert_not_called() + def test_uninstall_no_wait_uses_mount_wait_and_skips_post_reboot_verification(self) -> None: + collector = CollectingSink() + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + mounted = [SimpleNamespace(volume_root="/Volumes/dk2")] + + with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot") as reboot: + with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.maintenance.verify_post_uninstall") as verify: + rc = service.run_api_request( + { + "operation": "uninstall", + "params": { + "confirm_uninstall": True, + "confirm_reboot": True, + "mount_wait": 13, + "no_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 0) + self.assertEqual(mounted_mock.call_args.kwargs["wait_seconds"], 13) + reboot.assert_called_once() + wait.assert_not_called() + verify.assert_not_called() + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["reboot_requested"], True) + self.assertEqual(payload["waited"], False) + self.assertEqual(payload["verified"], False) + def test_fsck_requires_confirmation_before_remote_connection(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -785,6 +933,76 @@ def test_fsck_requires_confirmation_before_remote_connection(self) -> None: self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") resolve_connection.assert_not_called() + def test_fsck_rejects_non_integer_mount_wait_before_remote_connection(self) -> None: + for value in (12.5, True): + with self.subTest(value=value): + collector = CollectingSink() + with mock.patch("timecapsulesmb.app.operations.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.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.operations.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.operations.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted): + with mock.patch("timecapsulesmb.app.operations.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) diff --git a/tests/test_cli.py b/tests/test_cli.py index e234c0d0..99492269 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import errno +import argparse import io import json import plistlib @@ -1241,6 +1242,37 @@ def test_repair_xattrs_non_macos_emits_platform_check_telemetry(self) -> None: self.assertEqual(finished["host_platform"], "linux") self.assertIn("stage=platform_check", finished["error"]) + def test_repair_xattrs_json_emits_ndjson_result(self) -> None: + output = io.StringIO() + result = repair_xattrs.RepairRunResult( + returncode=0, + root=Path("/Volumes/Data"), + findings=[mock.Mock()], + candidates=[mock.Mock()], + summary=repair_xattrs.RepairSummary(scanned=1, repairable=1), + report="detected issues", + ) + with mock.patch("timecapsulesmb.cli.repair_xattrs.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.cli.repair_xattrs.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.cli.repair_xattrs.run_repair_structured", return_value=result): + with redirect_stdout(output): + rc = repair_xattrs.main(["--path", "/Volumes/Data", "--dry-run", "--json"]) + + self.assertEqual(rc, 0) + events = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertEqual(events[0]["type"], "stage") + self.assertEqual(events[-1]["type"], "result") + self.assertEqual(events[-1]["payload"]["finding_count"], 1) + self.assertEqual(events[-1]["payload"]["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): @@ -1557,6 +1589,18 @@ def test_configure_hidden_debug_logging_arg_writes_true(self) -> None: self.assertEqual(result.rc, 0) self.assertEqual(result.values["TC_DEBUG_LOGGING"], "true") + def test_configure_bonjour_timeout_reaches_discovery(self) -> None: + result = self.run_configure_cli( + ["--bonjour-timeout", "1.25"], + prompt_side_effect=self.configure_prompt_defaults(), + probe_state=self.make_probe_state(self.make_probe_result_unreachable()), + confirm=True, + command_context=FakeCommandContext(), + ) + self.assertEqual(result.rc, 0) + result.mocks.discover_resolved_records.assert_called_once() + self.assertEqual(result.mocks.discover_resolved_records.call_args.kwargs["timeout"], 1.25) + def test_configure_preserves_existing_debug_logging_when_arg_is_omitted(self) -> None: result = self.run_configure_cli( existing_values={"TC_DEBUG_LOGGING": "true"}, @@ -4125,6 +4169,64 @@ def test_set_ssh_enable_flow_succeeds(self) -> None: self.assertEqual(finished["ssh_initially_reachable"], False) self.assertEqual(finished["ssh_final_reachable"], True) + def test_set_ssh_status_requires_only_host(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--status"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH enabled.", output.getvalue()) + enable_mock.assert_not_called() + disable_mock.assert_not_called() + finished = self.telemetry_payload("set_ssh_finished") + self.assertEqual(finished["set_ssh_action"], "status") + + def test_set_ssh_explicit_enable_is_noop_when_already_enabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--enable"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH already enabled.", output.getvalue()) + enable_mock.assert_not_called() + + def test_set_ssh_explicit_disable_is_noop_when_already_disabled(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=False): + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--disable"]) + + self.assertEqual(rc, 0) + self.assertIn("SSH already disabled.", output.getvalue()) + disable_mock.assert_not_called() + + def test_set_ssh_no_wait_skips_enable_verification(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=False): + with mock.patch("timecapsulesmb.cli.set_ssh.enable_ssh") as enable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state") as wait_mock: + with redirect_stdout(output): + rc = set_ssh.main(["--enable", "--no-wait"]) + + self.assertEqual(rc, 0) + enable_mock.assert_called_once() + wait_mock.assert_not_called() + self.assertIn("not waiting for SSH to open", output.getvalue()) + def test_set_ssh_enable_exception_emits_failure_stage(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} @@ -4279,6 +4381,21 @@ def test_set_ssh_disable_flow_confirms_ssh_disabled(self) -> None: self.assertEqual(finished["ssh_final_reachable"], False) self.assertEqual(finished["ssh_disable_persisted"], True) + def test_set_ssh_yes_disables_legacy_enabled_state_without_prompt(self) -> None: + output = io.StringIO() + values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} + with mock.patch("timecapsulesmb.cli.set_ssh.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.set_ssh.tcp_open", return_value=True): + with mock.patch("builtins.input", side_effect=AssertionError("--yes should skip prompt")) as input_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.disable_ssh_over_ssh") as disable_mock: + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_tcp_port_state", side_effect=[True, True]): + with mock.patch("timecapsulesmb.cli.set_ssh.wait_for_device_up", return_value=True): + with redirect_stdout(output): + rc = set_ssh.main(["--yes"]) + self.assertEqual(rc, 0) + input_mock.assert_not_called() + disable_mock.assert_called_once() + def test_doctor_json_outputs_structured_results(self) -> None: output = io.StringIO() fake_result = doctor.CheckResult("PASS", "ok") @@ -4291,6 +4408,16 @@ def test_doctor_json_outputs_structured_results(self) -> None: self.assertEqual(payload["fatal"], False) self.assertEqual(payload["results"][0]["status"], "PASS") + def test_doctor_bonjour_timeout_reaches_checks(self) -> None: + output = io.StringIO() + fake_result = doctor.CheckResult("PASS", "ok") + with mock.patch("timecapsulesmb.cli.doctor.load_env_config", return_value=self.make_app_config({})): + with mock.patch("timecapsulesmb.cli.doctor.run_doctor_checks", return_value=([fake_result], False)) as checks_mock: + with redirect_stdout(output): + rc = doctor.main(["--bonjour-timeout", "2.5"]) + self.assertEqual(rc, 0) + self.assertEqual(checks_mock.call_args.kwargs["bonjour_timeout"], 2.5) + def test_doctor_ensures_install_id_before_telemetry(self) -> None: output = io.StringIO() fake_result = doctor.CheckResult("PASS", "ok") @@ -4520,6 +4647,39 @@ def fake_upload(_plan, *, connection, source_resolver): self.assertIn("SMBD_DEBUG_LOGGING=0\n", captured["flash_config"]) self.assertIn("MDNS_DEBUG_LOGGING=0\n", captured["flash_config"]) + def test_deploy_no_wait_requests_reboot_without_observation_or_runtime_verify(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + patch_actions=True, + patch_upload=True, + wait_side_effect=AssertionError("deploy --no-wait should not observe SSH state"), + verify_runtime=self.managed_runtime_probe(False), + ) + + self.assertEqual(result.rc, 0) + result.mocks.remote_request_shutdown_reboot.assert_called_once() + result.mocks.wait_for_ssh_state_conn.assert_not_called() + result.mocks.verify_managed_runtime.assert_not_called() + self.assertIn("not waiting for the device", result.text) + + def test_deploy_no_wait_fails_when_reboot_request_fails(self) -> None: + result = self.run_deploy_cli( + ["--yes", "--no-wait"], + patch_actions=True, + patch_upload=True, + reboot_side_effect=SshError("ssh command failed with rc=255"), + wait_side_effect=AssertionError("deploy --no-wait should not observe SSH state after request failure"), + verify_runtime=self.managed_runtime_probe(False), + raises=SystemExit, + ) + + self.assertIn("ssh command failed with rc=255", str(result.exception)) + result.mocks.remote_request_shutdown_reboot.assert_called_once() + result.mocks.wait_for_ssh_state_conn.assert_not_called() + result.mocks.verify_managed_runtime.assert_not_called() + finished = self.telemetry_payload("deploy_finished") + self.assertEqual(finished["result"], "failure") + def test_deploy_rejects_removed_install_nbns_flag(self) -> None: stderr = io.StringIO() with redirect_stderr(stderr): @@ -4528,6 +4688,20 @@ def test_deploy_rejects_removed_install_nbns_flag(self) -> None: self.assertEqual(raised.exception.code, 2) self.assertIn("unrecognized arguments: --install-nbns", stderr.getvalue()) + def test_negative_shared_timeouts_are_rejected_by_parsers(self) -> None: + cases = ( + (deploy.main, ["--mount-wait", "-1", "--dry-run"], "must be 0 or greater"), + (doctor.main, ["--bonjour-timeout", "-0.1"], "must be 0 or greater"), + ) + for entrypoint, argv, message in cases: + with self.subTest(argv=argv): + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + entrypoint(argv) + self.assertEqual(raised.exception.code, 2) + self.assertIn(message, stderr.getvalue()) + def test_deploy_exits_when_mast_volumes_are_not_writable(self) -> None: volumes = (self._mast_volume("dk2"),) result = self.run_deploy_cli( @@ -6382,8 +6556,50 @@ def fake_get_property(_host: str, _password: str, name: str, **_kwargs: object) self.assertNotIn("verify Samba startup", text) finished = command_context.finish.call_args.kwargs self.assertEqual(finished["result"], "success") - self.assertEqual(finished["reboot_was_attempted"], True) - self.assertEqual(finished["device_came_back_after_reboot"], True) + + def test_flash_restore_reboot_no_wait_skips_reboot_observation(self) -> None: + output = io.StringIO() + command_context = FakeCommandContext() + target = SimpleNamespace(connection=SshConnection("root@10.0.0.2", "pw", "-o foo")) + args = argparse.Namespace(reboot=True, no_wait=True) + + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot") as reboot_mock: + with mock.patch("timecapsulesmb.cli.flash.observe_reboot_cycle") as observe_mock: + with redirect_stdout(output): + rc = cli_flash._finish_write( + command_context, + args=args, + operation="restore", + target=target, + log=None, + ) + + self.assertEqual(rc, 0) + reboot_mock.assert_called_once() + observe_mock.assert_not_called() + self.assertIn("not waiting for the device", output.getvalue()) + + def test_flash_restore_reboot_no_wait_fails_when_reboot_request_fails(self) -> None: + output = io.StringIO() + command_context = FakeCommandContext() + target = SimpleNamespace(connection=SshConnection("root@10.0.0.2", "pw", "-o foo")) + args = argparse.Namespace(reboot=True, no_wait=True) + + with mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot_mock: + with mock.patch("timecapsulesmb.cli.flash.observe_reboot_cycle") as observe_mock: + with self.assertRaises(SshError): + with redirect_stdout(output): + cli_flash._finish_write( + command_context, + args=args, + operation="restore", + target=target, + log=None, + ) + + reboot_mock.assert_called_once() + observe_mock.assert_not_called() + self.assertNotIn("not waiting for the device", output.getvalue()) def test_flash_restore_noops_when_active_bank_already_matches_apple(self) -> None: output = io.StringIO() @@ -7046,6 +7262,26 @@ def test_activate_returns_nonzero_when_verification_fails(self) -> None: self.assertEqual(rc, 1) self.assertIn("NetBSD4 activation failed.", output.getvalue()) + def test_activate_dry_run_json_outputs_activation_plan(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with mock.patch("timecapsulesmb.cli.activate.load_env_config", return_value=self.make_app_config(values)): + with mock.patch("timecapsulesmb.cli.context.CommandContext.require_compatibility", return_value=self.make_supported_netbsd4_compatibility()): + with redirect_stdout(output): + rc = activate.main(["--dry-run", "--json"]) + self.assertEqual(rc, 0) + payload = json.loads(output.getvalue()) + self.assertIn("actions", payload) + self.assertTrue(all("kind" in action for action in payload["actions"])) + + def test_activate_json_requires_dry_run(self) -> None: + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + activate.main(["--json"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("--json currently requires --dry-run", stderr.getvalue()) + def test_uninstall_dry_run_prints_target_host(self) -> None: output = io.StringIO() values = { @@ -7194,6 +7430,49 @@ def test_uninstall_yes_reboots_and_verifies(self) -> None: self.assertEqual(finished["device_came_back_after_reboot"], True) self.assertEqual(finished["post_uninstall_verified"], True) + def test_uninstall_mount_wait_and_no_wait_skip_reboot_observation_and_verify(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.load_env_config", return_value=self.make_app_config(values))) + mast_mocks = self._patch_mast_volume_flow(stack, "uninstall") + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) + reboot_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.remote_request_reboot")) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn")) + verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) + with redirect_stdout(output): + rc = uninstall.main(["--yes", "--mount-wait", "17", "--no-wait"]) + + self.assertEqual(rc, 0) + self.assertEqual(mast_mocks.mounted_mast_volumes_conn.call_args.kwargs["wait_seconds"], 17) + reboot_mock.assert_called_once() + wait_mock.assert_not_called() + verify_mock.assert_not_called() + self.assertIn("Post-uninstall verification skipped.", output.getvalue()) + + def test_uninstall_no_wait_fails_when_reboot_request_fails(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.load_env_config", return_value=self.make_app_config(values))) + self._patch_mast_volume_flow(stack, "uninstall") + stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.remote_uninstall_payload")) + reboot_mock = stack.enter_context( + mock.patch("timecapsulesmb.cli.flows.remote_request_reboot", side_effect=SshError("ssh command failed with rc=255")) + ) + wait_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.flows.wait_for_ssh_state_conn")) + verify_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.uninstall.verify_post_uninstall")) + with self.assertRaises(SystemExit) as raised: + with redirect_stdout(output): + uninstall.main(["--yes", "--no-wait"]) + + self.assertIn("ssh command failed with rc=255", str(raised.exception)) + reboot_mock.assert_called_once() + wait_mock.assert_not_called() + verify_mock.assert_not_called() + finished = self.telemetry_payload("uninstall_finished") + self.assertEqual(finished["result"], "failure") + def test_uninstall_reboot_request_timeout_continues_when_device_reboots(self) -> None: output = io.StringIO() values = self.make_valid_env() @@ -7436,6 +7715,43 @@ def test_fsck_no_wait_skips_ssh_waits(self) -> None: self.assertEqual(rc, 0) observe_mock.assert_not_called() + def test_fsck_list_volumes_mounts_with_custom_wait_without_remote_fsck(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.load_env_config", return_value=self.make_app_config(values))) + mast_mocks = self._patch_mast_volume_flow( + stack, + "fsck", + mounted_volumes=(self._mast_volume("dk2"), self._mast_volume("dk5", builtin=False)), + ) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh")) + with redirect_stdout(output): + rc = fsck.main(["--list-volumes", "--mount-wait", "11"]) + + self.assertEqual(rc, 0) + self.assertEqual(mast_mocks.mounted_mast_volumes_conn.call_args.kwargs["wait_seconds"], 11) + run_ssh_mock.assert_not_called() + self.assertIn("Mounted HFS volumes:", output.getvalue()) + self.assertIn("/dev/dk5", output.getvalue()) + + def test_fsck_dry_run_selects_target_without_remote_fsck_or_prompt(self) -> None: + output = io.StringIO() + values = self.make_valid_env() + with ExitStack() as stack: + stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.load_env_config", return_value=self.make_app_config(values))) + self._patch_mast_volume_flow(stack, "fsck", mounted_volumes=(self._mast_volume("dk2"),)) + input_mock = stack.enter_context(mock.patch("builtins.input", side_effect=AssertionError("dry run should not prompt"))) + run_ssh_mock = stack.enter_context(mock.patch("timecapsulesmb.cli.fsck.run_ssh")) + with redirect_stdout(output): + rc = fsck.main(["--dry-run"]) + + self.assertEqual(rc, 0) + input_mock.assert_not_called() + run_ssh_mock.assert_not_called() + self.assertIn("Dry run: fsck plan", output.getvalue()) + self.assertIn("/sbin/fsck_hfs -fy /dev/dk2", output.getvalue()) + def test_fsck_no_reboot_omits_reboot_and_waits(self) -> None: output = io.StringIO() values = { From b8fc311a9fd872056157de925780bc0e55ec3812 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 00:12:40 -0700 Subject: [PATCH 07/20] Helper hardening, localized UI strings, and centralized Swift operation params --- macos/TimeCapsuleSMB/Package.swift | 4 +- .../TimeCapsuleSMBApp/ContentView.swift | 196 ++++++++++-------- .../TimeCapsuleSMBApp/HelperRunner.swift | 2 +- .../TimeCapsuleSMBApp/Localization.swift | 7 + .../TimeCapsuleSMBApp/OperationParams.swift | 125 +++++++++++ .../PendingConfirmation.swift | 94 ++++----- .../Resources/en.lproj/Localizable.strings | 68 ++++++ .../HelperRunnerTests.swift | 24 +++ .../PendingConfirmationTests.swift | 14 ++ src/timecapsulesmb/app/helper.py | 15 +- src/timecapsulesmb/services/app.py | 3 + tests/test_app_api.py | 79 +++++++ 12 files changed, 486 insertions(+), 145 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings diff --git a/macos/TimeCapsuleSMB/Package.swift b/macos/TimeCapsuleSMB/Package.swift index a5ccd2a6..b29a7506 100644 --- a/macos/TimeCapsuleSMB/Package.swift +++ b/macos/TimeCapsuleSMB/Package.swift @@ -13,6 +13,7 @@ let xcodeLinkerSettings: [LinkerSetting] = xcodeFrameworkFlags.isEmpty ? [] : [. let package = Package( name: "TimeCapsuleSMBMac", + defaultLocalization: "en", platforms: [.macOS(.v13)], products: [ .executable(name: "TimeCapsuleSMB", targets: ["TimeCapsuleSMBExecutable"]) @@ -20,7 +21,8 @@ let package = Package( targets: [ .target( name: "TimeCapsuleSMBApp", - path: "Sources/TimeCapsuleSMBApp" + path: "Sources/TimeCapsuleSMBApp", + resources: [.process("Resources")] ), .executableTarget( name: "TimeCapsuleSMBExecutable", diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 48fa7c2d..46de61c5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -37,13 +37,13 @@ public struct ContentView: View { Button { backend.clear() } label: { - Label("Clear", systemImage: "trash") + Label(L10n.string("toolbar.clear"), systemImage: "trash") } .disabled(backend.isRunning) Button { backend.cancel() } label: { - Label("Cancel", systemImage: "xmark.circle") + Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") } .disabled(!backend.isRunning) } @@ -59,7 +59,7 @@ public struct ContentView: View { backend.run(operation: confirmation.operation, params: confirmation.params) pendingConfirmation = nil } - Button("Cancel", role: .cancel) { + Button(L10n.string("action.cancel"), role: .cancel) { pendingConfirmation = nil } } message: { confirmation in @@ -82,56 +82,61 @@ public struct ContentView: View { private var form: some View { switch selection { case .readiness: - CommandPanel(title: "Readiness") { - TextField("Helper", text: $backend.helperPath) + CommandPanel(title: L10n.string("screen.readiness")) { + TextField(L10n.string("field.helper"), text: $backend.helperPath) HStack { - runButton("Paths", icon: "folder", operation: "paths") - runButton("Validate", icon: "checkmark.seal", operation: "validate-install") + runButton(L10n.string("button.paths"), icon: "folder", operation: "paths") + runButton(L10n.string("button.validate"), icon: "checkmark.seal", operation: "validate-install") } } case .connect: - CommandPanel(title: "Discover And Connect") { - TextField("Host", text: $host) - SecureField("Password", text: $password) - TextField("Bonjour timeout seconds", text: $bonjourTimeout) - Toggle("Enable Debug Logging", isOn: $configureDebugLogging) + CommandPanel(title: L10n.string("panel.connect")) { + TextField(L10n.string("field.host"), text: $host) + SecureField(L10n.string("field.password"), text: $password) + TextField(L10n.string("field.bonjour_timeout"), text: $bonjourTimeout) + Toggle(L10n.string("toggle.enable_debug_logging"), isOn: $configureDebugLogging) HStack { - runButton("Discover", icon: "network", operation: "discover", params: [ - "timeout": numberValue(bonjourTimeout, default: 6) - ]) + runButton( + L10n.string("button.discover"), + icon: "network", + operation: "discover", + params: OperationParams.discover(timeout: numberDouble(bonjourTimeout, default: 6)) + ) Button { - var params: [String: JSONValue] = [ - "host": .string(host), - "password": .string(password) - ] - if configureDebugLogging { - params["debug_logging"] = .bool(true) - } - backend.run(operation: "configure", params: params) + backend.run( + operation: "configure", + params: OperationParams.configure( + host: host, + password: password, + debugLogging: configureDebugLogging + ) + ) } label: { - Label("Configure", systemImage: "lock.open") + Label(L10n.string("button.configure"), systemImage: "lock.open") } .disabled(backend.isRunning || password.isEmpty) } } case .deploy: - CommandPanel(title: "Deploy") { - Toggle("Enable NBNS", isOn: $nbnsEnabled) - Toggle("No Reboot", isOn: $noReboot) - Toggle("No Wait", isOn: $noWait) - Toggle("Dry Run", isOn: $dryRun) - Toggle("Force Debug Logging", isOn: $deployDebugLogging) - TextField("Mount wait seconds", text: $mountWait) + CommandPanel(title: L10n.string("screen.deploy")) { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $nbnsEnabled) + Toggle(L10n.string("toggle.no_reboot"), isOn: $noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) + Toggle(L10n.string("toggle.dry_run"), isOn: $dryRun) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $deployDebugLogging) + TextField(L10n.string("field.mount_wait"), text: $mountWait) Button { if dryRun { - backend.run(operation: "deploy", params: [ - "dry_run": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(deployDebugLogging), - "mount_wait": numberValue(mountWait, default: 30) - ]) + backend.run( + operation: "deploy", + params: OperationParams.deployPlan( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging, + mountWait: numberDouble(mountWait, default: 30) + ) + ) } else { pendingConfirmation = .deploy( noReboot: noReboot, @@ -142,37 +147,47 @@ public struct ContentView: View { ) } } label: { - Label(dryRun ? "Plan Deploy" : "Deploy", systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up") + Label( + dryRun ? L10n.string("button.plan_deploy") : L10n.string("button.deploy"), + systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up" + ) } .disabled(backend.isRunning) } case .doctor: - CommandPanel(title: "Doctor") { - TextField("Bonjour timeout seconds", text: $bonjourTimeout) - runButton("Run Doctor", icon: "stethoscope", operation: "doctor", params: [ - "bonjour_timeout": numberValue(bonjourTimeout, default: 6) - ]) + CommandPanel(title: L10n.string("screen.doctor")) { + TextField(L10n.string("field.bonjour_timeout"), text: $bonjourTimeout) + runButton( + L10n.string("button.run_doctor"), + icon: "stethoscope", + operation: "doctor", + params: OperationParams.doctor(bonjourTimeout: numberDouble(bonjourTimeout, default: 6)) + ) } case .maintenance: - CommandPanel(title: "Maintenance") { - TextField("Repair xattrs path", text: $repairPath) - TextField("fsck volume, optional", text: $volume) - TextField("Mount wait seconds", text: $mountWait) - Toggle("No Reboot", isOn: $noReboot) - Toggle("No Wait", isOn: $noWait) + CommandPanel(title: L10n.string("screen.maintenance")) { + TextField(L10n.string("field.repair_xattrs_path"), text: $repairPath) + TextField(L10n.string("field.fsck_volume"), text: $volume) + TextField(L10n.string("field.mount_wait"), text: $mountWait) + Toggle(L10n.string("toggle.no_reboot"), isOn: $noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) HStack { Button { pendingConfirmation = .activate() } label: { - Label("Activate", systemImage: "power") + Label(L10n.string("button.activate"), systemImage: "power") } .disabled(backend.isRunning) - runButton("Uninstall Plan", icon: "xmark.bin", operation: "uninstall", params: [ - "dry_run": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": numberValue(mountWait, default: 30) - ]) + runButton( + L10n.string("button.uninstall_plan"), + icon: "xmark.bin", + operation: "uninstall", + params: OperationParams.uninstallPlan( + noReboot: noReboot, + noWait: noWait, + mountWait: numberDouble(mountWait, default: 30) + ) + ) Button { pendingConfirmation = .uninstall( noReboot: noReboot, @@ -180,22 +195,28 @@ public struct ContentView: View { noWait: noWait ) } label: { - Label("Uninstall", systemImage: "xmark.bin.fill") + Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") } .disabled(backend.isRunning) } HStack { - runButton("List fsck Volumes", icon: "list.bullet.rectangle", operation: "fsck", params: [ - "list_volumes": .bool(true), - "mount_wait": numberValue(mountWait, default: 30) - ]) - runButton("Plan fsck", icon: "doc.text.magnifyingglass", operation: "fsck", params: [ - "dry_run": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": numberValue(mountWait, default: 30), - "volume": .string(volume) - ]) + runButton( + L10n.string("button.list_fsck_volumes"), + icon: "list.bullet.rectangle", + operation: "fsck", + params: OperationParams.fsckList(mountWait: numberDouble(mountWait, default: 30)) + ) + runButton( + L10n.string("button.plan_fsck"), + icon: "doc.text.magnifyingglass", + operation: "fsck", + params: OperationParams.fsckPlan( + volume: volume, + noReboot: noReboot, + noWait: noWait, + mountWait: numberDouble(mountWait, default: 30) + ) + ) Button { pendingConfirmation = .fsck( volume: volume, @@ -204,33 +225,33 @@ public struct ContentView: View { noWait: noWait ) } label: { - Label("Run fsck", systemImage: "externaldrive.badge.checkmark") + Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") } .disabled(backend.isRunning) } HStack { Button { - backend.run(operation: "repair-xattrs", params: [ - "path": .string(repairPath), - "dry_run": .bool(true) - ]) + backend.run( + operation: "repair-xattrs", + params: OperationParams.repairXattrsScan(path: repairPath) + ) } label: { - Label("Scan xattrs", systemImage: "wand.and.stars") + Label(L10n.string("button.scan_xattrs"), systemImage: "wand.and.stars") } .disabled(backend.isRunning || repairPath.isEmpty) Button { pendingConfirmation = .repairXattrs(path: repairPath) } label: { - Label("Repair xattrs", systemImage: "wand.and.stars.inverse") + Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") } .disabled(backend.isRunning || repairPath.isEmpty) } } case .advanced: - CommandPanel(title: "Advanced") { - Text("Flash backup, patch, and restore remain CLI-only in this version.") + CommandPanel(title: L10n.string("screen.advanced")) { + Text(L10n.string("advanced.flash_cli_only")) .foregroundStyle(.secondary) - Text("Use `.venv/bin/tcapsule flash --help` for firmware operations.") + Text(L10n.string("advanced.flash_help")) .font(.system(.body, design: .monospaced)) } } @@ -255,9 +276,6 @@ public struct ContentView: View { return Double(trimmed) ?? defaultValue } - private func numberValue(_ text: String, default defaultValue: Double) -> JSONValue { - .number(numberDouble(text, default: defaultValue)) - } } private enum Screen: String, CaseIterable, Identifiable { @@ -272,12 +290,12 @@ private enum Screen: String, CaseIterable, Identifiable { var title: String { switch self { - case .readiness: return "Readiness" - case .connect: return "Connect" - case .deploy: return "Deploy" - case .doctor: return "Doctor" - case .maintenance: return "Maintenance" - case .advanced: return "Advanced" + case .readiness: return L10n.string("screen.readiness") + case .connect: return L10n.string("screen.connect") + case .deploy: return L10n.string("screen.deploy") + case .doctor: return L10n.string("screen.doctor") + case .maintenance: return L10n.string("screen.maintenance") + case .advanced: return L10n.string("screen.advanced") } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index cbef5bcf..b3112a28 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -134,7 +134,7 @@ public final class HelperRunner { output.append(data.prefix(limit - output.count)) } } - return String(data: output, encoding: .utf8) ?? "" + return String(decoding: output, as: UTF8.self) } private static func waitForExit(_ process: Process) async { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift new file mode 100644 index 00000000..54586039 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift @@ -0,0 +1,7 @@ +import Foundation + +enum L10n { + static func string(_ key: String) -> String { + NSLocalizedString(key, bundle: .module, comment: "") + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift new file mode 100644 index 00000000..d4f487fa --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -0,0 +1,125 @@ +import Foundation + +enum OperationParams { + static func discover(timeout: Double) -> [String: JSONValue] { + ["timeout": .number(timeout)] + } + + static func configure(host: String, password: String, debugLogging: Bool) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "host": .string(host), + "password": .string(password) + ] + if debugLogging { + params["debug_logging"] = .bool(true) + } + return params + } + + static func doctor(bonjourTimeout: Double) -> [String: JSONValue] { + ["bonjour_timeout": .number(bonjourTimeout)] + } + + static func deployPlan( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWait: Double + ) -> [String: JSONValue] { + [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait) + ] + } + + static func deployConfirmed( + noReboot: Bool, + noWait: Bool, + nbnsEnabled: Bool, + debugLogging: Bool, + mountWait: Double + ) -> [String: JSONValue] { + [ + "dry_run": .bool(false), + "confirm_deploy": .bool(true), + "confirm_reboot": .bool(!noReboot), + "confirm_netbsd4_activation": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "nbns_enabled": .bool(nbnsEnabled), + "debug_logging": .bool(debugLogging), + "mount_wait": .number(mountWait) + ] + } + + static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ] + } + + static func uninstallConfirmed(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(false), + "confirm_uninstall": .bool(true), + "confirm_reboot": .bool(!noReboot), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait) + ] + } + + static func activateConfirmed() -> [String: JSONValue] { + ["confirm_netbsd4_activation": .bool(true)] + } + + static func fsckList(mountWait: Double) -> [String: JSONValue] { + [ + "list_volumes": .bool(true), + "mount_wait": .number(mountWait) + ] + } + + static func fsckPlan(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "dry_run": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), + "volume": .string(volume) + ] + } + + static func fsckConfirmed(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { + [ + "confirm_fsck": .bool(true), + "no_reboot": .bool(noReboot), + "no_wait": .bool(noWait), + "mount_wait": .number(mountWait), + "volume": .string(volume) + ] + } + + static func repairXattrsScan(path: String) -> [String: JSONValue] { + [ + "path": .string(path), + "dry_run": .bool(true) + ] + } + + static func repairXattrsConfirmed(path: String) -> [String: JSONValue] { + [ + "path": .string(path), + "dry_run": .bool(false), + "confirm_repair": .bool(true) + ] + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 7a777460..41f13f12 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -10,90 +10,78 @@ struct PendingConfirmation: Identifiable { static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Deploy Without Reboot?" : (noWait ? "Deploy And Skip Waiting?" : "Deploy And Reboot?"), + title: noReboot ? L10n.string("confirm.deploy.no_reboot.title") : (noWait ? L10n.string("confirm.deploy.no_wait.title") : L10n.string("confirm.deploy.reboot.title")), message: noReboot - ? "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device." + ? L10n.string("confirm.deploy.no_reboot.message") : (noWait - ? "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device." - : "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately."), - actionTitle: noReboot ? "Deploy" : "Deploy And Allow Reboot", + ? L10n.string("confirm.deploy.no_wait.message") + : L10n.string("confirm.deploy.reboot.message")), + actionTitle: noReboot ? L10n.string("action.deploy") : L10n.string("action.deploy_allow_reboot"), operation: "deploy", - params: [ - "dry_run": .bool(false), - "confirm_deploy": .bool(true), - "confirm_reboot": .bool(!noReboot), - "confirm_netbsd4_activation": .bool(true), - "no_reboot": .bool(noReboot), - "nbns_enabled": .bool(nbnsEnabled), - "debug_logging": .bool(debugLogging), - "mount_wait": .number(mountWait), - "no_wait": .bool(noWait) - ] + params: OperationParams.deployConfirmed( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: debugLogging, + mountWait: mountWait + ) ) } static func activate() -> PendingConfirmation { PendingConfirmation( - title: "Activate NetBSD 4 Runtime?", - message: "This will restart the deployed Samba runtime on an older NetBSD 4 device.", - actionTitle: "Activate", + title: L10n.string("confirm.activate.title"), + message: L10n.string("confirm.activate.message"), + actionTitle: L10n.string("action.activate"), operation: "activate", - params: ["confirm_netbsd4_activation": .bool(true)] + params: OperationParams.activateConfirmed() ) } static func fsck(volume: String, noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Run Disk Repair Without Reboot?" : (noWait ? "Run Disk Repair And Skip Waiting?" : "Run Disk Repair And Reboot?"), + title: noReboot ? L10n.string("confirm.fsck.no_reboot.title") : (noWait ? L10n.string("confirm.fsck.no_wait.title") : L10n.string("confirm.fsck.reboot.title")), message: noReboot - ? "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward." + ? L10n.string("confirm.fsck.no_reboot.message") : (noWait - ? "This will run fsck on the selected Time Capsule disk and return after requesting reboot." - : "This will run fsck on the selected Time Capsule disk and wait for the device to reboot."), - actionTitle: "Run fsck", + ? L10n.string("confirm.fsck.no_wait.message") + : L10n.string("confirm.fsck.reboot.message")), + actionTitle: L10n.string("action.run_fsck"), operation: "fsck", - params: [ - "confirm_fsck": .bool(true), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": .number(mountWait), - "volume": .string(volume) - ] + params: OperationParams.fsckConfirmed( + volume: volume, + noReboot: noReboot, + noWait: noWait, + mountWait: mountWait + ) ) } static func uninstall(noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { PendingConfirmation( - title: noReboot ? "Uninstall Without Reboot?" : (noWait ? "Uninstall And Skip Waiting?" : "Uninstall And Reboot?"), + title: noReboot ? L10n.string("confirm.uninstall.no_reboot.title") : (noWait ? L10n.string("confirm.uninstall.no_wait.title") : L10n.string("confirm.uninstall.reboot.title")), message: noReboot - ? "This will remove the managed TimeCapsuleSMB payload without rebooting the device." + ? L10n.string("confirm.uninstall.no_reboot.message") : (noWait - ? "This will remove the managed TimeCapsuleSMB payload, request reboot, and return without waiting." - : "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."), - actionTitle: "Uninstall", + ? L10n.string("confirm.uninstall.no_wait.message") + : L10n.string("confirm.uninstall.reboot.message")), + actionTitle: L10n.string("action.uninstall"), operation: "uninstall", - params: [ - "dry_run": .bool(false), - "confirm_uninstall": .bool(true), - "confirm_reboot": .bool(!noReboot), - "no_reboot": .bool(noReboot), - "no_wait": .bool(noWait), - "mount_wait": .number(mountWait) - ] + params: OperationParams.uninstallConfirmed( + noReboot: noReboot, + noWait: noWait, + mountWait: mountWait + ) ) } static func repairXattrs(path: String) -> PendingConfirmation { PendingConfirmation( - title: "Repair Extended Attributes?", - message: "This will repair extended attributes at the selected mounted SMB path.", - actionTitle: "Repair xattrs", + title: L10n.string("confirm.repair_xattrs.title"), + message: L10n.string("confirm.repair_xattrs.message"), + actionTitle: L10n.string("action.repair_xattrs"), operation: "repair-xattrs", - params: [ - "path": .string(path), - "dry_run": .bool(false), - "confirm_repair": .bool(true) - ] + params: OperationParams.repairXattrsConfirmed(path: path) ) } } 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..f44ed3c3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -0,0 +1,68 @@ +"action.activate" = "Activate"; +"action.cancel" = "Cancel"; +"action.deploy" = "Deploy"; +"action.deploy_allow_reboot" = "Deploy And Allow Reboot"; +"action.repair_xattrs" = "Repair xattrs"; +"action.run_fsck" = "Run fsck"; +"action.uninstall" = "Uninstall"; +"advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; +"advanced.flash_help" = "Use `.venv/bin/tcapsule flash --help` for firmware operations."; +"button.activate" = "Activate"; +"button.configure" = "Configure"; +"button.deploy" = "Deploy"; +"button.discover" = "Discover"; +"button.list_fsck_volumes" = "List fsck Volumes"; +"button.paths" = "Paths"; +"button.plan_deploy" = "Plan Deploy"; +"button.plan_fsck" = "Plan fsck"; +"button.repair_xattrs" = "Repair xattrs"; +"button.run_doctor" = "Run Doctor"; +"button.run_fsck" = "Run fsck"; +"button.scan_xattrs" = "Scan xattrs"; +"button.uninstall" = "Uninstall"; +"button.uninstall_plan" = "Uninstall Plan"; +"button.validate" = "Validate"; +"confirm.activate.message" = "This will restart the deployed Samba runtime on an older NetBSD 4 device."; +"confirm.activate.title" = "Activate NetBSD 4 Runtime?"; +"confirm.deploy.no_reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device."; +"confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; +"confirm.deploy.no_wait.message" = "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device."; +"confirm.deploy.no_wait.title" = "Deploy And Skip Waiting?"; +"confirm.deploy.reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload. NetBSD 6 devices will reboot; NetBSD 4 devices may activate the runtime immediately."; +"confirm.deploy.reboot.title" = "Deploy And Reboot?"; +"confirm.fsck.no_reboot.message" = "This will run fsck on the selected Time Capsule disk without requesting a reboot afterward."; +"confirm.fsck.no_reboot.title" = "Run Disk Repair Without Reboot?"; +"confirm.fsck.no_wait.message" = "This will run fsck on the selected Time Capsule disk and return after requesting reboot."; +"confirm.fsck.no_wait.title" = "Run Disk Repair And Skip Waiting?"; +"confirm.fsck.reboot.message" = "This will run fsck on the selected Time Capsule disk and wait for the device to reboot."; +"confirm.fsck.reboot.title" = "Run Disk Repair And Reboot?"; +"confirm.repair_xattrs.message" = "This will repair extended attributes at the selected mounted SMB path."; +"confirm.repair_xattrs.title" = "Repair Extended Attributes?"; +"confirm.uninstall.no_reboot.message" = "This will remove the managed TimeCapsuleSMB payload without rebooting the device."; +"confirm.uninstall.no_reboot.title" = "Uninstall Without Reboot?"; +"confirm.uninstall.no_wait.message" = "This will remove the managed TimeCapsuleSMB payload, request reboot, and return without waiting."; +"confirm.uninstall.no_wait.title" = "Uninstall And Skip Waiting?"; +"confirm.uninstall.reboot.message" = "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."; +"confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; +"field.bonjour_timeout" = "Bonjour timeout seconds"; +"field.fsck_volume" = "fsck volume, optional"; +"field.helper" = "Helper"; +"field.host" = "Host"; +"field.mount_wait" = "Mount wait seconds"; +"field.password" = "Password"; +"field.repair_xattrs_path" = "Repair xattrs path"; +"panel.connect" = "Discover And Connect"; +"screen.advanced" = "Advanced"; +"screen.connect" = "Connect"; +"screen.deploy" = "Deploy"; +"screen.doctor" = "Doctor"; +"screen.maintenance" = "Maintenance"; +"screen.readiness" = "Readiness"; +"toggle.dry_run" = "Dry Run"; +"toggle.enable_debug_logging" = "Enable Debug Logging"; +"toggle.enable_nbns" = "Enable NBNS"; +"toggle.force_debug_logging" = "Force Debug Logging"; +"toggle.no_reboot" = "No Reboot"; +"toggle.no_wait" = "No Wait"; +"toolbar.cancel" = "Cancel"; +"toolbar.clear" = "Clear"; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index d595d2a0..8f15890c 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -77,6 +77,30 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(recorder.events.last?.ok, true) } + func testRunnerDecodesTruncatedUTF8StderrWithReplacementCharacter() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + printf '\\303\\251' >&2 + """ + ) + let runner = HelperRunner( + locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default), + stderrLimit: 1 + ) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { + recorder.append($0) + } + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr, "\u{FFFD}") + XCTAssertEqual(recorder.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) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index ec07cb43..23b2da49 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -2,6 +2,20 @@ import XCTest @testable import TimeCapsuleSMBApp final class PendingConfirmationTests: XCTestCase { + func testLocalizedStringsLoadFromResourceBundle() { + XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") + XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") + } + + func testUninstallPlanParamsCarryNoRebootSelection() { + let params = OperationParams.uninstallPlan(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)) + } + func testDeployConfirmationCarriesDeployAndRebootConsent() { let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true, mountWait: 45, noWait: true) diff --git a/src/timecapsulesmb/app/helper.py b/src/timecapsulesmb/app/helper.py index f35d0988..15178b9b 100644 --- a/src/timecapsulesmb/app/helper.py +++ b/src/timecapsulesmb/app/helper.py @@ -11,6 +11,9 @@ 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()) @@ -29,7 +32,17 @@ def main(argv: Optional[list[str]] = None) -> int: args = parser.parse_args(argv) sink = _sink_for_stream(sys.stdout).with_request_id(str(uuid.uuid4())) - raw = sys.stdin.read() + 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: diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index c9e75bd0..af004a66 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass, is_dataclass +from enum import Enum import math from pathlib import Path @@ -29,6 +30,8 @@ class OperationResult: 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): diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 1a40d9de..64424caa 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass +from enum import Enum import io import json import sys @@ -27,10 +29,20 @@ from timecapsulesmb.device.storage import MaStVolume from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance from timecapsulesmb.integrations.acp import ACPAuthError +from timecapsulesmb.services.app import jsonable from timecapsulesmb.transport.errors import 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]] = [] @@ -149,6 +161,9 @@ def test_result_event_preserves_falsey_payloads(self) -> None: self.assertEqual(result["schema_version"], 1) self.assertTrue(result["request_id"]) + def test_jsonable_serializes_enum_values_inside_dataclasses(self) -> None: + self.assertEqual(jsonable(SamplePayload(SampleMode.FAST)), {"mode": "fast"}) + def test_stage_events_include_policy_metadata(self) -> None: collector = CollectingSink() @@ -477,6 +492,32 @@ def test_configure_reports_unsupported_device(self) -> None: self.assertFalse(config_path.exists()) self.assertEqual(collector.events_of_type("error")[0]["code"], "unsupported_device") + def test_configure_rejects_boolean_ssh_wait_timeout(self) -> None: + collector = CollectingSink() + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.operations.enable_ssh") as enable_ssh: + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": "root@10.0.0.2", + "password": "pw", + "ssh_wait_timeout": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + enable_ssh.assert_called_once() + self.assertFalse(config_path.exists()) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn("ssh_wait_timeout must be an integer", error["message"]) + def test_doctor_streams_check_events(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) @@ -629,6 +670,27 @@ def test_deploy_requires_deploy_confirmation_even_without_reboot(self) -> None: self.assertEqual(error["code"], "confirmation_required") load_config.assert_not_called() + def test_deploy_rejects_boolean_mount_wait_before_remote_connection(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + rc = service.run_api_request( + { + "operation": "deploy", + "params": { + "dry_run": True, + "mount_wait": True, + }, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + load_config.assert_not_called() + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertIn("mount_wait must be an integer", error["message"]) + def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: collector = CollectingSink() connection = SshConnection("root@10.0.0.2", "pw", "-o foo") @@ -1167,6 +1229,23 @@ def test_helper_rejects_invalid_json_without_leaking_pretty_error_details(self) self.assertEqual(event["code"], "invalid_request") self.assertNotIn("secret", error_output.getvalue()) + def test_helper_rejects_oversized_request_without_leaking_body(self) -> None: + output = io.StringIO() + error_output = io.StringIO() + secret = "secret" + oversized = secret + ("x" * (helper.MAX_REQUEST_CHARS + 1)) + with mock.patch.object(sys, "stdin", io.StringIO(oversized)): + with redirect_stdout(output): + with mock.patch.object(sys, "stderr", error_output): + rc = helper.main(["--pretty-error"]) + + self.assertEqual(rc, 1) + event = json.loads(output.getvalue()) + self.assertEqual(event["type"], "error") + self.assertEqual(event["code"], "invalid_request") + self.assertIn("maximum size", event["message"]) + self.assertNotIn(secret, error_output.getvalue()) + def test_helper_rejects_top_level_non_object_json(self) -> None: output = io.StringIO() with mock.patch.object(sys, "stdin", io.StringIO('["paths"]')): From 3db812a843228d556d78a3e678669e4325582255 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 01:12:59 -0700 Subject: [PATCH 08/20] Validate repair-xattrs API paths and simplify set-ssh action flow --- src/timecapsulesmb/app/ops/maintenance.py | 5 +- src/timecapsulesmb/cli/set_ssh.py | 177 ++++++++++++---------- src/timecapsulesmb/services/app.py | 15 ++ tests/test_app_api.py | 31 ++++ tests/test_cli.py | 42 +++++ 5 files changed, 188 insertions(+), 82 deletions(-) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index a5966a29..5b405526 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -4,7 +4,6 @@ import shlex import sys from contextlib import redirect_stderr, redirect_stdout -from pathlib import Path from timecapsulesmb.app.contracts import ( activation_plan_payload, @@ -57,6 +56,7 @@ int_param, jsonable, optional_int_param, + required_path_param, string_param, ) from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE @@ -310,9 +310,10 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera code="validation_failed", ) sink.stage(operation, "validate_params") + path = required_path_param(params, "path") config = load_optional_env_config(env_path=config_path(params)) args = argparse.Namespace( - path=Path(str(params["path"])) if params.get("path") else None, + path=path, dry_run=dry_run, yes=confirm_repair, recursive=bool_param(params, "recursive", True), diff --git a/src/timecapsulesmb/cli/set_ssh.py b/src/timecapsulesmb/cli/set_ssh.py index a1314885..dd95086a 100644 --- a/src/timecapsulesmb/cli/set_ssh.py +++ b/src/timecapsulesmb/cli/set_ssh.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from enum import Enum from typing import Optional from timecapsulesmb.cli.context import CommandContext @@ -28,6 +29,22 @@ def _looks_like_ssh_auth_failure(output: str) -> bool: return "permission denied" in lowered or "please try again" in lowered +class SetSshAction(Enum): + ENABLE = "enable_ssh" + ENABLE_NOOP = "enable_noop" + DISABLE = "disable_ssh" + DISABLE_NOOP = "disable_noop" + PROMPT_DISABLE = "prompt_disable_ssh" + + +def select_set_ssh_action(*, explicit_enable: bool, explicit_disable: bool, ssh_open: bool) -> SetSshAction: + if explicit_enable: + return SetSshAction.ENABLE_NOOP if ssh_open else SetSshAction.ENABLE + if explicit_disable: + return SetSshAction.DISABLE if ssh_open else SetSshAction.DISABLE_NOOP + return SetSshAction.PROMPT_DISABLE if ssh_open else SetSshAction.ENABLE + + def disable_ssh_over_ssh( connection: SshConnection, *, @@ -109,17 +126,20 @@ def main(argv: Optional[list[str]] = None) -> int: return 0 assert connection is not None - should_enable = args.enable or (not args.disable and not ssh_open) - should_disable = args.disable or (not args.enable and ssh_open) - - if should_enable: - if ssh_open: - command_context.update_fields(set_ssh_action="enable_noop", ssh_final_reachable=True) - print("SSH already enabled.") - command_context.succeed() - return 0 + 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 - command_context.update_fields(set_ssh_action="enable_ssh") + 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") @@ -149,85 +169,82 @@ def main(argv: Optional[list[str]] = None) -> int: command_context.succeed() return 0 - if should_disable: - if not ssh_open: - command_context.update_fields(set_ssh_action="disable_noop", ssh_final_reachable=False) - print("SSH already disabled.") - 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") - if not args.disable and not args.yes: - should_disable = confirm( + if not args.yes: + confirmed = confirm( "SSH already enabled. Disable?", default=False, eof_default=False, interrupt_default=False, ) - if not should_disable: + 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 - - if args.no_wait: - command_context.update_fields(ssh_verification_skipped=True) - print("SSH disable requested; not waiting for reboot or verifying SSH stays closed.") - command_context.succeed() - return 0 - - print("Device is starting reboot now, waiting for it to shut down...") - command_context.set_stage("wait_for_ssh_down") - if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): - message = "SSH did not close after disable/reboot request; disable could not be verified." - command_context.update_fields( - ssh_final_reachable=True, - ssh_disable_persisted=False, - ssh_reboot_observed_down=False, - ) - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - print("Device is down now, verifying persistence after reboot...") - command_context.update_fields(ssh_reboot_observed_down=True) - command_context.set_stage("wait_for_device_up") - if not wait_for_device_up(acp_host): - message = "Device went down after disable request but did not come back within timeout." - command_context.update_fields(device_recovered=False) - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - command_context.update_fields(device_recovered=True) - print("Device successfully rebooted. Checking if SSH is still disabled...") - command_context.set_stage("verify_ssh_disabled") - if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, timeout_seconds=30, service_name="SSH port"): - command_context.update_fields(ssh_final_reachable=True, ssh_disable_persisted=False) - message = "SSH reopened after reboot. Disable did not persist." - print(color_red("Failed to verify SSH disable:")) - print(message) - command_context.fail_with_error(message) - return 1 - 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.fail_with_error("No set-ssh action selected.") - return 1 - return 1 + command_context.update_fields(set_ssh_action=action.value) + try: + command_context.set_stage("disable_ssh") + disable_ssh_over_ssh(connection, reboot_device=True, log=print) + except Exception as e: + error_text = str(e) + message = f"Failed to disable SSH over SSH: {error_text}" + print(color_red("Failed to disable SSH over SSH:")) + print(error_text) + command_context.fail_with_error(message) + return 1 + + if args.no_wait: + command_context.update_fields(ssh_verification_skipped=True) + print("SSH disable requested; not waiting for reboot or verifying SSH stays closed.") + command_context.succeed() + return 0 + + print("Device is starting reboot now, waiting for it to shut down...") + command_context.set_stage("wait_for_ssh_down") + if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, service_name="SSH port"): + message = "SSH did not close after disable/reboot request; disable could not be verified." + command_context.update_fields( + ssh_final_reachable=True, + ssh_disable_persisted=False, + ssh_reboot_observed_down=False, + ) + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + print("Device is down now, verifying persistence after reboot...") + command_context.update_fields(ssh_reboot_observed_down=True) + command_context.set_stage("wait_for_device_up") + if not wait_for_device_up(acp_host): + message = "Device went down after disable request but did not come back within timeout." + command_context.update_fields(device_recovered=False) + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + command_context.update_fields(device_recovered=True) + print("Device successfully rebooted. Checking if SSH is still disabled...") + command_context.set_stage("verify_ssh_disabled") + if not wait_for_tcp_port_state(acp_host, 22, expected_state=False, timeout_seconds=30, service_name="SSH port"): + command_context.update_fields(ssh_final_reachable=True, ssh_disable_persisted=False) + message = "SSH reopened after reboot. Disable did not persist." + print(color_red("Failed to verify SSH disable:")) + print(message) + command_context.fail_with_error(message) + return 1 + command_context.update_fields(ssh_final_reachable=False, ssh_disable_persisted=True) + print("SSH disabled (remains closed after reboot). Enable SSH again if this was not intended.") + command_context.succeed() + return 0 diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index af004a66..64723ebe 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -130,3 +130,18 @@ def require_string_param(params: dict[str, object], name: str) -> str: 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/tests/test_app_api.py b/tests/test_app_api.py index 64424caa..4c08c1ef 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1125,6 +1125,37 @@ def fake_runner(*_args, **_kwargs): self.assertIn({"info": "stdout detail"}, [{log["level"]: log["message"]} for log in logs]) self.assertIn({"warning": "stderr detail"}, [{log["level"]: log["message"]} for log in logs]) + def test_repair_xattrs_rejects_invalid_path_before_runner(self) -> None: + cases = [ + ({}, "missing required parameter: path"), + ({"path": ""}, "missing required parameter: path"), + ({"path": " "}, "missing required parameter: path"), + ({"path": True}, "path must be a path string"), + ] + for extra_params, message in cases: + with self.subTest(extra_params=extra_params): + collector = CollectingSink() + params = {"dry_run": True} + params.update(extra_params) + with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.operations.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": params, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["message"], message) + self.assertEqual(error["recovery"]["title"], "Invalid repair options") + load_config.assert_not_called() + runner.assert_not_called() + def test_repair_xattrs_rejects_invalid_max_depth_before_runner(self) -> None: for max_depth in ("bad", -1, True): with self.subTest(max_depth=max_depth): diff --git a/tests/test_cli.py b/tests/test_cli.py index 99492269..ddef0f30 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4151,6 +4151,30 @@ def test_set_ssh_returns_error_when_env_missing(self) -> None: self.assertIn("stage=load_config", finished["error"]) self.assertNotIn("TC_PASSWORD", finished["error"]) + def test_set_ssh_action_selection_covers_cli_modes(self) -> None: + cases = [ + (False, False, False, set_ssh.SetSshAction.ENABLE), + (False, False, True, set_ssh.SetSshAction.PROMPT_DISABLE), + (True, False, False, set_ssh.SetSshAction.ENABLE), + (True, False, True, set_ssh.SetSshAction.ENABLE_NOOP), + (False, True, False, set_ssh.SetSshAction.DISABLE_NOOP), + (False, True, True, set_ssh.SetSshAction.DISABLE), + ] + for explicit_enable, explicit_disable, ssh_open, expected in cases: + with self.subTest( + explicit_enable=explicit_enable, + explicit_disable=explicit_disable, + ssh_open=ssh_open, + ): + self.assertIs( + set_ssh.select_set_ssh_action( + explicit_enable=explicit_enable, + explicit_disable=explicit_disable, + ssh_open=ssh_open, + ), + expected, + ) + def test_set_ssh_enable_flow_succeeds(self) -> None: output = io.StringIO() values = {"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"} @@ -4287,6 +4311,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"} From 7ff8e66e61dbe070290e5a699e2c7240c30a36c0 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 04:49:34 -0700 Subject: [PATCH 09/20] Localize Swift fallback event messages and summaries --- .../TimeCapsuleSMBApp/HelperRunner.swift | 4 ++-- .../TimeCapsuleSMBApp/Localization.swift | 4 ++++ .../Sources/TimeCapsuleSMBApp/Models.swift | 19 +++++++++++++++---- .../Resources/en.lproj/Localizable.strings | 10 ++++++++++ .../BackendEventTests.swift | 14 ++++++++++++++ .../HelperRunnerTests.swift | 2 ++ .../PendingConfirmationTests.swift | 2 ++ 7 files changed, 49 insertions(+), 6 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index b3112a28..0d108d85 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -93,14 +93,14 @@ public final class HelperRunner { eventSink(BackendEvent.error( operation: operation, code: "cancelled", - message: "Operation cancelled.", + message: L10n.string("helper.error.cancelled"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } else if !sawTerminalEvent { eventSink(BackendEvent.error( operation: operation, code: "missing_terminal_event", - message: "Helper exited without a result or error event.", + message: L10n.string("helper.error.missing_terminal_event"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift index 54586039..7ac25032 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Localization.swift @@ -4,4 +4,8 @@ enum L10n { static func string(_ key: String) -> String { NSLocalizedString(key, bundle: .module, comment: "") } + + static func format(_ key: String, _ arguments: CVarArg...) -> String { + String(format: string(key), locale: Locale.current, arguments: arguments) + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index ec67bea4..e923af39 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -161,13 +161,24 @@ public struct BackendEvent: Decodable, Identifiable { public var summary: String { switch type { case "stage": - return stage.map { "\(operation): \($0)" } ?? operation + return stage.map { L10n.format("event.summary.stage", operation, $0) } ?? operation case "check": - return "\(status ?? "INFO") \(message ?? "")" + return L10n.format( + "event.summary.check", + status ?? L10n.string("event.summary.check.default_status"), + message ?? "" + ) case "result": - return "\(operation): \(ok == true ? "finished" : "failed")" + 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 "\(operation): \(message ?? "error")" + return L10n.format( + "event.summary.error", + operation, + message ?? L10n.string("event.summary.error.default_message") + ) default: return message ?? stage ?? operation } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index f44ed3c3..143d0c85 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -44,6 +44,14 @@ "confirm.uninstall.no_wait.title" = "Uninstall And Skip Waiting?"; "confirm.uninstall.reboot.message" = "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."; "confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; +"event.summary.check" = "%@ %@"; +"event.summary.check.default_status" = "INFO"; +"event.summary.error" = "%@: %@"; +"event.summary.error.default_message" = "error"; +"event.summary.result" = "%@: %@"; +"event.summary.result.failed" = "failed"; +"event.summary.result.finished" = "finished"; +"event.summary.stage" = "%@: %@"; "field.bonjour_timeout" = "Bonjour timeout seconds"; "field.fsck_volume" = "fsck volume, optional"; "field.helper" = "Helper"; @@ -51,6 +59,8 @@ "field.mount_wait" = "Mount wait seconds"; "field.password" = "Password"; "field.repair_xattrs_path" = "Repair xattrs path"; +"helper.error.cancelled" = "Operation cancelled."; +"helper.error.missing_terminal_event" = "Helper exited without a result or error event."; "panel.connect" = "Discover And Connect"; "screen.advanced" = "Advanced"; "screen.connect" = "Connect"; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift index 98696fb7..b421657f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -37,6 +37,20 @@ final class BackendEventTests: XCTestCase { 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 testJSONValueRoundTripsNestedObjects() throws { let value = JSONValue.object([ "operation": .string("paths"), diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index 8f15890c..a9ddcc49 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -47,6 +47,7 @@ final class HelperRunnerTests: XCTestCase { 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")])) } @@ -141,6 +142,7 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(result.exitCode, 130) XCTAssertEqual(recorder.events.last?.type, "error") XCTAssertEqual(recorder.events.last?.code, "cancelled") + XCTAssertEqual(recorder.events.last?.message, L10n.string("helper.error.cancelled")) } private func makeHelper(in directory: URL, body: String) throws -> URL { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index 23b2da49..297b3baf 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -5,6 +5,8 @@ final class PendingConfirmationTests: XCTestCase { func testLocalizedStringsLoadFromResourceBundle() { XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") + XCTAssertEqual(L10n.string("helper.error.cancelled"), "Operation cancelled.") + XCTAssertEqual(L10n.format("event.summary.result", "deploy", "finished"), "deploy: finished") } func testUninstallPlanParamsCarryNoRebootSelection() { From 818e548143d4c6f68732321a178f90f85a2ac955 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 05:04:42 -0700 Subject: [PATCH 10/20] Normalize repair-xattrs summaries and modernize Swift helper output reads --- .../TimeCapsuleSMBApp/HelperRunner.swift | 31 +++++++++++------- .../Sources/TimeCapsuleSMBApp/Models.swift | 23 +++++++++++++ .../BackendEventTests.swift | 32 +++++++++++++++++++ src/timecapsulesmb/app/contracts.py | 15 +++++++-- src/timecapsulesmb/app/events.py | 2 +- src/timecapsulesmb/app/ops/maintenance.py | 2 +- src/timecapsulesmb/cli/repair_xattrs.py | 2 +- tests/test_app_api.py | 31 +++++++++++++++--- tests/test_cli.py | 3 ++ 9 files changed, 120 insertions(+), 21 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 0d108d85..2f6bffdf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -8,6 +8,8 @@ public struct HelperRunResult: Equatable { } public final class HelperRunner { + private static let pipeReadChunkSize = 4096 + private let locator: HelperLocator private let stderrLimit: Int @@ -113,23 +115,15 @@ public final class HelperRunner { } private static func readOutput(_ handle: FileHandle, parser: OutputLineParser) { - while true { - let data = handle.availableData - if data.isEmpty { - parser.finish() - return - } + readChunks(from: handle) { data in parser.append(data) } + parser.finish() } private static func readCapped(_ handle: FileHandle, limit: Int) -> String { var output = Data() - while true { - let data = handle.availableData - if data.isEmpty { - break - } + readChunks(from: handle) { data in if output.count < limit { output.append(data.prefix(limit - output.count)) } @@ -137,6 +131,21 @@ public final class HelperRunner { return String(decoding: output, as: UTF8.self) } + private static func readChunks(from handle: FileHandle, onChunk: (Data) -> Void) { + while true { + let data: Data? + do { + data = try handle.read(upToCount: pipeReadChunkSize) + } catch { + return + } + guard let data, !data.isEmpty else { + return + } + onChunk(data) + } + } + private static func waitForExit(_ process: Process) async { if !process.isRunning { return diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index e923af39..ffbb1353 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -63,6 +63,14 @@ public enum JSONValue: Codable, Hashable { 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 { @@ -169,6 +177,9 @@ public struct BackendEvent: Decodable, Identifiable { 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") @@ -183,4 +194,16 @@ public struct BackendEvent: Decodable, Identifiable { 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/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift index b421657f..ba32dcfd 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendEventTests.swift @@ -51,6 +51,38 @@ final class BackendEventTests: XCTestCase { XCTAssertEqual(error.summary, "deploy: error") } + func testBackendEventResultSummaryPrefersPayloadText() { + let summary = BackendEvent( + type: "result", + operation: "deploy", + ok: true, + payload: .object(["summary": .string("Deployment completed on the Time Capsule.")]) + ) + let message = BackendEvent( + type: "result", + operation: "activate", + ok: true, + payload: .object(["message": .string("Activation completed without reboot.")]) + ) + let legacySummaryText = BackendEvent( + type: "result", + operation: "repair-xattrs", + ok: true, + payload: .object(["summary_text": .string("repair-xattrs found 2 issue(s), 1 repairable.")]) + ) + let blankSummaryFallsBack = BackendEvent( + type: "result", + operation: "doctor", + ok: true, + payload: .object(["summary": .string(" ")]) + ) + + XCTAssertEqual(summary.summary, "Deployment completed on the Time Capsule.") + XCTAssertEqual(message.summary, "Activation completed without reboot.") + XCTAssertEqual(legacySummaryText.summary, "repair-xattrs found 2 issue(s), 1 repairable.") + XCTAssertEqual(blankSummaryFallsBack.summary, "doctor: finished") + } + func testJSONValueRoundTripsNestedObjects() throws { let value = JSONValue.object([ "operation": .string("paths"), diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index f70c319d..382fbb03 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -232,14 +232,23 @@ def fsck_result_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) - return _with_schema({ + legacy_summary = raw.get("summary") + stats = raw.get("stats", legacy_summary if not isinstance(legacy_summary, str) else None) + summary = legacy_summary if isinstance(legacy_summary, str) and legacy_summary.strip() else ( + f"repair-xattrs found {finding_count} issue(s), {repairable_count} repairable." + ) + payload = { **raw, "counts": { "findings": finding_count, "repairable": repairable_count, }, - "summary_text": f"repair-xattrs found {finding_count} issue(s), {repairable_count} repairable.", - }) + "summary": summary, + "summary_text": summary, + } + if stats is not None: + payload["stats"] = jsonable(stats) + return _with_schema(payload) def doctor_payload( diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index e66df37a..1b577bb3 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -9,7 +9,7 @@ from timecapsulesmb.app.stage_policy import stage_policy -SENSITIVE_KEY_PARTS = ("password", "secret", "token") +SENSITIVE_KEY_PARTS = ("password", "secret", "token", "key") REDACTED = "" diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 5b405526..74b9e833 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -345,7 +345,7 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera "root": str(result.root), "finding_count": len(result.findings), "repairable_count": len(result.candidates), - "summary": jsonable(result.summary), + "stats": jsonable(result.summary), "report": result.report, "telemetry_result": context.result, "error": context.error, diff --git a/src/timecapsulesmb/cli/repair_xattrs.py b/src/timecapsulesmb/cli/repair_xattrs.py index 734df12b..403a3418 100644 --- a/src/timecapsulesmb/cli/repair_xattrs.py +++ b/src/timecapsulesmb/cli/repair_xattrs.py @@ -273,7 +273,7 @@ def _repair_result_payload(result: RepairRunResult, context: RepairExecutionCont "root": str(result.root), "finding_count": len(result.findings), "repairable_count": len(result.candidates), - "summary": jsonable(result.summary), + "stats": jsonable(result.summary), "report": result.report, "telemetry_result": context.result, "error": context.error if isinstance(context, RepairExecutionContext) else None, diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 4c08c1ef..4721f083 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -137,12 +137,16 @@ def assert_single_terminal_event(self, collector: CollectingSink, event_type: st self.assertEqual([event["type"] for event in terminals], [event_type]) return terminals[0] - def test_event_redacts_password_fields(self) -> None: + def test_event_redacts_sensitive_fields(self) -> None: event = AppEvent("result", "configure", { "ok": True, "payload": { "password": "secret", - "nested": {"TC_PASSWORD": "secret"}, + "nested": { + "TC_PASSWORD": "secret", + "api_key": "secret", + "ssh_private_key": "secret", + }, }, }) @@ -150,6 +154,8 @@ def test_event_redacts_password_fields(self) -> None: 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() @@ -212,12 +218,24 @@ def test_contract_builders_keep_stable_representative_shapes(self) -> None: repair = contracts.repair_xattrs_payload({ "returncode": 0, "root": "/Volumes/Data", + "finding_count": 2, + "repairable_count": 1, + "stats": {"scanned": 3}, + }) + self.assertEqual(repair["summary"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["stats"], {"scanned": 3}) + + def test_repair_xattrs_payload_preserves_legacy_summary_stats_as_stats(self) -> None: + repair = contracts.repair_xattrs_payload({ "finding_count": 2, "repairable_count": 1, "summary": {"scanned": 3}, }) - self.assertEqual(repair["summary"], {"scanned": 3}) + + self.assertEqual(repair["summary"], "repair-xattrs found 2 issue(s), 1 repairable.") self.assertEqual(repair["summary_text"], "repair-xattrs found 2 issue(s), 1 repairable.") + self.assertEqual(repair["stats"], {"scanned": 3}) def test_request_id_propagates_to_every_event(self) -> None: collector = CollectingSink() @@ -1090,7 +1108,12 @@ def test_repair_xattrs_uses_structured_runner(self) -> None: self.assertEqual(rc, 0) runner.assert_called_once() - self.assertEqual(collector.events_of_type("result")[0]["payload"]["finding_count"], 1) + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["finding_count"], 1) + self.assertEqual(payload["summary"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(payload["summary_text"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(payload["stats"]["scanned"], 1) + self.assertNotIsInstance(payload["summary"], dict) def test_repair_xattrs_captures_direct_stdout_and_stderr_logs(self) -> None: collector = CollectingSink() diff --git a/tests/test_cli.py b/tests/test_cli.py index ddef0f30..2da187cb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1263,6 +1263,9 @@ def test_repair_xattrs_json_emits_ndjson_result(self) -> None: self.assertEqual(events[0]["type"], "stage") self.assertEqual(events[-1]["type"], "result") self.assertEqual(events[-1]["payload"]["finding_count"], 1) + self.assertEqual(events[-1]["payload"]["summary"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(events[-1]["payload"]["summary_text"], "repair-xattrs found 1 issue(s), 1 repairable.") + self.assertEqual(events[-1]["payload"]["stats"]["scanned"], 1) self.assertEqual(events[-1]["payload"]["repairable_count"], 1) def test_repair_xattrs_json_repair_requires_yes(self) -> None: From ff8c8c6663033988a3c4ba1d2f02bba489736579 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 05:33:25 -0700 Subject: [PATCH 11/20] Make GUI helper event delivery async and ordered --- .../TimeCapsuleSMBApp/BackendClient.swift | 59 +++++++-- .../TimeCapsuleSMBApp/HelperRunner.swift | 91 ++++++++------ .../Sources/TimeCapsuleSMBApp/Models.swift | 4 +- .../TimeCapsuleSMBApp/OutputLineParser.swift | 38 +++--- .../BackendClientTests.swift | 118 ++++++++++++++++++ .../HelperRunnerTests.swift | 67 ++++++---- .../OutputLineParserTests.swift | 14 +-- 7 files changed, 288 insertions(+), 103 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index 27a0f10e..c6d106d1 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -7,12 +7,15 @@ final class BackendClient: ObservableObject { @Published var isRunning = false @Published var lastExitCode: Int32? - private let runner: HelperRunner + private let runner: any HelperRunning private var runTask: Task? - init(runner: HelperRunner = HelperRunner()) { + init( + runner: any HelperRunning = HelperRunner(), + helperPath: String = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" + ) { self.runner = runner - helperPath = ProcessInfo.processInfo.environment["TCAPSULE_HELPER"] ?? "" + self.helperPath = helperPath } func clear() { @@ -25,23 +28,59 @@ final class BackendClient: ObservableObject { isRunning = true lastExitCode = nil let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) - runTask = Task { + 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, params] in let result = await runner.run( helperPath: helperPath.isEmpty ? nil : helperPath, operation: operation, params: params ) { event in - Task { @MainActor in - self.events.append(event) - } + await updateTarget.appendEvent(event) } - self.lastExitCode = result.exitCode - self.isRunning = false - self.runTask = nil + await updateTarget.finishRun(exitCode: result.exitCode) } } func cancel() { runTask?.cancel() } + + fileprivate func appendEvent(_ event: BackendEvent) { + events.append(event) + } + + fileprivate func finishRun(exitCode: Int32) { + lastExitCode = exitCode + isRunning = false + runTask = nil + } +} + +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/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 2f6bffdf..0740dc9a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -1,13 +1,22 @@ import Darwin import Foundation -public struct HelperRunResult: Equatable { +public struct HelperRunResult: Equatable, Sendable { public let exitCode: Int32 public let sawTerminalEvent: Bool public let stderr: String } -public final class HelperRunner { +public protocol HelperRunning: Sendable { + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult +} + +public final class HelperRunner: @unchecked Sendable, HelperRunning { private static let pipeReadChunkSize = 4096 private let locator: HelperLocator @@ -22,19 +31,19 @@ public final class HelperRunner { helperPath: String?, operation: String, params: [String: JSONValue], - onEvent: @escaping (BackendEvent) -> Void + onEvent: @escaping @Sendable (BackendEvent) async -> Void ) async -> HelperRunResult { let terminalTracker = TerminalEventTracker() - let eventSink: (BackendEvent) -> Void = { event in - terminalTracker.record(event) - onEvent(event) + 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 { - eventSink(BackendEvent.error(operation: operation, code: "helper_not_found", message: error.localizedDescription)) + await eventSink(BackendEvent.error(operation: operation, code: "helper_not_found", message: error.localizedDescription)) return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") } @@ -50,19 +59,19 @@ public final class HelperRunner { process.standardOutput = output process.standardError = error - let parser = OutputLineParser(onEvent: eventSink) do { try process.run() } catch { - eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) + await eventSink(BackendEvent.error(operation: operation, code: "helper_launch_failed", message: error.localizedDescription)) return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") } let stdoutTask = Task.detached { - Self.readOutput(output.fileHandleForReading, parser: parser) + await Self.readOutput(output.fileHandleForReading, onEvent: eventSink) } + let stderrLimit = self.stderrLimit let stderrTask = Task.detached { - Self.readCapped(error.fileHandleForReading, limit: self.stderrLimit) + Self.readCapped(error.fileHandleForReading, limit: stderrLimit) } do { @@ -73,7 +82,7 @@ public final class HelperRunner { } catch { try? input.fileHandleForWriting.close() await Self.terminate(process) - eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) + await eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) await stdoutTask.value let stderr = await stderrTask.value return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) @@ -90,40 +99,49 @@ public final class HelperRunner { await stdoutTask.value let stderrText = await stderrTask.value - let sawTerminalEvent = terminalTracker.sawTerminalEvent + let sawTerminalEvent = await terminalTracker.sawTerminalEvent if cancelled { - eventSink(BackendEvent.error( + await eventSink(BackendEvent.error( operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } else if !sawTerminalEvent { - eventSink(BackendEvent.error( + await eventSink(BackendEvent.error( operation: operation, code: "missing_terminal_event", message: L10n.string("helper.error.missing_terminal_event"), debug: stderrText.isEmpty ? nil : .object(["stderr": .string(stderrText)]) )) } + let finalSawTerminalEvent = await terminalTracker.sawTerminalEvent return HelperRunResult( exitCode: cancelled ? 130 : process.terminationStatus, - sawTerminalEvent: terminalTracker.sawTerminalEvent, + sawTerminalEvent: finalSawTerminalEvent, stderr: stderrText ) } - private static func readOutput(_ handle: FileHandle, parser: OutputLineParser) { - readChunks(from: handle) { data in - parser.append(data) + private static func readOutput( + _ handle: FileHandle, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async { + var parser = OutputLineParser() + while let data = readChunk(from: handle) { + for event in parser.append(data) { + await onEvent(event) + } + } + for event in parser.finish() { + await onEvent(event) } - parser.finish() } private static func readCapped(_ handle: FileHandle, limit: Int) -> String { var output = Data() - readChunks(from: handle) { data in + while let data = readChunk(from: handle) { if output.count < limit { output.append(data.prefix(limit - output.count)) } @@ -131,19 +149,17 @@ public final class HelperRunner { return String(decoding: output, as: UTF8.self) } - private static func readChunks(from handle: FileHandle, onChunk: (Data) -> Void) { - while true { - let data: Data? - do { - data = try handle.read(upToCount: pipeReadChunkSize) - } catch { - return - } - guard let data, !data.isEmpty else { - return - } - onChunk(data) + private static func readChunk(from handle: FileHandle) -> Data? { + let data: Data? + do { + data = try handle.read(upToCount: pipeReadChunkSize) + } catch { + return nil + } + guard let data, !data.isEmpty else { + return nil } + return data } private static func waitForExit(_ process: Process) async { @@ -193,20 +209,15 @@ private final class TerminationContinuation: @unchecked Sendable { } } -private final class TerminalEventTracker: @unchecked Sendable { - private let lock = NSLock() +private actor TerminalEventTracker { private var seen = false var sawTerminalEvent: Bool { - lock.lock() - defer { lock.unlock() } - return seen + seen } func record(_ event: BackendEvent) { guard event.type == "result" || event.type == "error" else { return } - lock.lock() seen = true - lock.unlock() } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift index ffbb1353..6037582f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Models.swift @@ -1,6 +1,6 @@ import Foundation -public enum JSONValue: Codable, Hashable { +public enum JSONValue: Codable, Hashable, Sendable { case string(String) case number(Double) case bool(Bool) @@ -73,7 +73,7 @@ public enum JSONValue: Codable, Hashable { } } -public struct BackendEvent: Decodable, Identifiable { +public struct BackendEvent: Decodable, Identifiable, Sendable { public let id = UUID() public let schemaVersion: Int? public let requestId: String? diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift index 4e702be2..50761c33 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OutputLineParser.swift @@ -1,42 +1,38 @@ import Foundation -public final class OutputLineParser: @unchecked Sendable { - private let lock = NSLock() +public struct OutputLineParser { private var buffer = Data() private let decoder = JSONDecoder() - private let onEvent: (BackendEvent) -> Void - public init(onEvent: @escaping (BackendEvent) -> Void) { - self.onEvent = onEvent + public init() { } - public func append(_ data: Data) { - lock.lock() - defer { lock.unlock() } + public mutating func append(_ data: Data) -> [BackendEvent] { buffer.append(data) - consumeCompleteLines() + return consumeCompleteLines() } - public func finish() { - lock.lock() - defer { lock.unlock() } - guard !buffer.isEmpty else { return } - emit(buffer) + public mutating func finish() -> [BackendEvent] { + guard !buffer.isEmpty else { return [] } + let event = decode(buffer) buffer.removeAll() + return event.map { [$0] } ?? [] } - private func consumeCompleteLines() { + private mutating func consumeCompleteLines() -> [BackendEvent] { + var events: [BackendEvent] = [] while let newline = buffer.firstIndex(of: 0x0A) { let line = buffer.prefix(upTo: newline) buffer.removeSubrange(...newline) - emit(line) + if let event = decode(line) { + events.append(event) + } } + return events } - private func emit(_ line: Data.SubSequence) { - guard !line.isEmpty, let event = try? decoder.decode(BackendEvent.self, from: Data(line)) else { - return - } - onEvent(event) + 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/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift new file mode 100644 index 00000000..48088b7e --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class BackendClientTests: XCTestCase { + func testRunPublishesEventsAndResetsState() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "stage", operation: "paths", stage: "start"), + BackendEvent(type: "result", operation: "paths", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 50_000_000 + ) + let client = BackendClient(runner: runner, helperPath: " /tmp/tcapsule ") + + client.run(operation: "paths", params: ["dry_run": .bool(true)]) + + XCTAssertTrue(client.isRunning) + try await waitUntil { + !client.isRunning && client.events.count == 2 + } + XCTAssertEqual(client.lastExitCode, 0) + XCTAssertEqual(client.events.map(\.type), ["stage", "result"]) + XCTAssertEqual( + runner.calls, + [RecordingHelperRunner.Call( + helperPath: "/tmp/tcapsule", + operation: "paths", + params: ["dry_run": .bool(true)] + )] + ) + } + + 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") + } + + 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 final class RecordingHelperRunner: HelperRunning, @unchecked Sendable { + struct Call: Equatable, Sendable { + let helperPath: String? + let operation: String + let params: [String: JSONValue] + } + + 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], + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params)) + } + + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + if Task.isCancelled { + await onEvent(BackendEvent.error(operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"))) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } + for event in events { + await onEvent(event) + } + return result + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index a9ddcc49..f12fd120 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -17,15 +17,37 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { - recorder.append($0) + await recorder.append($0) } - let events = recorder.events + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(events.map(\.type), ["stage", "result"]) XCTAssertEqual(events.last?.ok, true) } + func testRunnerWaitsForEventDeliveryBeforeReturning() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + cat >/dev/null + echo '{"schema_version":1,"request_id":"req","type":"result","operation":"paths","ok":true,"payload":{"ok":true}}' + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + + let result = await runner.run(helperPath: helper.path, operation: "paths", params: [:]) { event in + try? await Task.sleep(nanoseconds: 50_000_000) + await recorder.append(event) + } + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(events.map(\.type), ["result"]) + } + func testRunnerSynthesizesErrorWhenHelperHasNoTerminalEvent() async throws { let temp = try TemporaryDirectory() let helper = try makeHelper( @@ -40,10 +62,10 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + await recorder.append($0) } - let events = recorder.events + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(events.last?.type, "error") XCTAssertEqual(events.last?.code, "missing_terminal_event") @@ -69,13 +91,14 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + await recorder.append($0) } + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(result.stderr.count, 64 * 1024) - XCTAssertEqual(recorder.events.last?.type, "result") - XCTAssertEqual(recorder.events.last?.ok, true) + XCTAssertEqual(events.last?.type, "result") + XCTAssertEqual(events.last?.ok, true) } func testRunnerDecodesTruncatedUTF8StderrWithReplacementCharacter() async throws { @@ -94,12 +117,13 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + await recorder.append($0) } + let events = await recorder.events XCTAssertEqual(result.exitCode, 0) XCTAssertEqual(result.stderr, "\u{FFFD}") - XCTAssertEqual(recorder.events.last?.code, "missing_terminal_event") + XCTAssertEqual(events.last?.code, "missing_terminal_event") } func testRunnerReportsMissingHelper() async { @@ -108,12 +132,13 @@ final class HelperRunnerTests: XCTestCase { let recorder = EventRecorder() let result = await runner.run(helperPath: "/missing/tcapsule", operation: "paths", params: [:]) { - recorder.append($0) + await recorder.append($0) } + let events = await recorder.events XCTAssertEqual(result.exitCode, 1) - XCTAssertEqual(recorder.events.last?.type, "error") - XCTAssertEqual(recorder.events.last?.code, "helper_not_found") + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "helper_not_found") } func testRunnerCancelsLongRunningHelper() async throws { @@ -132,17 +157,18 @@ final class HelperRunnerTests: XCTestCase { let task = Task { await runner.run(helperPath: helper.path, operation: "doctor", params: [:]) { - recorder.append($0) + 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(recorder.events.last?.type, "error") - XCTAssertEqual(recorder.events.last?.code, "cancelled") - XCTAssertEqual(recorder.events.last?.message, L10n.string("helper.error.cancelled")) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "cancelled") + XCTAssertEqual(events.last?.message, L10n.string("helper.error.cancelled")) } private func makeHelper(in directory: URL, body: String) throws -> URL { @@ -153,19 +179,14 @@ final class HelperRunnerTests: XCTestCase { } } -private final class EventRecorder: @unchecked Sendable { - private let lock = NSLock() +private actor EventRecorder { private var storage: [BackendEvent] = [] var events: [BackendEvent] { - lock.lock() - defer { lock.unlock() } - return storage + storage } func append(_ event: BackendEvent) { - lock.lock() storage.append(event) - lock.unlock() } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift index 93c87319..0c57055a 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OutputLineParserTests.swift @@ -4,14 +4,14 @@ import XCTest final class OutputLineParserTests: XCTestCase { func testParserHandlesSplitMultipleAndUnterminatedLines() { - var events: [BackendEvent] = [] - let parser = OutputLineParser { events.append($0) } + var parser = OutputLineParser() - parser.append(Data(#"{"type":"stage","operation":"paths","stage":"resolve"#.utf8)) - parser.append(Data(#"_paths"}"#.utf8)) - parser.append(Data("\nnot-json\n".utf8)) - parser.append(Data(#"{"type":"result","operation":"paths","ok":true,"payload":{}}"#.utf8)) - parser.finish() + var events: [BackendEvent] = [] + events.append(contentsOf: parser.append(Data(#"{"type":"stage","operation":"paths","stage":"resolve"#.utf8))) + events.append(contentsOf: parser.append(Data(#"_paths"}"#.utf8))) + events.append(contentsOf: parser.append(Data("\nnot-json\n".utf8))) + events.append(contentsOf: parser.append(Data(#"{"type":"result","operation":"paths","ok":true,"payload":{}}"#.utf8))) + events.append(contentsOf: parser.finish()) XCTAssertEqual(events.map(\.type), ["stage", "result"]) XCTAssertEqual(events.first?.stage, "resolve_paths") From b381409834143309c897a09f236c5d6e6c39274f Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 14:18:23 -0700 Subject: [PATCH 12/20] Implement service-layer GUI backend contracts with backend-driven confirmations, credential separation, cancellability state, and Swift confirmation handling --- .../TimeCapsuleSMBApp/BackendClient.swift | 42 ++ .../TimeCapsuleSMBApp/ContentView.swift | 123 +++-- .../TimeCapsuleSMBApp/OperationParams.swift | 75 ++-- .../PendingConfirmation.swift | 97 ++-- .../Resources/en.lproj/Localizable.strings | 4 + .../BackendClientTests.swift | 55 +++ .../PendingConfirmationTests.swift | 85 ++-- src/timecapsulesmb/app/confirmations.py | 133 ++++++ src/timecapsulesmb/app/contracts.py | 20 + src/timecapsulesmb/app/events.py | 3 + src/timecapsulesmb/app/operations.py | 178 -------- src/timecapsulesmb/app/ops/__init__.py | 8 +- src/timecapsulesmb/app/ops/configure.py | 11 +- src/timecapsulesmb/app/ops/deploy.py | 85 +++- src/timecapsulesmb/app/ops/doctor.py | 7 +- src/timecapsulesmb/app/ops/maintenance.py | 115 +++-- src/timecapsulesmb/app/ops/readiness.py | 37 +- src/timecapsulesmb/app/requests.py | 32 ++ src/timecapsulesmb/app/service.py | 51 +-- src/timecapsulesmb/app/stage_policy.py | 2 + src/timecapsulesmb/cli/fsck.py | 28 +- src/timecapsulesmb/cli/runtime.py | 73 +-- src/timecapsulesmb/services/app.py | 8 +- src/timecapsulesmb/services/config_store.py | 31 ++ src/timecapsulesmb/services/credentials.py | 31 ++ src/timecapsulesmb/services/doctor.py | 211 +++++++++ src/timecapsulesmb/services/maintenance.py | 23 + src/timecapsulesmb/services/repair_xattrs.py | 219 +++++++++ src/timecapsulesmb/services/runtime.py | 171 +++++++ tests/test_app_api.py | 421 +++++++++++------- 30 files changed, 1686 insertions(+), 693 deletions(-) create mode 100644 src/timecapsulesmb/app/confirmations.py delete mode 100644 src/timecapsulesmb/app/operations.py create mode 100644 src/timecapsulesmb/app/requests.py create mode 100644 src/timecapsulesmb/services/config_store.py create mode 100644 src/timecapsulesmb/services/credentials.py create mode 100644 src/timecapsulesmb/services/repair_xattrs.py create mode 100644 src/timecapsulesmb/services/runtime.py diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index c6d106d1..b11d6ed3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -6,9 +6,14 @@ final class BackendClient: ObservableObject { @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? private let runner: any HelperRunning private var runTask: Task? + private var activeCall: BackendCall? init( runner: any HelperRunning = HelperRunner(), @@ -21,12 +26,25 @@ final class BackendClient: ObservableObject { func clear() { events.removeAll() lastExitCode = nil + pendingConfirmation = nil + currentStage = nil + currentRisk = nil + currentCancellable = nil + } + + var canCancel: Bool { + isRunning && (currentCancellable ?? true) } func run(operation: String, params: [String: JSONValue] = [:]) { guard !isRunning else { return } isRunning = true lastExitCode = nil + pendingConfirmation = nil + currentStage = nil + currentRisk = nil + currentCancellable = nil + activeCall = BackendCall(operation: operation, params: params) let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) let runner = self.runner let updateTarget = BackendClientUpdateTarget( @@ -50,10 +68,28 @@ final class BackendClient: ObservableObject { } 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) + } + 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 + ) { + pendingConfirmation = confirmation + } events.append(event) } @@ -61,9 +97,15 @@ final class BackendClient: ObservableObject { lastExitCode = exitCode isRunning = false runTask = nil + activeCall = nil } } +private struct BackendCall: Sendable { + let operation: String + let params: [String: JSONValue] +} + private final class BackendClientUpdateTarget: Sendable { private let appendEventOnMain: @MainActor @Sendable (BackendEvent) -> Void private let finishRunOnMain: @MainActor @Sendable (Int32) -> Void diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 46de61c5..060871cd 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -15,7 +15,6 @@ public struct ContentView: View { @State private var mountWait = "30" @State private var bonjourTimeout = "6" @State private var noWait = false - @State private var pendingConfirmation: PendingConfirmation? public init() {} @@ -45,22 +44,21 @@ public struct ContentView: View { } label: { Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") } - .disabled(!backend.isRunning) + .disabled(!backend.canCancel) } } } .frame(minWidth: 980, minHeight: 680) .alert( - pendingConfirmation?.title ?? "", + backend.pendingConfirmation?.title ?? "", isPresented: confirmationPresented, - presenting: pendingConfirmation + presenting: backend.pendingConfirmation ) { confirmation in Button(confirmation.actionTitle, role: .destructive) { - backend.run(operation: confirmation.operation, params: confirmation.params) - pendingConfirmation = nil + backend.confirmPending() } Button(L10n.string("action.cancel"), role: .cancel) { - pendingConfirmation = nil + backend.pendingConfirmation = nil } } message: { confirmation in Text(confirmation.message) @@ -69,10 +67,10 @@ public struct ContentView: View { private var confirmationPresented: Binding { Binding( - get: { pendingConfirmation != nil }, + get: { backend.pendingConfirmation != nil }, set: { isPresented in if !isPresented { - pendingConfirmation = nil + backend.pendingConfirmation = nil } } ) @@ -85,6 +83,7 @@ public struct ContentView: View { CommandPanel(title: L10n.string("screen.readiness")) { TextField(L10n.string("field.helper"), text: $backend.helperPath) HStack { + runButton(L10n.string("button.capabilities"), icon: "info.circle", operation: "capabilities") runButton(L10n.string("button.paths"), icon: "folder", operation: "paths") runButton(L10n.string("button.validate"), icon: "checkmark.seal", operation: "validate-install") } @@ -100,7 +99,8 @@ public struct ContentView: View { L10n.string("button.discover"), icon: "network", operation: "discover", - params: OperationParams.discover(timeout: numberDouble(bonjourTimeout, default: 6)) + params: OperationParams.discover(timeout: bonjourTimeoutValue ?? 6), + disabled: bonjourTimeoutValue == nil ) Button { backend.run( @@ -134,16 +134,21 @@ public struct ContentView: View { noWait: noWait, nbnsEnabled: nbnsEnabled, debugLogging: deployDebugLogging, - mountWait: numberDouble(mountWait, default: 30) + mountWait: mountWaitValue ?? 30, + password: password ) ) } else { - pendingConfirmation = .deploy( - noReboot: noReboot, - nbnsEnabled: nbnsEnabled, - debugLogging: deployDebugLogging, - mountWait: numberDouble(mountWait, default: 30), - noWait: noWait + backend.run( + operation: "deploy", + params: OperationParams.deployRun( + noReboot: noReboot, + noWait: noWait, + nbnsEnabled: nbnsEnabled, + debugLogging: deployDebugLogging, + mountWait: mountWaitValue ?? 30, + password: password + ) ) } } label: { @@ -152,7 +157,7 @@ public struct ContentView: View { systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up" ) } - .disabled(backend.isRunning) + .disabled(backend.isRunning || mountWaitValue == nil) } case .doctor: CommandPanel(title: L10n.string("screen.doctor")) { @@ -161,7 +166,8 @@ public struct ContentView: View { L10n.string("button.run_doctor"), icon: "stethoscope", operation: "doctor", - params: OperationParams.doctor(bonjourTimeout: numberDouble(bonjourTimeout, default: 6)) + params: OperationParams.doctor(bonjourTimeout: bonjourTimeoutValue ?? 6, password: password), + disabled: bonjourTimeoutValue == nil ) } case .maintenance: @@ -173,7 +179,7 @@ public struct ContentView: View { Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) HStack { Button { - pendingConfirmation = .activate() + backend.run(operation: "activate", params: OperationParams.activateRun(password: password)) } label: { Label(L10n.string("button.activate"), systemImage: "power") } @@ -185,26 +191,33 @@ public struct ContentView: View { params: OperationParams.uninstallPlan( noReboot: noReboot, noWait: noWait, - mountWait: numberDouble(mountWait, default: 30) - ) + mountWait: mountWaitValue ?? 30, + password: password + ), + disabled: mountWaitValue == nil ) Button { - pendingConfirmation = .uninstall( - noReboot: noReboot, - mountWait: numberDouble(mountWait, default: 30), - noWait: noWait + backend.run( + operation: "uninstall", + params: OperationParams.uninstallRun( + noReboot: noReboot, + noWait: noWait, + mountWait: mountWaitValue ?? 30, + password: password + ) ) } label: { Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") } - .disabled(backend.isRunning) + .disabled(backend.isRunning || mountWaitValue == nil) } HStack { runButton( L10n.string("button.list_fsck_volumes"), icon: "list.bullet.rectangle", operation: "fsck", - params: OperationParams.fsckList(mountWait: numberDouble(mountWait, default: 30)) + params: OperationParams.fsckList(mountWait: mountWaitValue ?? 30, password: password), + disabled: mountWaitValue == nil ) runButton( L10n.string("button.plan_fsck"), @@ -214,20 +227,26 @@ public struct ContentView: View { volume: volume, noReboot: noReboot, noWait: noWait, - mountWait: numberDouble(mountWait, default: 30) - ) + mountWait: mountWaitValue ?? 30, + password: password + ), + disabled: mountWaitValue == nil ) Button { - pendingConfirmation = .fsck( - volume: volume, - noReboot: noReboot, - mountWait: numberDouble(mountWait, default: 30), - noWait: noWait + backend.run( + operation: "fsck", + params: OperationParams.fsckRun( + volume: volume, + noReboot: noReboot, + noWait: noWait, + mountWait: mountWaitValue ?? 30, + password: password + ) ) } label: { Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") } - .disabled(backend.isRunning) + .disabled(backend.isRunning || mountWaitValue == nil) } HStack { Button { @@ -240,7 +259,10 @@ public struct ContentView: View { } .disabled(backend.isRunning || repairPath.isEmpty) Button { - pendingConfirmation = .repairXattrs(path: repairPath) + backend.run( + operation: "repair-xattrs", + params: OperationParams.repairXattrsRun(path: repairPath) + ) } label: { Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") } @@ -261,19 +283,38 @@ public struct ContentView: View { _ title: String, icon: String, operation: String, - params: [String: JSONValue] = [:] + params: [String: JSONValue] = [:], + disabled: Bool = false ) -> some View { Button { backend.run(operation: operation, params: params) } label: { Label(title, systemImage: icon) } - .disabled(backend.isRunning) + .disabled(backend.isRunning || disabled) } - private func numberDouble(_ text: String, default defaultValue: Double) -> Double { + private var mountWaitValue: Double? { + nonNegativeIntegerDouble(mountWait) + } + + private var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + private func nonNegativeDouble(_ text: String) -> Double? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return Double(trimmed) ?? defaultValue + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } + + private func nonNegativeIntegerDouble(_ text: String) -> Double? { + guard let value = nonNegativeDouble(text), value.rounded(.towardZero) == value else { + return nil + } + return value } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift index d4f487fa..75023d92 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -1,6 +1,16 @@ import Foundation enum OperationParams { + private static func withCredentials(_ params: [String: JSONValue], password: String) -> [String: JSONValue] { + let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return params + } + var updated = params + updated["credentials"] = .object(["password": .string(password)]) + return updated + } + static func discover(timeout: Double) -> [String: JSONValue] { ["timeout": .number(timeout)] } @@ -16,8 +26,8 @@ enum OperationParams { return params } - static func doctor(bonjourTimeout: Double) -> [String: JSONValue] { - ["bonjour_timeout": .number(bonjourTimeout)] + static func doctor(bonjourTimeout: Double, password: String) -> [String: JSONValue] { + withCredentials(["bonjour_timeout": .number(bonjourTimeout)], password: password) } static func deployPlan( @@ -25,87 +35,83 @@ enum OperationParams { noWait: Bool, nbnsEnabled: Bool, debugLogging: Bool, - mountWait: Double + mountWait: Double, + password: String ) -> [String: JSONValue] { - [ + withCredentials([ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func deployConfirmed( + static func deployRun( noReboot: Bool, noWait: Bool, nbnsEnabled: Bool, debugLogging: Bool, - mountWait: Double + mountWait: Double, + password: String ) -> [String: JSONValue] { - [ + withCredentials([ "dry_run": .bool(false), - "confirm_deploy": .bool(true), - "confirm_reboot": .bool(!noReboot), - "confirm_netbsd4_activation": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "nbns_enabled": .bool(nbnsEnabled), "debug_logging": .bool(debugLogging), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ + static func uninstallPlan(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func uninstallConfirmed(noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ + static func uninstallRun(noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "dry_run": .bool(false), - "confirm_uninstall": .bool(true), - "confirm_reboot": .bool(!noReboot), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func activateConfirmed() -> [String: JSONValue] { - ["confirm_netbsd4_activation": .bool(true)] + static func activateRun(password: String) -> [String: JSONValue] { + withCredentials([:], password: password) } - static func fsckList(mountWait: Double) -> [String: JSONValue] { - [ + static func fsckList(mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "list_volumes": .bool(true), "mount_wait": .number(mountWait) - ] + ], password: password) } - static func fsckPlan(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ + static func fsckPlan(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "dry_run": .bool(true), "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait), "volume": .string(volume) - ] + ], password: password) } - static func fsckConfirmed(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double) -> [String: JSONValue] { - [ - "confirm_fsck": .bool(true), + static func fsckRun(volume: String, noReboot: Bool, noWait: Bool, mountWait: Double, password: String) -> [String: JSONValue] { + withCredentials([ "no_reboot": .bool(noReboot), "no_wait": .bool(noWait), "mount_wait": .number(mountWait), "volume": .string(volume) - ] + ], password: password) } static func repairXattrsScan(path: String) -> [String: JSONValue] { @@ -115,11 +121,10 @@ enum OperationParams { ] } - static func repairXattrsConfirmed(path: String) -> [String: JSONValue] { + static func repairXattrsRun(path: String) -> [String: JSONValue] { [ "path": .string(path), - "dry_run": .bool(false), - "confirm_repair": .bool(true) + "dry_run": .bool(false) ] } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 41f13f12..497530f2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -8,80 +8,33 @@ struct PendingConfirmation: Identifiable { let operation: String let params: [String: JSONValue] - static func deploy(noReboot: Bool, nbnsEnabled: Bool, debugLogging: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { - PendingConfirmation( - title: noReboot ? L10n.string("confirm.deploy.no_reboot.title") : (noWait ? L10n.string("confirm.deploy.no_wait.title") : L10n.string("confirm.deploy.reboot.title")), - message: noReboot - ? L10n.string("confirm.deploy.no_reboot.message") - : (noWait - ? L10n.string("confirm.deploy.no_wait.message") - : L10n.string("confirm.deploy.reboot.message")), - actionTitle: noReboot ? L10n.string("action.deploy") : L10n.string("action.deploy_allow_reboot"), - operation: "deploy", - params: OperationParams.deployConfirmed( - noReboot: noReboot, - noWait: noWait, - nbnsEnabled: nbnsEnabled, - debugLogging: debugLogging, - mountWait: mountWait - ) - ) - } - - static func activate() -> PendingConfirmation { - PendingConfirmation( - title: L10n.string("confirm.activate.title"), - message: L10n.string("confirm.activate.message"), - actionTitle: L10n.string("action.activate"), - operation: "activate", - params: OperationParams.activateConfirmed() - ) - } - - static func fsck(volume: String, noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { - PendingConfirmation( - title: noReboot ? L10n.string("confirm.fsck.no_reboot.title") : (noWait ? L10n.string("confirm.fsck.no_wait.title") : L10n.string("confirm.fsck.reboot.title")), - message: noReboot - ? L10n.string("confirm.fsck.no_reboot.message") - : (noWait - ? L10n.string("confirm.fsck.no_wait.message") - : L10n.string("confirm.fsck.reboot.message")), - actionTitle: L10n.string("action.run_fsck"), - operation: "fsck", - params: OperationParams.fsckConfirmed( - volume: volume, - noReboot: noReboot, - noWait: noWait, - mountWait: mountWait - ) - ) - } + init?( + confirmationEvent event: BackendEvent, + originalParams: [String: JSONValue] + ) { + guard + event.type == "error", + event.code == "confirmation_required", + case .object(let details)? = event.details, + case .string(let confirmationId)? = details["confirmation_id"] + else { + return nil + } - static func uninstall(noReboot: Bool, mountWait: Double, noWait: Bool) -> PendingConfirmation { - PendingConfirmation( - title: noReboot ? L10n.string("confirm.uninstall.no_reboot.title") : (noWait ? L10n.string("confirm.uninstall.no_wait.title") : L10n.string("confirm.uninstall.reboot.title")), - message: noReboot - ? L10n.string("confirm.uninstall.no_reboot.message") - : (noWait - ? L10n.string("confirm.uninstall.no_wait.message") - : L10n.string("confirm.uninstall.reboot.message")), - actionTitle: L10n.string("action.uninstall"), - operation: "uninstall", - params: OperationParams.uninstallConfirmed( - noReboot: noReboot, - noWait: noWait, - mountWait: mountWait - ) - ) + self.title = Self.detailString(details, "title") ?? L10n.string("confirm.backend.title") + self.message = Self.detailString(details, "message") ?? event.message ?? L10n.string("confirm.backend.message") + self.actionTitle = Self.detailString(details, "action_title") ?? L10n.string("action.confirm") + self.operation = event.operation + var confirmedParams = originalParams + confirmedParams["confirmation_id"] = .string(confirmationId) + self.params = confirmedParams } - static func repairXattrs(path: String) -> PendingConfirmation { - PendingConfirmation( - title: L10n.string("confirm.repair_xattrs.title"), - message: L10n.string("confirm.repair_xattrs.message"), - actionTitle: L10n.string("action.repair_xattrs"), - operation: "repair-xattrs", - params: OperationParams.repairXattrsConfirmed(path: path) - ) + private static func detailString(_ details: [String: JSONValue], _ key: String) -> String? { + guard case .string(let value)? = details[key] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : value } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 143d0c85..b1fcd4f0 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -1,5 +1,6 @@ "action.activate" = "Activate"; "action.cancel" = "Cancel"; +"action.confirm" = "Confirm"; "action.deploy" = "Deploy"; "action.deploy_allow_reboot" = "Deploy And Allow Reboot"; "action.repair_xattrs" = "Repair xattrs"; @@ -8,6 +9,7 @@ "advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; "advanced.flash_help" = "Use `.venv/bin/tcapsule flash --help` for firmware operations."; "button.activate" = "Activate"; +"button.capabilities" = "Capabilities"; "button.configure" = "Configure"; "button.deploy" = "Deploy"; "button.discover" = "Discover"; @@ -24,6 +26,8 @@ "button.validate" = "Validate"; "confirm.activate.message" = "This will restart the deployed Samba runtime on an older NetBSD 4 device."; "confirm.activate.title" = "Activate NetBSD 4 Runtime?"; +"confirm.backend.message" = "Confirm this operation."; +"confirm.backend.title" = "Confirm Operation"; "confirm.deploy.no_reboot.message" = "This will upload and install the managed TimeCapsuleSMB payload without rebooting the device."; "confirm.deploy.no_reboot.title" = "Deploy Without Reboot?"; "confirm.deploy.no_wait.message" = "This will upload and install the managed TimeCapsuleSMB payload, request a reboot, and return without waiting for the device."; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift index 48088b7e..789e93a4 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -55,6 +55,61 @@ final class BackendClientTests: XCTestCase { XCTAssertEqual(client.events.last?.type, "error") } + 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)) + } + private func waitUntil( timeoutNanoseconds: UInt64 = 2_000_000_000, _ condition: @escaping @MainActor () -> Bool diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index 297b3baf..cae0980e 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -5,60 +5,83 @@ final class PendingConfirmationTests: XCTestCase { func testLocalizedStringsLoadFromResourceBundle() { XCTAssertEqual(L10n.string("screen.readiness"), "Readiness") XCTAssertEqual(L10n.string("button.uninstall_plan"), "Uninstall Plan") + XCTAssertEqual(L10n.string("button.capabilities"), "Capabilities") XCTAssertEqual(L10n.string("helper.error.cancelled"), "Operation cancelled.") XCTAssertEqual(L10n.format("event.summary.result", "deploy", "finished"), "deploy: finished") } func testUninstallPlanParamsCarryNoRebootSelection() { - let params = OperationParams.uninstallPlan(noReboot: true, noWait: true, mountWait: 9) + let params = OperationParams.uninstallPlan(noReboot: true, noWait: true, mountWait: 9, password: "pw") XCTAssertEqual(params["dry_run"], .bool(true)) XCTAssertEqual(params["no_reboot"], .bool(true)) XCTAssertEqual(params["no_wait"], .bool(true)) XCTAssertEqual(params["mount_wait"], .number(9)) + XCTAssertEqual(params["credentials"], .object(["password": .string("pw")])) } - func testDeployConfirmationCarriesDeployAndRebootConsent() { - let confirmation = PendingConfirmation.deploy(noReboot: false, nbnsEnabled: true, debugLogging: true, mountWait: 45, noWait: true) + func testDeployRunParamsCarryOptionsWithoutFrontendConsentFlags() { + let params = OperationParams.deployRun( + noReboot: false, + noWait: true, + nbnsEnabled: true, + debugLogging: true, + mountWait: 45, + password: "" + ) - XCTAssertEqual(confirmation.operation, "deploy") - XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) - XCTAssertEqual(confirmation.params["confirm_deploy"], .bool(true)) - XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(true)) - XCTAssertEqual(confirmation.params["confirm_netbsd4_activation"], .bool(true)) - XCTAssertEqual(confirmation.params["no_reboot"], .bool(false)) - XCTAssertEqual(confirmation.params["nbns_enabled"], .bool(true)) - XCTAssertEqual(confirmation.params["debug_logging"], .bool(true)) - XCTAssertEqual(confirmation.params["mount_wait"], .number(45)) - XCTAssertEqual(confirmation.params["no_wait"], .bool(true)) + XCTAssertEqual(params["dry_run"], .bool(false)) + XCTAssertNil(params["confirm_deploy"]) + XCTAssertNil(params["confirm_reboot"]) + XCTAssertNil(params["confirm_netbsd4_activation"]) + XCTAssertEqual(params["no_reboot"], .bool(false)) + XCTAssertEqual(params["nbns_enabled"], .bool(true)) + XCTAssertEqual(params["debug_logging"], .bool(true)) + XCTAssertEqual(params["mount_wait"], .number(45)) + XCTAssertEqual(params["no_wait"], .bool(true)) + XCTAssertNil(params["credentials"]) } - func testUninstallConfirmationCarriesUninstallAndNoRebootConsent() { - let confirmation = PendingConfirmation.uninstall(noReboot: true, mountWait: 12, noWait: true) + func testPendingConfirmationBuildsFromBackendEvent() throws { + let event = BackendEvent( + type: "error", + operation: "uninstall", + code: "confirmation_required", + message: "Confirm uninstall.", + details: .object([ + "title": .string("Confirm uninstall"), + "message": .string("Remove files."), + "action_title": .string("Uninstall"), + "confirmation_id": .string("abc123") + ]) + ) + let originalParams = OperationParams.uninstallRun(noReboot: true, noWait: true, mountWait: 12, password: "pw") + + let confirmation = try XCTUnwrap(PendingConfirmation(confirmationEvent: event, originalParams: originalParams)) XCTAssertEqual(confirmation.operation, "uninstall") - XCTAssertEqual(confirmation.params["dry_run"], .bool(false)) - XCTAssertEqual(confirmation.params["confirm_uninstall"], .bool(true)) - XCTAssertEqual(confirmation.params["confirm_reboot"], .bool(false)) + 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 testMaintenanceConfirmationsCarryExplicitOperationConsent() { - let fsck = PendingConfirmation.fsck(volume: "Data", noReboot: true, mountWait: 18, noWait: true) - let repair = PendingConfirmation.repairXattrs(path: "/Volumes/Data") + func testMaintenanceRunParamsDoNotCarryFrontendConsentFlags() { + let fsck = OperationParams.fsckRun(volume: "Data", noReboot: true, noWait: true, mountWait: 18, password: "") + let repair = OperationParams.repairXattrsRun(path: "/Volumes/Data") - XCTAssertEqual(fsck.operation, "fsck") - XCTAssertEqual(fsck.params["confirm_fsck"], .bool(true)) - XCTAssertEqual(fsck.params["no_reboot"], .bool(true)) - XCTAssertEqual(fsck.params["mount_wait"], .number(18)) - XCTAssertEqual(fsck.params["no_wait"], .bool(true)) - XCTAssertEqual(fsck.params["volume"], .string("Data")) + XCTAssertNil(fsck["confirm_fsck"]) + XCTAssertEqual(fsck["no_reboot"], .bool(true)) + XCTAssertEqual(fsck["mount_wait"], .number(18)) + XCTAssertEqual(fsck["no_wait"], .bool(true)) + XCTAssertEqual(fsck["volume"], .string("Data")) - XCTAssertEqual(repair.operation, "repair-xattrs") - XCTAssertEqual(repair.params["path"], .string("/Volumes/Data")) - XCTAssertEqual(repair.params["dry_run"], .bool(false)) - XCTAssertEqual(repair.params["confirm_repair"], .bool(true)) + XCTAssertEqual(repair["path"], .string("/Volumes/Data")) + XCTAssertEqual(repair["dry_run"], .bool(false)) + XCTAssertNil(repair["confirm_repair"]) } } diff --git a/src/timecapsulesmb/app/confirmations.py b/src/timecapsulesmb/app/confirmations.py new file mode 100644 index 00000000..26c9f22f --- /dev/null +++ b/src/timecapsulesmb/app/confirmations.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from typing import Mapping + +from timecapsulesmb.services.app import AppOperationError, jsonable + + +CONFIRMATION_SCHEMA_VERSION = 1 +_LEGACY_CONFIRM_KEYS = frozenset({ + "yes", + "confirm", + "confirm_deploy", + "confirm_reboot", + "confirm_netbsd4_activation", + "confirm_uninstall", + "confirm_fsck", + "confirm_repair", +}) +_CONFIRMATION_ONLY_KEYS = frozenset({ + "confirmation_id", + "confirmation", + *_LEGACY_CONFIRM_KEYS, +}) +_SECRET_PARAM_KEYS = frozenset({"password", "credentials"}) + + +@dataclass(frozen=True) +class ConfirmationRequest: + operation: str + title: str + message: str + action_title: str + risk: str + confirmation_id: str + summary: str + context: Mapping[str, object] + + def to_jsonable(self) -> dict[str, object]: + return { + "schema_version": CONFIRMATION_SCHEMA_VERSION, + "operation": self.operation, + "title": self.title, + "message": self.message, + "action_title": self.action_title, + "risk": self.risk, + "confirmation_id": self.confirmation_id, + "summary": self.summary, + "context": jsonable(dict(self.context)), + } + + +class AppConfirmationRequired(AppOperationError): + def __init__(self, confirmation: ConfirmationRequest) -> None: + super().__init__(confirmation.message, code="confirmation_required") + self.confirmation = confirmation + + +def _safe_params(params: Mapping[str, object]) -> dict[str, object]: + return { + str(key): value + for key, value in params.items() + if str(key) not in _CONFIRMATION_ONLY_KEYS and str(key) not in _SECRET_PARAM_KEYS + } + + +def _confirmation_id(operation: str, params: Mapping[str, object], context: Mapping[str, object]) -> str: + canonical = { + "schema_version": CONFIRMATION_SCHEMA_VERSION, + "operation": operation, + "params": jsonable(_safe_params(params)), + "context": jsonable(dict(context)), + } + payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def build_confirmation( + *, + operation: str, + params: Mapping[str, object], + title: str, + message: str, + action_title: str, + risk: str, + summary: str, + context: Mapping[str, object], +) -> ConfirmationRequest: + return ConfirmationRequest( + operation=operation, + title=title, + message=message, + action_title=action_title, + risk=risk, + confirmation_id=_confirmation_id(operation, params, context), + summary=summary, + context=context, + ) + + +def supplied_confirmation_id(params: Mapping[str, object]) -> str: + direct = params.get("confirmation_id") + if isinstance(direct, str): + return direct.strip() + nested = params.get("confirmation") + if isinstance(nested, Mapping): + nested_id = nested.get("id") or nested.get("confirmation_id") + if isinstance(nested_id, str): + return nested_id.strip() + return "" + + +def has_legacy_confirmation(params: Mapping[str, object], *names: str) -> bool: + from timecapsulesmb.services.app import bool_param + + if "yes" in params and bool_param(dict(params), "yes"): + return True + return bool(names) and all(name in params and bool_param(dict(params), name) for name in names) + + +def require_confirmation( + params: Mapping[str, object], + confirmation: ConfirmationRequest, + *, + legacy_names: tuple[str, ...] = (), +) -> None: + if has_legacy_confirmation(params, *legacy_names): + return + if supplied_confirmation_id(params) == confirmation.confirmation_id: + return + raise AppConfirmationRequired(confirmation) diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index 382fbb03..b1e322dd 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -17,6 +17,26 @@ def _with_schema(payload: Mapping[str, object]) -> dict[str, object]: 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, diff --git a/src/timecapsulesmb/app/events.py b/src/timecapsulesmb/app/events.py index 1b577bb3..9abdb1e4 100644 --- a/src/timecapsulesmb/app/events.py +++ b/src/timecapsulesmb/app/events.py @@ -112,10 +112,13 @@ def error( 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: diff --git a/src/timecapsulesmb/app/operations.py b/src/timecapsulesmb/app/operations.py deleted file mode 100644 index ab90ec31..00000000 --- a/src/timecapsulesmb/app/operations.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -# Compatibility shim for callers that imported or monkeypatched the original -# monolithic module. New code should import from timecapsulesmb.app.ops. - -from collections.abc import Callable - -from timecapsulesmb.app.events import EventSink -from timecapsulesmb.app.ops import configure as _configure -from timecapsulesmb.app.ops import deploy as _deploy -from timecapsulesmb.app.ops import doctor as _doctor -from timecapsulesmb.app.ops import maintenance as _maintenance -from timecapsulesmb.app.ops import readiness as _readiness -from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME -from timecapsulesmb.device.storage import build_dry_run_payload_home -from timecapsulesmb.services.app import ( - AppOperationError, - OperationResult, - bool_param as _bool_param, - config_path as _config_path, - confirm_param as _confirm_param, - float_param as _float_param, - int_param as _int_param, - jsonable as _jsonable, - optional_int_param as _optional_int_param, - require_string_param as _require_string_param, - string_param as _string_param, -) - - -discover_snapshot = _readiness.discover_snapshot - -probe_connection_state = _configure.probe_connection_state -enable_ssh = _configure.enable_ssh - -load_env_config = _deploy.load_env_config -resolve_validated_managed_target = _deploy.resolve_validated_managed_target -resolve_app_paths = _deploy.resolve_app_paths -validate_artifacts = _deploy.validate_artifacts -resolve_payload_artifacts = _deploy.resolve_payload_artifacts -run_remote_actions = _deploy.run_remote_actions -wait_for_mast_volumes_conn = _deploy.wait_for_mast_volumes_conn -select_payload_home_with_diagnostics_conn = _deploy.select_payload_home_with_diagnostics_conn -verify_payload_home_conn = _deploy.verify_payload_home_conn -upload_deployment_payload = _deploy.upload_deployment_payload -flush_remote_filesystem_writes = _deploy.flush_remote_filesystem_writes -wait_for_ssh_state_conn = _deploy.wait_for_ssh_state_conn - -resolve_env_connection = _maintenance.resolve_env_connection -remote_uninstall_payload = _maintenance.remote_uninstall_payload -read_mast_volumes_conn = _maintenance.read_mast_volumes_conn -mounted_mast_volumes_conn = _maintenance.mounted_mast_volumes_conn -run_ssh = _maintenance.run_ssh -probe_managed_runtime_conn = _maintenance.probe_managed_runtime_conn -load_optional_env_config = _maintenance.load_optional_env_config -repair_xattrs_cli = _maintenance.repair_xattrs_cli -sys = _maintenance.sys - -run_doctor_checks = _doctor.run_doctor_checks - - -def _sync_compat_bindings() -> None: - _readiness.discover_snapshot = discover_snapshot - _readiness.resolve_app_paths = resolve_app_paths - - _configure.probe_connection_state = probe_connection_state - _configure.enable_ssh = enable_ssh - _configure.resolve_app_paths = resolve_app_paths - - _deploy.load_env_config = load_env_config - _deploy.resolve_validated_managed_target = resolve_validated_managed_target - _deploy.resolve_app_paths = resolve_app_paths - _deploy.validate_artifacts = validate_artifacts - _deploy.resolve_payload_artifacts = resolve_payload_artifacts - _deploy.run_remote_actions = run_remote_actions - _deploy.wait_for_mast_volumes_conn = wait_for_mast_volumes_conn - _deploy.select_payload_home_with_diagnostics_conn = select_payload_home_with_diagnostics_conn - _deploy.verify_payload_home_conn = verify_payload_home_conn - _deploy.upload_deployment_payload = upload_deployment_payload - _deploy.flush_remote_filesystem_writes = flush_remote_filesystem_writes - _deploy.wait_for_ssh_state_conn = wait_for_ssh_state_conn - - _maintenance.load_env_config = load_env_config - _maintenance.resolve_env_connection = resolve_env_connection - _maintenance.remote_uninstall_payload = remote_uninstall_payload - _maintenance.read_mast_volumes_conn = read_mast_volumes_conn - _maintenance.mounted_mast_volumes_conn = mounted_mast_volumes_conn - _maintenance.run_ssh = run_ssh - _maintenance.wait_for_ssh_state_conn = wait_for_ssh_state_conn - _maintenance.run_remote_actions = run_remote_actions - _maintenance.probe_managed_runtime_conn = probe_managed_runtime_conn - _maintenance.load_optional_env_config = load_optional_env_config - _maintenance.repair_xattrs_cli = repair_xattrs_cli - _maintenance.sys = sys - - _doctor.load_env_config = load_env_config - _doctor.resolve_app_paths = resolve_app_paths - _doctor.resolve_env_connection = resolve_env_connection - _doctor.run_doctor_checks = run_doctor_checks - - -def discover_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _readiness.discover_operation(params, sink) - - -def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _readiness.paths_operation(params, sink) - - -def validate_install_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _readiness.validate_install_operation(params, sink) - - -def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _configure.configure_operation(params, sink) - - -def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _deploy.deploy_operation(params, sink) - - -def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.activate_operation(params, sink) - - -def uninstall_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.uninstall_operation(params, sink) - - -def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.fsck_operation(params, sink) - - -def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _maintenance.repair_xattrs_operation(params, sink) - - -def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: - _sync_compat_bindings() - return _doctor.doctor_operation(params, sink) - - -_selected_record_host = _readiness.selected_record_host -_selected_record_properties = _readiness.selected_record_properties -_snapshot_payload = _readiness.snapshot_payload -_wait_for_ssh_port = _configure.wait_for_ssh_port -_require_supported_payload = _deploy.require_supported_payload -_load_config_and_target = _deploy.load_config_and_target -_verify_payload_upload = _deploy.verify_payload_upload -_verify_runtime = _deploy.verify_runtime -_request_reboot_and_wait = _deploy.request_reboot_and_wait -_request_ssh_reboot = _deploy.request_ssh_reboot -_observe_reboot_cycle = _maintenance.observe_reboot_cycle -_RepairContext = _maintenance.RepairExecutionContext -_StreamLogCapture = _maintenance.LineLogCapture - - -OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { - "activate": activate_operation, - "configure": configure_operation, - "deploy": deploy_operation, - "discover": discover_operation, - "doctor": doctor_operation, - "fsck": fsck_operation, - "paths": paths_operation, - "repair-xattrs": repair_xattrs_operation, - "uninstall": uninstall_operation, - "validate-install": validate_install_operation, -} diff --git a/src/timecapsulesmb/app/ops/__init__.py b/src/timecapsulesmb/app/ops/__init__.py index b015146d..8a961c45 100644 --- a/src/timecapsulesmb/app/ops/__init__.py +++ b/src/timecapsulesmb/app/ops/__init__.py @@ -12,12 +12,18 @@ repair_xattrs_operation, uninstall_operation, ) -from timecapsulesmb.app.ops.readiness import discover_operation, paths_operation, validate_install_operation +from timecapsulesmb.app.ops.readiness import ( + capabilities_operation, + discover_operation, + paths_operation, + validate_install_operation, +) from timecapsulesmb.services.app import OperationResult OPERATIONS: dict[str, Callable[[dict[str, object], EventSink], OperationResult]] = { "activate": activate_operation, + "capabilities": capabilities_operation, "configure": configure_operation, "deploy": deploy_operation, "discover": discover_operation, diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index 5e9b7b54..1d88b0dd 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -9,7 +9,6 @@ DEFAULTS, parse_bool, parse_env_file, - write_env_file, ) from timecapsulesmb.core.net import extract_host from timecapsulesmb.core.paths import resolve_app_paths @@ -26,11 +25,11 @@ require_string_param, string_param, ) +from timecapsulesmb.services.config_store import EnvFileConfigStore from timecapsulesmb.services.configure import build_configure_env_values +from timecapsulesmb.services.runtime import ssh_target_link_local_resolution_error, wait_for_tcp_port_state from timecapsulesmb.transport.ssh import SshConnection -from timecapsulesmb.cli.runtime import ssh_target_link_local_resolution_error - def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "configure" @@ -113,7 +112,8 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation sink.stage(operation, "write_env") env_path.parent.mkdir(parents=True, exist_ok=True) - write_env_file(env_path, values) + omit_keys = frozenset() if bool_param(params, "persist_password") else frozenset({"TC_PASSWORD"}) + EnvFileConfigStore(omit_keys=omit_keys).save(env_path, values) return OperationResult(True, configure_payload( config_path=str(env_path), host=host, @@ -126,13 +126,10 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation def wait_for_ssh_port(host: str, *, timeout_seconds: int) -> bool: - from timecapsulesmb.cli.flows import wait_for_tcp_port_state - return wait_for_tcp_port_state( extract_host(host), 22, expected_state=True, timeout_seconds=timeout_seconds, - verbose=False, service_name="SSH port", ) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index 7757082d..75716b59 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -5,8 +5,8 @@ import tempfile from timecapsulesmb.app.contracts import deploy_plan_payload, deploy_result_payload +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.events import EventSink -from timecapsulesmb.cli.runtime import load_env_config, resolve_validated_managed_target from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, airport_family_display_name_from_identity from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP from timecapsulesmb.core.net import extract_host @@ -64,9 +64,9 @@ OperationResult, bool_param, config_path, - confirm_param, int_param, ) +from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.deploy import ( DEPLOY_REBOOT_NO_DOWN_MESSAGE, DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE, @@ -75,6 +75,7 @@ payload_verification_error, render_flash_runtime_config, ) +from timecapsulesmb.services.runtime import load_env_config, resolve_validated_managed_target from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError @@ -105,7 +106,7 @@ def load_config_and_target( include_probe: bool, ) -> tuple[AppConfig, object]: sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_managed_target") target = resolve_validated_managed_target( config, @@ -122,16 +123,10 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes dry_run = bool_param(params, "dry_run") no_reboot = bool_param(params, "no_reboot") no_wait = bool_param(params, "no_wait") - confirm_deploy = confirm_param(params, "confirm_deploy") - confirm_reboot = confirm_param(params, "confirm_reboot") - confirm_netbsd4_activation = confirm_param(params, "confirm_netbsd4_activation") mount_wait = int_param(params, "mount_wait", DEFAULT_APPLE_MOUNT_WAIT_SECONDS) allow_unsupported = bool_param(params, "allow_unsupported") debug_logging = bool_param(params, "debug_logging") - if not dry_run and not confirm_deploy: - raise AppOperationError("Deploy requires explicit confirmation.", code="confirmation_required") - config, target = load_config_and_target(operation, params, sink, profile="deploy", include_probe=True) connection = target.connection app_paths = resolve_app_paths(config_path=config_path(params)) @@ -148,21 +143,63 @@ def deploy_operation(params: dict[str, object], sink: EventSink) -> OperationRes sink.log(operation, f"Using {payload_family_description(payload_family)} payload.") resolved_artifacts = resolve_payload_artifacts(app_paths.distribution_root, payload_family) if not dry_run: - if is_netbsd4 and not confirm_netbsd4_activation: - raise AppOperationError( - "NetBSD 4 deploy requires explicit activation confirmation.", - code="confirmation_required", - ) - if not is_netbsd4 and not no_reboot and not confirm_reboot: - 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, - ) - raise AppOperationError( - f"Deploy requires confirmation to reboot the {device_name}.", - code="confirmation_required", - ) - + confirmation_plan = build_deployment_plan( + connection.host, + build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME), + resolved_artifacts["smbd"].absolute_path, + resolved_artifacts["mdns-advertiser"].absolute_path, + resolved_artifacts["nbns-advertiser"].absolute_path, + activate_netbsd4=is_netbsd4, + reboot_after_deploy=not no_reboot, + apple_mount_wait_seconds=mount_wait, + ) + device_name = airport_family_display_name_from_identity( + model=target.probe_state.probe_result.airport_model if target.probe_state else None, + syap=target.probe_state.probe_result.airport_syap if target.probe_state else None, + ) + if is_netbsd4: + title = "Confirm NetBSD4 deployment" + message = f"Deploy and activate the NetBSD4 payload on this {device_name}. Remote services will be changed." + action_title = "Deploy and activate" + risk = "destructive" + summary = "NetBSD4 deployment with service activation" + elif no_reboot: + title = "Confirm deployment" + message = f"Deploy TimeCapsuleSMB to this {device_name} without rebooting it." + action_title = "Deploy" + risk = "remote_write" + summary = "Deployment without reboot" + else: + title = "Confirm deployment and reboot" + message = f"Deploy TimeCapsuleSMB and reboot this {device_name}." + action_title = "Deploy and reboot" + risk = "reboot" + summary = "Deployment with reboot request" + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title=title, + message=message, + action_title=action_title, + risk=risk, + summary=summary, + context={ + "host": connection.host, + "payload_family": payload_family, + "netbsd4": is_netbsd4, + "requires_reboot": bool(confirmation_plan.reboot_required), + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=( + ("confirm_deploy", "confirm_netbsd4_activation") + if is_netbsd4 + else ("confirm_deploy",) if no_reboot else ("confirm_deploy", "confirm_reboot") + ), + ) if dry_run: payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) else: diff --git a/src/timecapsulesmb/app/ops/doctor.py b/src/timecapsulesmb/app/ops/doctor.py index 0996e41c..7bc12d81 100644 --- a/src/timecapsulesmb/app/ops/doctor.py +++ b/src/timecapsulesmb/app/ops/doctor.py @@ -4,18 +4,19 @@ from timecapsulesmb.app.events import EventSink from timecapsulesmb.checks.doctor import run_doctor_checks from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.cli.doctor import build_doctor_error -from timecapsulesmb.cli.runtime import load_env_config, resolve_env_connection from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC from timecapsulesmb.services.app import OperationResult, bool_param, config_path, float_param +from timecapsulesmb.services.credentials import overlay_request_credentials +from timecapsulesmb.services.doctor import build_doctor_error +from timecapsulesmb.services.runtime import load_env_config, resolve_env_connection def doctor_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "doctor" bonjour_timeout = float_param(params, "bonjour_timeout", DEFAULT_BROWSE_TIMEOUT_SEC) sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) app_paths = resolve_app_paths(config_path=config_path(params)) connection = None if not bool_param(params, "skip_ssh") and config.has_value("TC_HOST"): diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index 74b9e833..b2db7485 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -15,6 +15,7 @@ uninstall_plan_payload, uninstall_result_payload, ) +from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.events import EventSink from timecapsulesmb.app.ops.deploy import ( load_config_and_target, @@ -23,12 +24,6 @@ require_supported_payload, verify_runtime, ) -from timecapsulesmb.cli import repair_xattrs as repair_xattrs_cli -from timecapsulesmb.cli.fsck import ( - FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, - build_remote_fsck_script, -) -from timecapsulesmb.cli.runtime import load_env_config, load_optional_env_config, resolve_env_connection from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME from timecapsulesmb.core.errors import system_exit_message from timecapsulesmb.core.messages import NETBSD4_REBOOT_FOLLOWUP @@ -52,19 +47,21 @@ OperationResult, bool_param, config_path, - confirm_param, int_param, jsonable, optional_int_param, required_path_param, string_param, ) +from timecapsulesmb.services.credentials import overlay_request_credentials from timecapsulesmb.services.deploy import DEPLOY_REBOOT_UP_TIMEOUT_MESSAGE from timecapsulesmb.services.maintenance import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, FSCK_REBOOT_NO_DOWN_MESSAGE, UNINSTALL_REBOOT_NO_DOWN_MESSAGE, LineLogCapture, RepairExecutionContext, + build_remote_fsck_script, format_fsck_plan, format_fsck_targets, fsck_plan_to_jsonable, @@ -72,12 +69,13 @@ fsck_target_to_jsonable, select_fsck_target, ) +from timecapsulesmb.services import repair_xattrs as repair_xattrs_service +from timecapsulesmb.services.runtime import load_env_config, load_optional_env_config, resolve_env_connection from timecapsulesmb.transport.ssh import SshConnection, run_ssh def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "activate" - confirm_activation = confirm_param(params, "confirm_netbsd4_activation") dry_run = bool_param(params, "dry_run") _, target = load_config_and_target(operation, params, sink, profile="activate", include_probe=True) compatibility = require_supported_payload(target, allow_unsupported=False) @@ -90,12 +88,28 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR plan = build_netbsd4_activation_plan() if dry_run: return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) - if not confirm_activation: - raise AppOperationError("NetBSD4 activation requires explicit confirmation.", code="confirmation_required") connection = target.connection sink.stage(operation, "probe_runtime") if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: return OperationResult(True, activation_result_payload(already_active=True)) + 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": connection.host, + "payload_family": compatibility.payload_family, + "netbsd4": True, + }, + ), + legacy_names=("confirm_netbsd4_activation",), + ) sink.stage(operation, "run_activation") run_remote_actions(connection, plan.actions) verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) @@ -111,16 +125,33 @@ def uninstall_operation(params: dict[str, object], sink: EventSink) -> Operation 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) - confirm_uninstall = confirm_param(params, "confirm_uninstall") - confirm_reboot = confirm_param(params, "confirm_reboot") - if not dry_run and not confirm_uninstall: - raise AppOperationError("Uninstall requires explicit confirmation.", code="confirmation_required") - if not dry_run and not no_reboot and not confirm_reboot: - raise AppOperationError("Uninstall requires confirmation to reboot the device.", code="confirmation_required") sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) + if not dry_run: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm uninstall", + message=( + "Remove managed TimeCapsuleSMB files from the device" + + (" and reboot it." if not no_reboot else ".") + ), + action_title="Uninstall", + risk="destructive" if not no_reboot else "remote_write", + summary="Uninstall managed payload" + (" with reboot" if not no_reboot else " without reboot"), + context={ + "host": connection.host, + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=("confirm_uninstall",) if no_reboot else ("confirm_uninstall", "confirm_reboot"), + ) if dry_run: volume_roots = [UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER] payload_dirs = [f"{UNINSTALL_DRY_RUN_VOLUME_ROOT_PLACEHOLDER}/{MANAGED_PAYLOAD_DIR_NAME}"] @@ -187,16 +218,33 @@ def fsck_operation(params: dict[str, object], sink: EventSink) -> OperationResul operation = "fsck" dry_run = bool_param(params, "dry_run") list_volumes = bool_param(params, "list_volumes") - confirm_fsck = confirm_param(params, "confirm_fsck") 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 and not confirm_fsck: - raise AppOperationError("fsck requires explicit confirmation.", code="confirmation_required") + if not dry_run and not list_volumes: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm fsck", + message="Run fsck on the selected HFS volume" + (" and reboot the device." if not no_reboot else "."), + action_title="Run fsck", + risk="destructive" if not no_reboot else "remote_write", + summary="Filesystem check and repair", + context={ + "volume": string_param(params, "volume"), + "requires_reboot": not no_reboot, + "no_reboot": no_reboot, + "no_wait": no_wait, + }, + ), + legacy_names=("confirm_fsck",), + ) sink.stage(operation, "load_config") - config = load_env_config(env_path=config_path(params)) + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_connection") connection = resolve_env_connection(config, allow_empty_password=True) sink.stage(operation, "read_mast") @@ -297,12 +345,6 @@ def observe_reboot_cycle( def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" dry_run = bool_param(params, "dry_run") - confirm_repair = confirm_param(params, "confirm_repair") - if not dry_run and not confirm_repair: - raise AppOperationError( - "repair-xattrs requires dry_run or explicit confirmation.", - code="confirmation_required", - ) sink.stage(operation, "platform_check") if sys.platform != "darwin": raise AppOperationError( @@ -311,11 +353,26 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera ) sink.stage(operation, "validate_params") path = required_path_param(params, "path") + if not dry_run: + require_confirmation( + params, + build_confirmation( + operation=operation, + params=params, + title="Confirm xattr repair", + message=f"Repair known-safe macOS metadata issues under {path}.", + action_title="Repair xattrs", + risk="local_write", + summary="Repair local mounted-share metadata", + context={"path": str(path)}, + ), + legacy_names=("confirm_repair",), + ) config = load_optional_env_config(env_path=config_path(params)) args = argparse.Namespace( path=path, dry_run=dry_run, - yes=confirm_repair, + yes=not dry_run, recursive=bool_param(params, "recursive", True), max_depth=optional_int_param(params, "max_depth"), include_hidden=bool_param(params, "include_hidden"), @@ -328,7 +385,7 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera stderr_capture = LineLogCapture(lambda message: sink.log(operation, message, level="warning")) try: with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): - result = repair_xattrs_cli.run_repair_structured( + result = repair_xattrs_service.run_repair_structured( args, context, config, diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py index 05e10b38..7fc7d82a 100644 --- a/src/timecapsulesmb/app/ops/readiness.py +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -1,8 +1,11 @@ from __future__ import annotations -from timecapsulesmb.app.contracts import discover_payload, install_validation_payload, paths_payload +import hashlib + +from timecapsulesmb.app.contracts import capabilities_payload, discover_payload, install_validation_payload, paths_payload from timecapsulesmb.app.events import EventSink -from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.core.paths import artifact_manifest_resource, resolve_app_paths +from timecapsulesmb.core.release import CLI_VERSION, CLI_VERSION_CODE from timecapsulesmb.discovery.bonjour import ( DEFAULT_BROWSE_TIMEOUT_SEC, BonjourDiscoverySnapshot, @@ -67,6 +70,36 @@ def discover_operation(params: dict[str, object], sink: EventSink) -> OperationR return OperationResult(True, discover_payload(snapshot_payload(snapshot))) +def capabilities_operation(params: dict[str, object], sink: EventSink) -> OperationResult: + operation = "capabilities" + sink.stage(operation, "resolve_paths") + app_paths = resolve_app_paths(config_path=config_path(params)) + sink.stage(operation, "summarize_capabilities") + try: + manifest_hash = hashlib.sha256(artifact_manifest_resource().read_bytes()).hexdigest() + except OSError: + manifest_hash = None + return OperationResult(True, capabilities_payload( + helper_version=CLI_VERSION, + helper_version_code=CLI_VERSION_CODE, + operations=[ + "activate", + "capabilities", + "configure", + "deploy", + "discover", + "doctor", + "fsck", + "paths", + "repair-xattrs", + "uninstall", + "validate-install", + ], + distribution_root=str(app_paths.distribution_root), + artifact_manifest_sha256=manifest_hash, + )) + + def paths_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "paths" sink.stage(operation, "resolve_paths") 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 index ca796394..92695ec7 100644 --- a/src/timecapsulesmb/app/service.py +++ b/src/timecapsulesmb/app/service.py @@ -4,46 +4,32 @@ from collections.abc import Callable from timecapsulesmb.app.events import EventSink, redact -from timecapsulesmb.app.operations import OPERATIONS +from timecapsulesmb.app.ops import OPERATIONS +from timecapsulesmb.app.confirmations import AppConfirmationRequired +from timecapsulesmb.app.requests import parse_api_request from timecapsulesmb.app.recovery import recovery_for from timecapsulesmb.core.config import ConfigError from timecapsulesmb.services.app import AppOperationError, OperationResult from timecapsulesmb.transport.errors import TransportError -def _request_operation(request: dict[str, object]) -> str: - return str(request.get("operation") or "") - - -def _request_params(request: dict[str, object]) -> object: - if "params" not in request or request.get("params") is None: - return {} - return request.get("params") - - def run_api_request(request: dict[str, object], sink: EventSink) -> int: - request_id = request.get("request_id") - if request_id is not None and str(request_id).strip(): - sink = sink.with_request_id(str(request_id)) - - operation = _request_operation(request) - params = _request_params(request) - if not operation: + try: + api_request = parse_api_request(request) + except AppOperationError as exc: sink.error( "api", - "missing required field: operation", - code="invalid_request", + str(exc), + code=exc.code, recovery=recovery_for("api", "invalid_request"), ) return 1 - if not isinstance(params, dict): - sink.error( - operation, - "params must be a JSON object", - code="invalid_request", - recovery=recovery_for(operation, "invalid_request"), - ) - return 1 + + if api_request.request_id: + sink = sink.with_request_id(api_request.request_id) + + operation = api_request.operation + params = api_request.params handler: Callable[[dict[str, object], EventSink], OperationResult] | None = OPERATIONS.get(operation) if handler is None: sink.error( @@ -56,6 +42,15 @@ def run_api_request(request: dict[str, object], sink: EventSink) -> int: return 1 try: result = handler(params, sink) + except AppConfirmationRequired as exc: + sink.error( + operation, + str(exc), + code=exc.code, + details=exc.confirmation.to_jsonable(), + recovery=recovery_for(operation, exc.code, stage=sink.current_stage(operation)), + ) + return 1 except AppOperationError as exc: recovery = exc.recovery or recovery_for(operation, exc.code, stage=sink.current_stage(operation)) sink.error( diff --git a/src/timecapsulesmb/app/stage_policy.py b/src/timecapsulesmb/app/stage_policy.py index 263fc478..3ea3c875 100644 --- a/src/timecapsulesmb/app/stage_policy.py +++ b/src/timecapsulesmb/app/stage_policy.py @@ -35,6 +35,8 @@ def to_jsonable(self) -> dict[str, object]: _POLICIES: dict[tuple[str, str], StagePolicy] = { + ("capabilities", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve helper configuration and distribution paths."), + ("capabilities", "summarize_capabilities"): StagePolicy(LOCAL_READ, True, "Summarize helper API capabilities."), ("discover", "bonjour_discovery"): StagePolicy(LOCAL_READ, True, "Browse for AirPort Bonjour services."), ("paths", "resolve_paths"): StagePolicy(LOCAL_READ, True, "Resolve configuration, state, and distribution paths."), ("paths", "summarize_artifacts"): StagePolicy(LOCAL_READ, True, "Summarize bundled artifact paths."), diff --git a/src/timecapsulesmb/cli/fsck.py b/src/timecapsulesmb/cli/fsck.py index c49bbf54..524fcacf 100644 --- a/src/timecapsulesmb/cli/fsck.py +++ b/src/timecapsulesmb/cli/fsck.py @@ -7,10 +7,11 @@ from timecapsulesmb.cli.context import CommandContext from timecapsulesmb.cli.flows import observe_reboot_cycle from timecapsulesmb.cli.runtime import add_config_argument, add_mount_wait_argument, add_no_wait_argument, load_env_config -from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.services.maintenance import ( + FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS, FSCK_REBOOT_NO_DOWN_MESSAGE, + build_remote_fsck_script, format_fsck_plan, format_fsck_targets, fsck_target_from_volume, @@ -20,31 +21,6 @@ from timecapsulesmb.transport.ssh import run_ssh -FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 - - -def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: - lines = [ - render_direct_pkill9_watchdog(), - render_direct_pkill9_by_ucomm("smbd"), - render_direct_pkill9_by_ucomm("afpserver"), - render_direct_pkill9_by_ucomm("wcifsnd"), - render_direct_pkill9_by_ucomm("wcifsfs"), - "sleep 2", - f"/sbin/umount -f {shlex.quote(mountpoint)} >/dev/null 2>&1 || true", - f"echo '--- fsck_hfs {device} ---'", - f"/sbin/fsck_hfs -fy {shlex.quote(device)} 2>&1 || true", - ] - if reboot: - lines.extend( - [ - "echo '--- reboot ---'", - "/sbin/reboot >/dev/null 2>&1 || true", - ] - ) - 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) diff --git a/src/timecapsulesmb/cli/runtime.py b/src/timecapsulesmb/cli/runtime.py index 702c519d..de1fab3f 100644 --- a/src/timecapsulesmb/cli/runtime.py +++ b/src/timecapsulesmb/cli/runtime.py @@ -31,6 +31,7 @@ ) from timecapsulesmb.deploy.planner import DEFAULT_APPLE_MOUNT_WAIT_SECONDS from timecapsulesmb.discovery.bonjour import DEFAULT_BROWSE_TIMEOUT_SEC +from timecapsulesmb.services import runtime as service_runtime from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy @@ -175,8 +176,7 @@ def load_config_from_args( def load_env_config(*, env_path: Path | None = None, defaults: dict[str, str] | None = None) -> AppConfig: - resolved_path = resolve_app_paths(config_path=env_path).config_path - return load_app_config(resolved_path, defaults=defaults) + return service_runtime.load_env_config(env_path=env_path, defaults=defaults, resolve_paths=resolve_app_paths) def load_optional_env_config( @@ -184,16 +184,7 @@ def load_optional_env_config( env_path: Path | None = None, defaults: dict[str, str] | None = None, ) -> AppConfig: - try: - resolved_path = resolve_app_paths(config_path=env_path).config_path - except Exception: - return AppConfig.missing(path=env_path or Path.cwd() / ".env") - if not resolved_path.exists(): - return AppConfig.missing(path=resolved_path) - try: - return load_app_config(resolved_path, defaults=defaults) - except OSError: - return AppConfig.missing(path=resolved_path) + return service_runtime.load_optional_env_config(env_path=env_path, defaults=defaults, resolve_paths=resolve_app_paths) def resolve_ssh_credentials( @@ -201,12 +192,7 @@ def resolve_ssh_credentials( *, allow_empty_password: bool = False, ) -> tuple[str, str]: - host = config.require("TC_HOST") - password = config.get("TC_PASSWORD") - if not password and not allow_empty_password: - import getpass - password = getpass.getpass("Device root password: ") - return host, password + return service_runtime.resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) def resolve_env_connection( @@ -215,10 +201,11 @@ def resolve_env_connection( required_keys: tuple[str, ...] = (), allow_empty_password: bool = False, ) -> SshConnection: - for key in required_keys: - config.require(key) - host, password = resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) - return SshConnection(host=host, password=password, ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + return service_runtime.resolve_env_connection( + config, + required_keys=required_keys, + allow_empty_password=allow_empty_password, + ) def inspect_managed_connection( @@ -227,9 +214,7 @@ def inspect_managed_connection( *, include_probe: bool = False, ) -> ManagedTargetState: - interface_probe = probe_remote_interface_conn(connection, iface) - probe_state = probe_connection_state(connection) if include_probe else None - return ManagedTargetState(connection=connection, interface_probe=interface_probe, probe_state=probe_state) + return service_runtime.inspect_managed_connection(connection, iface, include_probe=include_probe) def ssh_target_link_local_resolution_error( @@ -238,20 +223,7 @@ def ssh_target_link_local_resolution_error( *, field_name: str = "Device SSH target", ) -> str | None: - if ssh_opts_use_proxy(ssh_opts): - return None - host = extract_host(target).strip() - if not host or ipv4_literal(host) is not None: - return None - link_local_ips = tuple(ip for ip in resolve_host_ipv4s(host) if is_link_local_ipv4(ip)) - if not link_local_ips: - return None - noun = "address" if len(link_local_ips) == 1 else "addresses" - return ( - f"{field_name} host {host} resolves to 169.254.x.x link-local IPv4 {noun} " - f"{', '.join(link_local_ips)}. Use the device's LAN IP or a hostname that resolves " - "to its LAN IP; 169.254.x.x is only suitable for temporary SSH recovery." - ) + return service_runtime.ssh_target_link_local_resolution_error(target, ssh_opts, field_name=field_name) def resolve_validated_managed_target( @@ -261,27 +233,16 @@ def resolve_validated_managed_target( profile: str, include_probe: bool = False, ) -> ManagedTargetState: - require_valid_app_config(config, profile=profile, command_name=command_name) - resolution_error = ssh_target_link_local_resolution_error( - config.require("TC_HOST"), - config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), - field_name="TC_HOST", + return service_runtime.resolve_validated_managed_target( + config, + command_name=command_name, + profile=profile, + include_probe=include_probe, ) - if resolution_error is not None: - raise ConfigError(resolution_error) - connection = resolve_env_connection(config) - if profile == "flash": - return ManagedTargetState(connection=connection, interface_probe=None, probe_state=None) - probe_state = probe_connection_state(connection) if include_probe else None - return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) def require_connection_compatibility(connection: SshConnection) -> DeviceCompatibility: - state = probe_connection_state(connection) - return require_compatibility( - state.compatibility, - fallback_error=state.probe_result.error or "Failed to determine remote device OS compatibility.", - ) + return service_runtime.require_connection_compatibility(connection) def require_supported_device_compatibility( diff --git a/src/timecapsulesmb/services/app.py b/src/timecapsulesmb/services/app.py index 64723ebe..6ed5b105 100644 --- a/src/timecapsulesmb/services/app.py +++ b/src/timecapsulesmb/services/app.py @@ -53,8 +53,12 @@ def bool_param(params: dict[str, object], name: str, default: bool = False) -> b if isinstance(value, bool): return value if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y"} - return bool(value) + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y"}: + return True + if normalized in {"0", "false", "no", "n"}: + return False + raise AppOperationError(f"{name} must be a boolean", code="validation_failed") def confirm_param(params: dict[str, object], name: str) -> bool: diff --git a/src/timecapsulesmb/services/config_store.py b/src/timecapsulesmb/services/config_store.py new file mode 100644 index 00000000..8aaaebce --- /dev/null +++ b/src/timecapsulesmb/services/config_store.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Protocol + +from timecapsulesmb.core.config import AppConfig, load_app_config, write_env_file + + +class ConfigStore(Protocol): + def load(self, path: Path, *, defaults: dict[str, str] | None = None) -> AppConfig: + ... + + def save(self, path: Path, values: Mapping[str, str]) -> None: + ... + + +@dataclass(frozen=True) +class EnvFileConfigStore: + omit_keys: frozenset[str] = frozenset() + + def load(self, path: Path, *, defaults: dict[str, str] | None = None) -> AppConfig: + return load_app_config(path, defaults=defaults) + + def save(self, path: Path, values: Mapping[str, str]) -> None: + filtered = { + key: value + for key, value in values.items() + if key not in self.omit_keys + } + write_env_file(path, filtered) diff --git a/src/timecapsulesmb/services/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/doctor.py b/src/timecapsulesmb/services/doctor.py index c8737d37..992db561 100644 --- a/src/timecapsulesmb/services/doctor.py +++ b/src/timecapsulesmb/services/doctor.py @@ -1,10 +1,221 @@ from __future__ import annotations +import re +from collections.abc import Mapping + from timecapsulesmb.checks.models import CheckResult +BONJOUR_INSTANCE_FAILURE_PREFIX = "no discovered _smb._tcp instance matched" + + def doctor_status_counts(results: list[CheckResult]) -> dict[str, int]: return { status: sum(1 for result in results if result.status == status) for status in ("PASS", "WARN", "FAIL", "INFO") } + + +def _mapping_value(value: object, key: str) -> object | None: + if isinstance(value, Mapping): + return value.get(key) + return None + + +def _as_int(value: object) -> int | None: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _as_sequence(value: object) -> list[object]: + if isinstance(value, list): + return list(value) + if isinstance(value, tuple): + return list(value) + return [] + + +def _expected_bonjour_instance_from_results(results: list[CheckResult]) -> str | None: + for result in results: + if result.status != "FAIL" or BONJOUR_INSTANCE_FAILURE_PREFIX not in result.message: + continue + match = re.search( + r"expected (?:device |configured )?instance (?P['\"])(?P.*?)(?P=quote)", + result.message, + ) + if match: + return match.group("name") + return None + + +def _debug_bonjour_expected_instance(debug_fields: Mapping[str, object]) -> str | None: + expected = _mapping_value(debug_fields, "bonjour_expected") + value = _mapping_value(expected, "instance_name") + return value if isinstance(value, str) and value else None + + +def _bonjour_failure_uses_instance_match(results: list[CheckResult]) -> bool: + return any(result.status == "FAIL" and BONJOUR_INSTANCE_FAILURE_PREFIX in result.message for result in results) + + +def _native_dns_sd_smb_names(debug_fields: Mapping[str, object]) -> list[str]: + native_dns_sd = _mapping_value(debug_fields, "bonjour_native_dns_sd") + names: list[str] = [] + for browse in _as_sequence(_mapping_value(native_dns_sd, "browses")): + browse_type = str(_mapping_value(browse, "service_type") or "") + for event in _as_sequence(_mapping_value(browse, "events")): + event_type = str(_mapping_value(event, "service_type") or browse_type) + if not event_type.rstrip(".").startswith("_smb._tcp"): + continue + if str(_mapping_value(event, "action") or "").lower() != "add": + continue + name = _mapping_value(event, "name") + if isinstance(name, str) and name and name not in names: + names.append(name) + return names + + +def build_discovery_context(results: list[CheckResult], debug_fields: Mapping[str, object]) -> list[str]: + if not _bonjour_failure_uses_instance_match(results): + return [] + + zeroconf = _mapping_value(debug_fields, "bonjour_zeroconf") + zeroconf_instance_count = _as_int(_mapping_value(zeroconf, "instance_count")) + if zeroconf_instance_count != 0: + return [] + + native_smb_names = _native_dns_sd_smb_names(debug_fields) + expected_instance = _debug_bonjour_expected_instance(debug_fields) or _expected_bonjour_instance_from_results(results) + native_saw_expected = expected_instance is not None and expected_instance in native_smb_names + if not native_saw_expected: + return [] + + return [ + "INFO Python zeroconf discovered 0 Bonjour instances during doctor", + f"INFO native dns-sd discovered expected _smb._tcp instance {expected_instance!r}", + ( + "INFO likely doctor false negative: native macOS mDNS saw the expected service " + "but Python zeroconf did not receive browse events" + ), + ] + + +def _last_regex_group(pattern: str, text: str) -> str | None: + matches = list(re.finditer(pattern, text)) + if not matches: + return None + match = matches[-1] + return match.group(1) if match.groups() else match.group(0) + + +def _extract_generated_service_types(mdns_log: str) -> list[str]: + service_types: list[str] = [] + for match in re.finditer(r"serving service: type=([^ ]+)", mdns_log): + service_type = match.group(1) + if service_type not in service_types: + service_types.append(service_type) + return service_types + + +def build_mdns_boot_context(debug_fields: Mapping[str, object]) -> list[str]: + rc_log = _mapping_value(debug_fields, "remote_rc_local_log_tail") + mdns_log = _mapping_value(debug_fields, "remote_mdns_log_tail") + rc_text = rc_log if isinstance(rc_log, str) else "" + mdns_text = mdns_log if isinstance(mdns_log, str) else "" + combined = f"{rc_text}\n{mdns_text}" + if not combined.strip(): + return [] + + lines: list[str] = [] + capture_failed = any( + marker in combined + for marker in ( + "mDNS snapshot capture exited with failure", + "mDNS snapshot capture ended without status", + "mDNS snapshot capture timed out", + "mDNS snapshot capture did not produce trusted Apple snapshot", + "warning: could not identify local Apple mDNS records", + ) + ) + fallback_generated = ( + "generating AirPort fallback" in combined + or "airport snapshot: wrote" in combined + or "mDNS AirPort snapshot generated" in combined + ) + generated_fallback = "mdns advertiser will fall back to generated records" in combined + + if capture_failed and fallback_generated: + lines.append("INFO trusted Apple mDNS snapshot capture failed; AirPort fallback snapshot was generated") + elif capture_failed and generated_fallback: + lines.append( + "INFO trusted Apple mDNS snapshot capture failed; mdns-advertiser fell back to generated records" + ) + elif capture_failed: + lines.append("INFO trusted Apple mDNS snapshot capture failed") + + snapshot_load = _last_regex_group(r"snapshot load: loaded ([^\n]+)", mdns_text) + if snapshot_load: + lines.append(f"INFO mDNS snapshot load: loaded {snapshot_load}") + + source = _last_regex_group(r"serving summary: source=([^\s]+)", mdns_text) + service_types = _extract_generated_service_types(mdns_text) + if source and service_types: + lines.append( + f"INFO mdns-advertiser source={source}; generated services include {', '.join(service_types)}" + ) + elif source: + lines.append(f"INFO mdns-advertiser source={source}") + + takeover = _last_regex_group(r"mDNS takeover established after ([^\n]+)", mdns_text) + if takeover: + lines.append(f"INFO mDNS takeover established after {takeover}") + + return lines + + +def build_doctor_error(results: list[CheckResult], debug_fields: Mapping[str, object] | None = None) -> str | None: + debug_fields = debug_fields or {} + fail_lines = [f"{result.status} {result.message}" for result in results if result.status == "FAIL"] + warn_lines = [f"{result.status} {result.message}" for result in results if result.status == "WARN"] + info_lines = [ + f"{result.status} {result.message}" + for result in results + if result.status == "INFO" and result.message.startswith("discovered _smb._tcp candidates:") + ] + discovery_lines = build_discovery_context(results, debug_fields) + mdns_boot_lines = build_mdns_boot_context(debug_fields) + lines: list[str] = [] + if fail_lines: + lines.append("Doctor failures:") + lines.extend(fail_lines) + if warn_lines: + if lines: + lines.append("") + lines.append("Doctor warnings:") + lines.extend(warn_lines) + if info_lines: + if lines: + lines.append("") + lines.append("Doctor context:") + lines.extend(info_lines) + if discovery_lines: + if lines: + lines.append("") + lines.append("Discovery context:") + lines.extend(discovery_lines) + if mdns_boot_lines: + if lines: + lines.append("") + lines.append("mDNS boot context:") + lines.extend(mdns_boot_lines) + return "\n".join(lines) if lines else None diff --git a/src/timecapsulesmb/services/maintenance.py b/src/timecapsulesmb/services/maintenance.py index 67889e5e..cdc925a4 100644 --- a/src/timecapsulesmb/services/maintenance.py +++ b/src/timecapsulesmb/services/maintenance.py @@ -1,11 +1,14 @@ from __future__ import annotations from dataclasses import dataclass +import shlex from typing import Callable +from timecapsulesmb.device.processes import render_direct_pkill9_by_ucomm, render_direct_pkill9_watchdog from timecapsulesmb.device.storage import MaStVolume +FSCK_REMOTE_COMMAND_TIMEOUT_SECONDS = 3 * 60 * 60 UNINSTALL_REBOOT_NO_DOWN_MESSAGE = ( "Reboot was requested but the device did not go down.\n" "The uninstall removed managed TimeCapsuleSMB files before reboot; power-cycle or rerun uninstall." @@ -117,6 +120,26 @@ def format_fsck_plan(target: FsckTarget, *, reboot: bool, wait: bool) -> str: return "\n".join(lines) +def build_remote_fsck_script(device: str, mountpoint: str, *, reboot: bool) -> str: + lines = [ + render_direct_pkill9_watchdog(), + render_direct_pkill9_by_ucomm("smbd"), + render_direct_pkill9_by_ucomm("afpserver"), + render_direct_pkill9_by_ucomm("wcifsnd"), + render_direct_pkill9_by_ucomm("wcifsfs"), + "sleep 2", + f"/sbin/umount -f {shlex.quote(mountpoint)} >/dev/null 2>&1 || true", + f"echo '--- fsck_hfs {device} ---'", + f"/sbin/fsck_hfs -fy {shlex.quote(device)} 2>&1 || true", + ] + if reboot: + lines.extend([ + "echo '--- reboot ---'", + "/sbin/reboot >/dev/null 2>&1 || true", + ]) + return "\n".join(lines) + + class RepairExecutionContext: def __init__(self, stage_callback: Callable[[str], None]) -> None: self._stage_callback = stage_callback diff --git a/src/timecapsulesmb/services/repair_xattrs.py b/src/timecapsulesmb/services/repair_xattrs.py new file mode 100644 index 00000000..6a9b336c --- /dev/null +++ b/src/timecapsulesmb/services/repair_xattrs.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from timecapsulesmb.core.config import AppConfig +from timecapsulesmb.repair_xattrs import ( + ACTION_CLEAR_ARCH_FLAG, + ACTION_FIX_PERMISSIONS, + RepairCandidate, + RepairFinding, + RepairSummary, + actionable_findings, + build_repair_report, + default_share_path_from_config, + find_findings, + finding_to_candidate, + mounted_smb_shares, + path_exists, + repair_candidate, + unresolved_findings_after_success, + validate_repair_root_under_volumes, +) + + +@dataclass(frozen=True) +class RepairRunResult: + returncode: int + root: Path + findings: list[RepairFinding] + candidates: list[RepairCandidate] + summary: RepairSummary + report: str | None = None + + +def render_candidate_lines(candidates: list[RepairCandidate], *, dry_run: bool) -> list[str]: + verb = "Would repair" if dry_run else "Repairable" + lines: list[str] = [] + for candidate in candidates: + actions = ", ".join(candidate.actions) or "none" + flags = f", flags: {candidate.flags}" if candidate.flags else "" + lines.append(f"{verb}: {candidate.path} ({candidate.path_type}, actions: {actions}{flags})") + return lines + + +def render_diagnostic_lines(findings: list[RepairFinding], *, verbose: bool) -> list[str]: + lines: list[str] = [] + for finding in findings: + if finding.repairable: + continue + if finding.xattr_error or verbose: + detail = f"{finding.kind}: {finding.path} ({finding.path_type})" + if finding.flags: + detail += f" flags={finding.flags}" + if finding.xattr_error: + detail += f" xattr_error={finding.xattr_error}" + lines.append(f"WARN {detail}") + return lines + + +def render_summary_lines(summary: RepairSummary, *, dry_run: bool) -> list[str]: + lines = [ + "", + "Summary:", + f" scanned paths: {summary.scanned}", + f" scanned files: {summary.scanned_files}", + f" scanned directories: {summary.scanned_dirs}", + f" skipped: {summary.skipped}", + f" unreadable xattrs: {summary.unreadable}", + f" not repairable: {summary.not_repairable}", + f" repairable: {summary.repairable}", + f" permission repairs: {summary.permission_repairable}", + ] + if not dry_run: + lines.extend([ + f" repaired: {summary.repaired}", + f" failed: {summary.failed}", + ]) + return lines + + +def _emit_lines(emit: Callable[[str], None], lines: list[str]) -> None: + for line in lines: + emit(line) + + +def run_repair_structured( + args: argparse.Namespace, + command_context, + config: AppConfig, + *, + emit_log: Callable[[str], None] | None = None, + confirm: Callable[[str], bool] | None = None, +) -> RepairRunResult: + def emit(message: str) -> None: + if emit_log is not None: + emit_log(message) + + command_context.set_stage("resolve_scan_root") + command_context.update_fields( + dry_run=args.dry_run, + recursive=args.recursive, + max_depth=args.max_depth, + include_hidden=args.include_hidden, + include_time_machine=args.include_time_machine, + fix_permissions=args.fix_permissions, + explicit_path=args.path is not None, + ) + if args.path is None: + try: + root = default_share_path_from_config( + config, + shares=mounted_smb_shares(), + path_exists_func=path_exists, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + else: + root = args.path + if root is None: + raise SystemExit("Could not determine mounted share path. Pass --path explicitly.") + try: + root = validate_repair_root_under_volumes(root) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + + summary = RepairSummary() + command_context.update_fields(repair_root=str(root)) + command_context.set_stage("scan_findings") + emit(f"Scanning {root}") + try: + findings = find_findings( + root, + recursive=args.recursive, + max_depth=args.max_depth, + include_hidden=args.include_hidden, + include_time_machine=args.include_time_machine, + include_directories=True, + include_root_directory=True, + fix_permissions=args.fix_permissions, + summary=summary, + ) + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc + repairs = actionable_findings(findings) + candidates = [finding_to_candidate(finding) for finding in repairs] + command_context.update_fields( + scanned_paths=summary.scanned, + scanned_files=summary.scanned_files, + scanned_dirs=summary.scanned_dirs, + skipped_paths=summary.skipped, + unreadable_xattrs=summary.unreadable, + finding_count=len(findings), + repairable_count=len(candidates), + permission_repairable=summary.permission_repairable, + ) + + if not findings: + emit("No repairable files found.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + command_context.succeed() + return RepairRunResult(0, root, findings, candidates, summary) + + command_context.set_stage("report_findings") + _emit_lines(emit, render_diagnostic_lines(findings, verbose=args.verbose)) + if candidates: + _emit_lines(emit, render_candidate_lines(candidates, dry_run=args.dry_run)) + + if args.dry_run: + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + emit("No changes made.") + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) + + if not candidates: + emit("No known-safe repairs are available for the detected issues.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) + + command_context.set_stage("confirm_repair") + if not args.yes and not (confirm is not None and confirm(f"Repair {len(candidates)} paths with known-safe fixes?")): + emit("No changes made.") + _emit_lines(emit, render_summary_lines(summary, dry_run=True)) + report = build_repair_report(findings) + command_context.fail_with_error(report) + return RepairRunResult(0, root, findings, candidates, summary, report=report) + + command_context.set_stage("repair_findings") + failed_findings: list[RepairFinding] = [] + for finding, candidate in zip(repairs, candidates): + emit(f"Repairing: {candidate.path}") + if repair_candidate(candidate): + summary.repaired += 1 + if ACTION_CLEAR_ARCH_FLAG in candidate.actions: + emit(f"PASS xattr now readable: {candidate.path}") + if ACTION_FIX_PERMISSIONS in candidate.actions: + emit(f"PASS permissions repaired: {candidate.path}") + else: + summary.failed += 1 + failed_findings.append(finding) + if ACTION_CLEAR_ARCH_FLAG in candidate.actions: + emit(f"FAIL repair did not make xattr readable: {candidate.path}") + else: + emit(f"FAIL repair did not fix detected issue: {candidate.path}") + + unresolved = unresolved_findings_after_success(findings) + failed_findings + command_context.update_fields(repaired_count=summary.repaired, repair_failed_count=summary.failed) + _emit_lines(emit, render_summary_lines(summary, dry_run=False)) + if unresolved: + report = build_repair_report(findings, failed=unresolved) + command_context.fail_with_error(report) + return RepairRunResult(1, root, findings, candidates, summary, report=report) + command_context.succeed() + return RepairRunResult(0, root, findings, candidates, summary) diff --git a/src/timecapsulesmb/services/runtime.py b/src/timecapsulesmb/services/runtime.py new file mode 100644 index 00000000..4eedb638 --- /dev/null +++ b/src/timecapsulesmb/services/runtime.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import time +from typing import Callable + +from timecapsulesmb.core.config import DEFAULTS, AppConfig, ConfigError, load_app_config, require_valid_app_config +from timecapsulesmb.core.net import extract_host, ipv4_literal, is_link_local_ipv4, resolve_host_ipv4s +from timecapsulesmb.core.paths import resolve_app_paths +from timecapsulesmb.device.compat import require_compatibility +from timecapsulesmb.device.probe import ( + ProbedDeviceState, + RemoteInterfaceProbeResult, + probe_connection_state, + probe_remote_interface_conn, +) +from timecapsulesmb.transport.ssh import SshConnection, ssh_opts_use_proxy +from timecapsulesmb.transport.local import tcp_open + + +@dataclass(frozen=True) +class ManagedTargetState: + connection: SshConnection + interface_probe: RemoteInterfaceProbeResult | None + probe_state: ProbedDeviceState | None + + +def load_env_config( + *, + env_path: Path | None = None, + defaults: dict[str, str] | None = None, + resolve_paths=resolve_app_paths, +) -> AppConfig: + resolved_path = resolve_paths(config_path=env_path).config_path + return load_app_config(resolved_path, defaults=defaults) + + +def load_optional_env_config( + *, + env_path: Path | None = None, + defaults: dict[str, str] | None = None, + resolve_paths=resolve_app_paths, +) -> AppConfig: + try: + resolved_path = resolve_paths(config_path=env_path).config_path + except Exception: + return AppConfig.missing(path=env_path or Path.cwd() / ".env") + if not resolved_path.exists(): + return AppConfig.missing(path=resolved_path) + try: + return load_app_config(resolved_path, defaults=defaults) + except OSError: + return AppConfig.missing(path=resolved_path) + + +def resolve_ssh_credentials( + config: AppConfig, + *, + allow_empty_password: bool = False, +) -> tuple[str, str]: + host = config.require("TC_HOST") + password = config.get("TC_PASSWORD") + if not password and not allow_empty_password: + import getpass + password = getpass.getpass("Device root password: ") + return host, password + + +def resolve_env_connection( + config: AppConfig, + *, + required_keys: tuple[str, ...] = (), + allow_empty_password: bool = False, +) -> SshConnection: + for key in required_keys: + config.require(key) + host, password = resolve_ssh_credentials(config, allow_empty_password=allow_empty_password) + return SshConnection(host=host, password=password, ssh_opts=config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) + + +def inspect_managed_connection( + connection: SshConnection, + iface: str, + *, + include_probe: bool = False, +) -> ManagedTargetState: + interface_probe = probe_remote_interface_conn(connection, iface) + probe_state = probe_connection_state(connection) if include_probe else None + return ManagedTargetState(connection=connection, interface_probe=interface_probe, probe_state=probe_state) + + +def ssh_target_link_local_resolution_error( + target: str, + ssh_opts: str, + *, + field_name: str = "Device SSH target", +) -> str | None: + if ssh_opts_use_proxy(ssh_opts): + return None + host = extract_host(target).strip() + if not host or ipv4_literal(host) is not None: + return None + link_local_ips = tuple(ip for ip in resolve_host_ipv4s(host) if is_link_local_ipv4(ip)) + if not link_local_ips: + return None + noun = "address" if len(link_local_ips) == 1 else "addresses" + return ( + f"{field_name} host {host} resolves to 169.254.x.x link-local IPv4 {noun} " + f"{', '.join(link_local_ips)}. Use the device's LAN IP or a hostname that resolves " + "to its LAN IP; 169.254.x.x is only suitable for temporary SSH recovery." + ) + + +def resolve_validated_managed_target( + config: AppConfig, + *, + command_name: str, + profile: str, + include_probe: bool = False, +) -> ManagedTargetState: + require_valid_app_config(config, profile=profile, command_name=command_name) + resolution_error = ssh_target_link_local_resolution_error( + config.require("TC_HOST"), + config.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"]), + field_name="TC_HOST", + ) + if resolution_error is not None: + raise ConfigError(resolution_error) + connection = resolve_env_connection(config) + if profile == "flash": + return ManagedTargetState(connection=connection, interface_probe=None, probe_state=None) + probe_state = probe_connection_state(connection) if include_probe else None + return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) + + +def require_connection_compatibility(connection: SshConnection): + state = probe_connection_state(connection) + return require_compatibility( + state.compatibility, + fallback_error=state.probe_result.error or "Failed to determine remote device OS compatibility.", + ) + + +def wait_for_tcp_port_state( + host: str, + port: int, + *, + expected_state: bool, + timeout_seconds: int = 120, + interval_seconds: int = 5, + log: Callable[[str], None] | None = None, + service_name: str | None = None, +) -> bool: + label = service_name or f"TCP port {port}" + expected_state_string = "open" if expected_state else "closed" + if log is not None: + log(f"Waiting for {label} to be {expected_state_string}...") + deadline = time.time() + timeout_seconds + while True: + is_open = tcp_open(host, port) + if is_open == expected_state: + if log is not None: + log(f"{label} is {expected_state_string}.") + return True + if time.time() >= deadline: + break + time.sleep(interval_seconds) + if log is not None: + log(f"{label} did not become {expected_state_string} within {timeout_seconds}s.") + return False diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 4721f083..37aab352 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -20,13 +20,13 @@ from timecapsulesmb.app.events import AppEvent, EventSink from timecapsulesmb import repair_xattrs as repair_xattrs_domain -from timecapsulesmb.app import contracts, helper, operations, service +from timecapsulesmb.app import contracts, helper, service from timecapsulesmb.cli import main as cli_main from timecapsulesmb.checks.models import CheckResult -from timecapsulesmb.core.config import AppConfig, ConfigError, parse_env_file +from timecapsulesmb.core.config import MANAGED_PAYLOAD_DIR_NAME, AppConfig, ConfigError, parse_env_file from timecapsulesmb.device.compat import DeviceCompatibility from timecapsulesmb.device.probe import ProbeResult, ProbedDeviceState -from timecapsulesmb.device.storage import MaStVolume +from timecapsulesmb.device.storage import MaStVolume, build_dry_run_payload_home from timecapsulesmb.discovery.bonjour import BonjourDiscoverySnapshot, BonjourResolvedService, BonjourServiceInstance from timecapsulesmb.integrations.acp import ACPAuthError from timecapsulesmb.services.app import jsonable @@ -247,6 +247,19 @@ def test_request_id_propagates_to_every_event(self) -> None: self.assertEqual({event["request_id"] for event in collector.events}, {"req-123"}) self.assert_single_terminal_event(collector, "result") + def test_capabilities_returns_helper_contract_details(self) -> None: + collector = CollectingSink() + + rc = service.run_api_request({"operation": "capabilities", "params": {}}, collector.sink) + + self.assertEqual(rc, 0) + payload = self.assert_single_terminal_event(collector, "result")["payload"] + self.assertEqual(payload["api_schema_version"], 1) + self.assertIn("deploy", payload["operations"]) + self.assertIn("capabilities", payload["operations"]) + self.assertIn("helper_version", payload) + self.assertIn("artifact_manifest_sha256", payload) + def test_missing_params_defaults_to_empty_object(self) -> None: collector = CollectingSink() @@ -339,7 +352,7 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: ], ) - with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot): + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot): rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) self.assertEqual(rc, 0) @@ -354,7 +367,7 @@ 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.operations.discover_snapshot") as discover: + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot") as discover: rc = service.run_api_request( {"operation": "discover", "params": {"timeout": timeout}}, collector.sink, @@ -370,7 +383,7 @@ def test_discover_accepts_numeric_timeout_string(self) -> None: collector = CollectingSink() snapshot = BonjourDiscoverySnapshot(instances=[], resolved=[]) - with mock.patch("timecapsulesmb.app.operations.discover_snapshot", return_value=snapshot) as discover: + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot) as discover: rc = service.run_api_request( {"operation": "discover", "params": {"timeout": "0.25"}}, collector.sink, @@ -379,11 +392,11 @@ def test_discover_accepts_numeric_timeout_string(self) -> None: self.assertEqual(rc, 0) discover.assert_called_once_with(timeout=0.25) - def test_configure_writes_env_without_leaking_password_to_events(self) -> None: + 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.operations.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -398,11 +411,36 @@ def test_configure_writes_env_without_leaking_password_to_events(self) -> None: self.assertEqual(rc, 0) self.assertIn("TC_HOST=root@10.0.0.2", config_path.read_text()) - self.assertIn("TC_PASSWORD=goodpw", 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_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: @@ -415,7 +453,7 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s "TC_SAMBA_USER=old-admin\n" "TC_PAYLOAD_DIR_NAME=old-payload\n" ) - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -432,7 +470,7 @@ def test_configure_preserves_custom_env_keys_and_drops_deprecated_runtime_keys(s self.assertEqual(rc, 0) self.assertEqual(values["TC_HOST"], "root@10.0.0.2") - self.assertEqual(values["TC_PASSWORD"], "newpw") + self.assertEqual(values["TC_PASSWORD"], "") self.assertEqual(values["TC_CUSTOM_SETTING"], "keep me") self.assertEqual(values["TC_DEBUG_LOGGING"], "true") self.assertNotIn("TC_SAMBA_USER", values) @@ -442,7 +480,7 @@ 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.operations.probe_connection_state", return_value=probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=probed_state()): rc = service.run_api_request( { "operation": "configure", @@ -465,8 +503,8 @@ def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): - with mock.patch("timecapsulesmb.app.operations.enable_ssh", side_effect=ACPAuthError("bad password")): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh", side_effect=ACPAuthError("bad password")): rc = service.run_api_request( { "operation": "configure", @@ -493,7 +531,7 @@ def test_configure_reports_unsupported_device(self) -> None: ) with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unsupported_state): + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unsupported_state): rc = service.run_api_request( { "operation": "configure", @@ -514,8 +552,8 @@ def test_configure_rejects_boolean_ssh_wait_timeout(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: config_path = Path(tmp) / ".env" - with mock.patch("timecapsulesmb.app.operations.probe_connection_state", return_value=unreachable_probed_state()): - with mock.patch("timecapsulesmb.app.operations.enable_ssh") as enable_ssh: + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", return_value=unreachable_probed_state()): + with mock.patch("timecapsulesmb.app.ops.configure.enable_ssh") as enable_ssh: rc = service.run_api_request( { "operation": "configure", @@ -544,10 +582,10 @@ 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.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", side_effect=fake_run_doctor_checks): rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) self.assertEqual(rc, 0) @@ -560,10 +598,10 @@ def test_doctor_passes_bonjour_timeout_to_checks(self) -> None: collector = CollectingSink() config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", return_value=([], False)) as checks: + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", return_value=([], False)) as checks: rc = service.run_api_request( {"operation": "doctor", "params": {"bonjour_timeout": "2.75"}}, collector.sink, @@ -580,10 +618,10 @@ 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.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): - with mock.patch("timecapsulesmb.app.operations.run_doctor_checks", side_effect=fake_run_doctor_checks): + with mock.patch("timecapsulesmb.app.ops.doctor.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.doctor.resolve_env_connection", return_value=SshConnection("root@10.0.0.2", "pw", "-o foo")): + with mock.patch("timecapsulesmb.app.ops.doctor.run_doctor_checks", side_effect=fake_run_doctor_checks): rc = service.run_api_request({"operation": "doctor", "params": {}}, collector.sink) self.assertEqual(rc, 1) @@ -603,12 +641,12 @@ def test_deploy_dry_run_returns_structured_plan_without_remote_actions(self) -> "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions", side_effect=AssertionError("dry run should not run remote actions")): rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": True, "yes": True}}, collector.sink, @@ -632,12 +670,12 @@ def test_deploy_requires_reboot_confirmation_before_remote_actions(self) -> None "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions") as remote_actions: rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, collector.sink, @@ -657,13 +695,13 @@ def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(s "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns-netbsd4be/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn") as read_mast: - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn") as read_mast: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions") as remote_actions: rc = service.run_api_request( {"operation": "deploy", "params": {"dry_run": False, "confirm_deploy": True}}, collector.sink, @@ -676,22 +714,84 @@ def test_deploy_requires_netbsd4_activation_confirmation_before_remote_actions(s 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.operations.load_env_config") as load_config: - rc = service.run_api_request( - {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, - collector.sink, - ) + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn") as read_mast: + rc = service.run_api_request( + {"operation": "deploy", "params": {"dry_run": False, "no_reboot": True}}, + collector.sink, + ) self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") self.assertEqual(error["code"], "confirmation_required") - load_config.assert_not_called() + self.assertEqual(error["details"]["action_title"], "Deploy") + self.assertIn("confirmation_id", error["details"]) + read_mast.assert_not_called() + + def test_deploy_accepts_backend_confirmation_id_before_remote_writes(self) -> None: + first = CollectingSink() + second = CollectingSink() + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + target = SimpleNamespace(connection=connection, probe_state=probed_state()) + artifacts = { + "smbd": SimpleNamespace(absolute_path=REPO_ROOT / "bin/samba4/smbd"), + "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), + "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), + } + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + base_params = {"dry_run": False, "no_reboot": True, "mount_wait": 30} + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + rc = service.run_api_request( + {"operation": "deploy", "params": dict(base_params)}, + first.sink, + ) + + self.assertEqual(rc, 1) + confirmation_id = first.events_of_type("error")[0]["details"]["confirmation_id"] + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + confirmed = dict(base_params) + confirmed["confirmation_id"] = confirmation_id + rc = service.run_api_request( + {"operation": "deploy", "params": confirmed}, + second.sink, + ) + + self.assertEqual(rc, 0) + upload.assert_called_once() + self.assertEqual(second.events_of_type("error"), []) def test_deploy_rejects_boolean_mount_wait_before_remote_connection(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config") as load_config: rc = service.run_api_request( { "operation": "deploy", @@ -718,20 +818,20 @@ def test_deploy_no_reboot_uploads_and_skips_reboot_wait(self) -> None: "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) - - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): - with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): - with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): - with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload") as upload: - with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): - with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload") as upload: + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: rc = service.run_api_request( { "operation": "deploy", @@ -758,21 +858,21 @@ def test_deploy_no_wait_requests_reboot_without_wait_or_runtime_verify(self) -> "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) - - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): - with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): - with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): - with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): - with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot") as reboot: - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: rc = service.run_api_request( { @@ -805,21 +905,21 @@ def test_deploy_no_wait_reports_reboot_request_failure(self) -> None: "mdns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/mdns/mdns-advertiser"), "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - payload_home = operations.build_dry_run_payload_home(operations.MANAGED_PAYLOAD_DIR_NAME) - - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): - with mock.patch("timecapsulesmb.app.operations.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): - with mock.patch("timecapsulesmb.app.operations.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): - with mock.patch("timecapsulesmb.app.operations.upload_deployment_payload"): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions"): - with mock.patch("timecapsulesmb.app.operations.flush_remote_filesystem_writes"): + payload_home = build_dry_run_payload_home(MANAGED_PAYLOAD_DIR_NAME) + + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=("dk2",), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.select_payload_home_with_diagnostics_conn", return_value=SimpleNamespace(payload_home=payload_home)): + with mock.patch("timecapsulesmb.app.ops.deploy.verify_payload_home_conn", return_value=SimpleNamespace(ok=True, detail="ok")): + with mock.patch("timecapsulesmb.app.ops.deploy.upload_deployment_payload"): + with mock.patch("timecapsulesmb.app.ops.deploy.run_remote_actions"): + with mock.patch("timecapsulesmb.app.ops.deploy.flush_remote_filesystem_writes"): with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_shutdown_reboot", side_effect=SshError("ssh command failed with rc=255")) as reboot: - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_ssh_state_conn") as wait: with mock.patch("timecapsulesmb.app.ops.deploy.verify_managed_runtime") as verify_runtime: rc = service.run_api_request( { @@ -853,12 +953,12 @@ def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: "nbns-advertiser": SimpleNamespace(absolute_path=REPO_ROOT / "bin/nbns/nbns-advertiser"), } - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): - with mock.patch("timecapsulesmb.app.operations.validate_artifacts", return_value=[("smbd", True, "ok")]): - with mock.patch("timecapsulesmb.app.operations.resolve_payload_artifacts", return_value=artifacts): - with mock.patch("timecapsulesmb.app.operations.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_app_paths", return_value=SimpleNamespace(distribution_root=REPO_ROOT)): + with mock.patch("timecapsulesmb.app.ops.deploy.validate_artifacts", return_value=[("smbd", True, "ok")]): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_payload_artifacts", return_value=artifacts): + with mock.patch("timecapsulesmb.app.ops.deploy.wait_for_mast_volumes_conn", return_value=SimpleNamespace(volumes=(), attempts=1, raw_output="")): rc = service.run_api_request( { "operation": "deploy", @@ -887,9 +987,9 @@ def test_activate_requires_explicit_confirmation(self) -> None: ), ) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: rc = service.run_api_request({"operation": "activate", "params": {}}, collector.sink) self.assertEqual(rc, 1) @@ -901,10 +1001,10 @@ def test_activate_accepts_yes_alias_for_confirmation(self) -> None: connection = SshConnection("root@10.0.0.2", "pw", "-o foo") target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.operations.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.operations.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): - with mock.patch("timecapsulesmb.app.operations.run_remote_actions") as remote_actions: + with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.maintenance.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: rc = service.run_api_request( {"operation": "activate", "params": {"yes": True}}, collector.sink, @@ -921,37 +1021,42 @@ 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.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: - with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload") as uninstall: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection") as resolve_connection: + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload") as uninstall: rc = service.run_api_request({"operation": "uninstall", "params": {}}, collector.sink) self.assertEqual(rc, 1) self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") - resolve_connection.assert_not_called() + resolve_connection.assert_called_once() uninstall.assert_not_called() def test_uninstall_requires_reboot_confirmation_before_remote_connection(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: - rc = service.run_api_request( - {"operation": "uninstall", "params": {"confirm_uninstall": True}}, - collector.sink, - ) + config = AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"}) + connection = SshConnection("root@10.0.0.2", "pw", "-o foo") + + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn") as read_mast: + rc = service.run_api_request( + {"operation": "uninstall", "params": {"confirm_uninstall": True}}, + collector.sink, + ) self.assertEqual(rc, 1) self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") - load_config.assert_not_called() + 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.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload") as uninstall: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload") as uninstall: rc = service.run_api_request( {"operation": "uninstall", "params": {"dry_run": True}}, collector.sink, @@ -970,13 +1075,13 @@ def test_uninstall_no_wait_uses_mount_wait_and_skips_post_reboot_verification(se connection = SshConnection("root@10.0.0.2", "pw", "-o foo") mounted = [SimpleNamespace(volume_root="/Volumes/dk2")] - with mock.patch("timecapsulesmb.app.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): - with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: - with mock.patch("timecapsulesmb.app.operations.remote_uninstall_payload"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.ops.maintenance.remote_uninstall_payload"): with mock.patch("timecapsulesmb.app.ops.deploy.remote_request_reboot") as reboot: - with mock.patch("timecapsulesmb.app.operations.wait_for_ssh_state_conn") as wait: + with mock.patch("timecapsulesmb.app.ops.maintenance.wait_for_ssh_state_conn") as wait: with mock.patch("timecapsulesmb.app.ops.maintenance.verify_post_uninstall") as verify: rc = service.run_api_request( { @@ -1005,8 +1110,8 @@ 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.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection") as resolve_connection: + 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) @@ -1017,7 +1122,7 @@ def test_fsck_rejects_non_integer_mount_wait_before_remote_connection(self) -> N for value in (12.5, True): with self.subTest(value=value): collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.load_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config") as load_config: rc = service.run_api_request( { "operation": "fsck", @@ -1038,11 +1143,11 @@ def test_fsck_list_volumes_returns_targets_without_confirmation_or_remote_fsck(s 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.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): - with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: - with mock.patch("timecapsulesmb.app.operations.run_ssh") as run_ssh: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted) as mounted_mock: + with mock.patch("timecapsulesmb.app.ops.maintenance.run_ssh") as run_ssh: rc = service.run_api_request( { "operation": "fsck", @@ -1064,11 +1169,11 @@ def test_fsck_dry_run_returns_plan_without_remote_fsck(self) -> None: 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.operations.load_env_config", return_value=config): - with mock.patch("timecapsulesmb.app.operations.resolve_env_connection", return_value=connection): - with mock.patch("timecapsulesmb.app.operations.read_mast_volumes_conn", return_value=[]): - with mock.patch("timecapsulesmb.app.operations.mounted_mast_volumes_conn", return_value=mounted): - with mock.patch("timecapsulesmb.app.operations.run_ssh") as run_ssh: + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=config): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_env_connection", return_value=connection): + with mock.patch("timecapsulesmb.app.ops.maintenance.read_mast_volumes_conn", return_value=[]): + with mock.patch("timecapsulesmb.app.ops.maintenance.mounted_mast_volumes_conn", return_value=mounted): + with mock.patch("timecapsulesmb.app.ops.maintenance.run_ssh") as run_ssh: rc = service.run_api_request( { "operation": "fsck", @@ -1095,9 +1200,9 @@ def test_repair_xattrs_uses_structured_runner(self) -> None: report="detected issues", ) - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", return_value=repair_result) as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1132,9 +1237,9 @@ def fake_runner(*_args, **_kwargs): print("stderr detail", file=sys.stderr) return repair_result - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", side_effect=fake_runner): + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", side_effect=fake_runner): rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1160,9 +1265,9 @@ def test_repair_xattrs_rejects_invalid_path_before_runner(self) -> None: collector = CollectingSink() params = {"dry_run": True} params.update(extra_params) - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config") as load_config: - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1183,9 +1288,9 @@ 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.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1216,9 +1321,9 @@ def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: report=None, ) - with mock.patch("timecapsulesmb.app.operations.sys.platform", "darwin"): - with mock.patch("timecapsulesmb.app.operations.load_optional_env_config", return_value=AppConfig.missing()): - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured", return_value=repair_result) as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "darwin"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config", return_value=AppConfig.missing()): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured", return_value=repair_result) as runner: rc = service.run_api_request( { "operation": "repair-xattrs", @@ -1238,7 +1343,7 @@ def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.operations.repair_xattrs_cli.run_repair_structured") as runner: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: rc = service.run_api_request( { "operation": "repair-xattrs", From 8dfb061abb6921a7165989a517e2cb33723c38cb Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 15:33:54 -0700 Subject: [PATCH 13/20] Fix app API typing and repair confirmation ordering --- src/timecapsulesmb/app/ops/deploy.py | 7 +++-- src/timecapsulesmb/app/ops/maintenance.py | 32 +++++++++++-------- src/timecapsulesmb/services/runtime.py | 4 +-- tests/test_app_api.py | 38 ++++++++++++++++++----- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/timecapsulesmb/app/ops/deploy.py b/src/timecapsulesmb/app/ops/deploy.py index b2428e0a..4b734f03 100644 --- a/src/timecapsulesmb/app/ops/deploy.py +++ b/src/timecapsulesmb/app/ops/deploy.py @@ -43,6 +43,7 @@ verify_managed_runtime, ) from timecapsulesmb.device.compat import ( + DeviceCompatibility, is_netbsd4_payload_family, payload_family_description, render_compatibility_message, @@ -74,14 +75,14 @@ payload_verification_error, render_flash_runtime_config, ) -from timecapsulesmb.services.runtime import load_env_config, resolve_validated_managed_target +from timecapsulesmb.services.runtime import ManagedTargetState, load_env_config, resolve_validated_managed_target from timecapsulesmb.transport.ssh import SshCommandTimeout, SshConnection, SshError ACP_REBOOT_REQUEST_TIMEOUT_SECONDS = 10 -def require_supported_payload(target, *, allow_unsupported: bool) -> object: +def require_supported_payload(target: ManagedTargetState, *, allow_unsupported: bool) -> DeviceCompatibility: probe_state = target.probe_state if probe_state is None: raise AppOperationError("Failed to determine remote device OS compatibility.", code="remote_error") @@ -103,7 +104,7 @@ def load_config_and_target( *, profile: str, include_probe: bool, -) -> tuple[AppConfig, object]: +) -> tuple[AppConfig, ManagedTargetState]: sink.stage(operation, "load_config") config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) sink.stage(operation, "resolve_managed_target") diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index a94d0f72..d0e638d1 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -344,15 +344,15 @@ def observe_reboot_cycle( def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "repair-xattrs" - dry_run = bool_param(params, "dry_run") - sink.stage(operation, "platform_check") - if sys.platform != "darwin": - raise AppOperationError( - "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", - code="validation_failed", - ) sink.stage(operation, "validate_params") + dry_run = bool_param(params, "dry_run") path = required_path_param(params, "path") + recursive = bool_param(params, "recursive", True) + max_depth = optional_int_param(params, "max_depth") + include_hidden = bool_param(params, "include_hidden") + include_time_machine = bool_param(params, "include_time_machine") + fix_permissions = bool_param(params, "fix_permissions") + verbose = bool_param(params, "verbose") if not dry_run: require_confirmation( params, @@ -368,17 +368,23 @@ def repair_xattrs_operation(params: dict[str, object], sink: EventSink) -> Opera ), legacy_names=("confirm_repair",), ) + sink.stage(operation, "platform_check") + if sys.platform != "darwin": + raise AppOperationError( + "repair-xattrs must be run on macOS because it uses xattr/chflags on the mounted SMB share.", + code="validation_failed", + ) config = load_optional_env_config(env_path=config_path(params)) args = argparse.Namespace( path=path, dry_run=dry_run, yes=not dry_run, - recursive=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"), + recursive=recursive, + max_depth=max_depth, + include_hidden=include_hidden, + include_time_machine=include_time_machine, + fix_permissions=fix_permissions, + verbose=verbose, ) context = RepairExecutionContext(lambda stage: sink.stage(operation, stage)) stdout_capture = LineLogCapture(lambda message: sink.log(operation, message, level="info")) diff --git a/src/timecapsulesmb/services/runtime.py b/src/timecapsulesmb/services/runtime.py index 4eedb638..0e72a52e 100644 --- a/src/timecapsulesmb/services/runtime.py +++ b/src/timecapsulesmb/services/runtime.py @@ -8,7 +8,7 @@ from timecapsulesmb.core.config import DEFAULTS, AppConfig, ConfigError, load_app_config, require_valid_app_config from timecapsulesmb.core.net import extract_host, ipv4_literal, is_link_local_ipv4, resolve_host_ipv4s from timecapsulesmb.core.paths import resolve_app_paths -from timecapsulesmb.device.compat import require_compatibility +from timecapsulesmb.device.compat import DeviceCompatibility, require_compatibility from timecapsulesmb.device.probe import ( ProbedDeviceState, RemoteInterfaceProbeResult, @@ -134,7 +134,7 @@ def resolve_validated_managed_target( return ManagedTargetState(connection=connection, interface_probe=None, probe_state=probe_state) -def require_connection_compatibility(connection: SshConnection): +def require_connection_compatibility(connection: SshConnection) -> DeviceCompatibility: state = probe_connection_state(connection) return require_compatibility( state.compatibility, diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 4ee123bd..3ae79c96 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -1358,14 +1358,15 @@ def test_repair_xattrs_passes_valid_max_depth_as_int(self) -> None: def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: collector = CollectingSink() - with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: - rc = service.run_api_request( - { - "operation": "repair-xattrs", - "params": {"path": "/Volumes/Data", "dry_run": False}, - }, - collector.sink, - ) + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "linux"): + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False}, + }, + collector.sink, + ) self.assertEqual(rc, 1) error = self.assert_single_terminal_event(collector, "error") @@ -1373,6 +1374,27 @@ def test_repair_xattrs_requires_confirmation_for_non_dry_run(self) -> None: self.assertEqual(error["recovery"]["title"], "Repair confirmation required") runner.assert_not_called() + def test_repair_xattrs_checks_platform_after_confirmation(self) -> None: + collector = CollectingSink() + + with mock.patch("timecapsulesmb.app.ops.maintenance.sys.platform", "linux"): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_optional_env_config") as load_config: + with mock.patch("timecapsulesmb.app.ops.maintenance.repair_xattrs_service.run_repair_structured") as runner: + rc = service.run_api_request( + { + "operation": "repair-xattrs", + "params": {"path": "/Volumes/Data", "dry_run": False, "confirm_repair": True}, + }, + collector.sink, + ) + + self.assertEqual(rc, 1) + error = self.assert_single_terminal_event(collector, "error") + self.assertEqual(error["code"], "validation_failed") + self.assertEqual(error["recovery"]["title"], "repair-xattrs requires macOS") + load_config.assert_not_called() + runner.assert_not_called() + def test_helper_reads_request_and_writes_ndjson(self) -> None: output = io.StringIO() fake_stdin = io.StringIO('{"operation":"paths","params":{}}') From ae1d5c65cdbed257c648f04d5de2b7c64a6ffb39 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 16:52:10 -0700 Subject: [PATCH 14/20] Implement structured GUI workflows --- .../BackendPayloadDecoding.swift | 45 ++ .../TimeCapsuleSMBApp/BackendPayloads.swift | 452 ++++++++++++++++++ .../TimeCapsuleSMBApp/BackendViewModels.swift | 55 +++ .../TimeCapsuleSMBApp/ConnectView.swift | 209 ++++++++ .../ConnectionWorkflowStore.swift | 355 ++++++++++++++ .../TimeCapsuleSMBApp/ContentView.swift | 145 ++---- .../TimeCapsuleSMBApp/DeployView.swift | 201 ++++++++ .../DeployWorkflowStore.swift | 320 +++++++++++++ .../TimeCapsuleSMBApp/DoctorStore.swift | 250 ++++++++++ .../TimeCapsuleSMBApp/DoctorView.swift | 150 ++++++ .../TimeCapsuleSMBApp/OperationParams.swift | 28 +- .../TimeCapsuleSMBApp/ReadinessStore.swift | 216 +++++++++ .../TimeCapsuleSMBApp/ReadinessView.swift | 198 ++++++++ .../BackendPayloadTests.swift | 250 ++++++++++ .../ConnectionWorkflowStoreTests.swift | 426 +++++++++++++++++ .../DeployWorkflowStoreTests.swift | 273 +++++++++++ .../DoctorStoreTests.swift | 207 ++++++++ .../PendingConfirmationTests.swift | 21 + .../ReadinessStoreTests.swift | 190 ++++++++ .../StoreTestSupport.swift | 84 ++++ 20 files changed, 3962 insertions(+), 113 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift new file mode 100644 index 00000000..bdac20fc --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloadDecoding.swift @@ -0,0 +1,45 @@ +import Foundation + +enum BackendContractError: Error, Equatable, LocalizedError { + case missingPayload(operation: String) + case payloadDecodeFailed(operation: String, payloadType: String, message: String) + + var errorDescription: String? { + switch self { + case .missingPayload(let operation): + return "\(operation) result did not include a payload." + case .payloadDecodeFailed(let operation, let payloadType, let message): + return "\(operation) payload could not be decoded as \(payloadType): \(message)" + } + } +} + +extension JSONValue { + func decode(_ type: T.Type = T.self) throws -> T { + let data = try JSONEncoder().encode(self) + return try JSONDecoder().decode(T.self, from: data) + } +} + +extension BackendEvent { + func decodePayload(_ type: T.Type = T.self) throws -> T { + guard let payload else { + throw BackendContractError.missingPayload(operation: operation) + } + do { + return try payload.decode(type) + } catch let error as DecodingError { + throw BackendContractError.payloadDecodeFailed( + operation: operation, + payloadType: String(describing: type), + message: error.localizedDescription + ) + } catch { + throw BackendContractError.payloadDecodeFailed( + operation: operation, + payloadType: String(describing: type), + message: error.localizedDescription + ) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift new file mode 100644 index 00000000..1619c309 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift @@ -0,0 +1,452 @@ +import Foundation + +struct CapabilitiesPayload: Decodable, Equatable { + let schemaVersion: Int + let apiSchemaVersion: Int + let helperVersion: String + let helperVersionCode: Int + let operations: [String] + let distributionRoot: String + let artifactManifestSHA256: String? + let confirmationSchemaVersion: Int + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case apiSchemaVersion = "api_schema_version" + case helperVersion = "helper_version" + case helperVersionCode = "helper_version_code" + case operations + case distributionRoot = "distribution_root" + case artifactManifestSHA256 = "artifact_manifest_sha256" + case confirmationSchemaVersion = "confirmation_schema_version" + case summary + } +} + +struct PathsPayload: Decodable, Equatable { + let schemaVersion: Int + let distributionRoot: String + let configPath: String + let stateDir: String + let packageRoot: String + let artifactManifest: String + let artifacts: [ArtifactPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case distributionRoot = "distribution_root" + case configPath = "config_path" + case stateDir = "state_dir" + case packageRoot = "package_root" + case artifactManifest = "artifact_manifest" + case artifacts + case counts + case summary + } +} + +struct ArtifactPayload: Decodable, Equatable { + let name: String + let repoRelativePath: String + let absolutePath: String + let sha256: String + let ok: Bool + let message: String + + enum CodingKeys: String, CodingKey { + case name + case repoRelativePath = "repo_relative_path" + case absolutePath = "absolute_path" + case sha256 + case ok + case message + } +} + +struct InstallValidationPayload: Decodable, Equatable { + let schemaVersion: Int + let ok: Bool + let checks: [InstallCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case ok + case checks + case counts + case summary + } +} + +struct InstallCheckPayload: Decodable, Equatable { + let id: String + let ok: Bool + let message: String + let details: JSONValue? +} + +struct DiscoverPayload: Decodable, Equatable { + let schemaVersion: Int + let instances: [BonjourServiceInstancePayload] + let resolved: [BonjourResolvedServicePayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case instances + case resolved + case counts + case summary + } +} + +struct BonjourServiceInstancePayload: Decodable, Equatable { + let serviceType: String + let name: String + let fullname: String + + enum CodingKeys: String, CodingKey { + case serviceType = "service_type" + case name + case fullname + } +} + +struct BonjourResolvedServicePayload: Decodable, Equatable { + let name: String + let hostname: String + let serviceType: String + let port: Int + let ipv4: [String] + let ipv6: [String] + let services: [String] + let properties: [String: String] + let fullname: String + + enum CodingKeys: String, CodingKey { + case name + case hostname + case serviceType = "service_type" + case port + case ipv4 + case ipv6 + case services + case properties + case fullname + } + + init( + name: String, + hostname: String, + serviceType: String = "", + port: Int = 0, + ipv4: [String] = [], + ipv6: [String] = [], + services: [String] = [], + properties: [String: String] = [:], + fullname: String = "" + ) { + self.name = name + self.hostname = hostname + self.serviceType = serviceType + self.port = port + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.services = services + self.properties = properties + self.fullname = fullname + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" + self.serviceType = try container.decodeIfPresent(String.self, forKey: .serviceType) ?? "" + self.port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 0 + self.ipv4 = try container.decodeIfPresent([String].self, forKey: .ipv4) ?? [] + self.ipv6 = try container.decodeIfPresent([String].self, forKey: .ipv6) ?? [] + self.services = try container.decodeIfPresent([String].self, forKey: .services) ?? [] + self.properties = try container.decodeIfPresent([String: String].self, forKey: .properties) ?? [:] + self.fullname = try container.decodeIfPresent(String.self, forKey: .fullname) ?? "" + } + + var jsonValue: JSONValue { + .object([ + "name": .string(name), + "hostname": .string(hostname), + "service_type": .string(serviceType), + "port": .number(Double(port)), + "ipv4": .array(ipv4.map(JSONValue.string)), + "ipv6": .array(ipv6.map(JSONValue.string)), + "services": .array(services.map(JSONValue.string)), + "properties": .object(properties.mapValues(JSONValue.string)), + "fullname": .string(fullname) + ]) + } +} + +struct ConfigurePayload: Decodable, Equatable { + let schemaVersion: Int + let configPath: String + let host: String + let configureId: String + let sshAuthenticated: Bool + let deviceSyap: String? + let deviceModel: String? + let compatibility: DeviceCompatibilityPayload? + let device: DevicePayload? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case configPath = "config_path" + case host + case configureId = "configure_id" + case sshAuthenticated = "ssh_authenticated" + case deviceSyap = "device_syap" + case deviceModel = "device_model" + case compatibility + case device + case summary + } +} + +struct DevicePayload: Decodable, Equatable { + let host: String? + let syap: String? + let model: String? +} + +struct DeviceCompatibilityPayload: Decodable, Equatable { + let osName: String? + let osRelease: String? + let arch: String? + let elfEndianness: String? + let payloadFamily: String? + let deviceGeneration: String? + let supported: Bool? + let reasonCode: String? + let reasonDetail: String? + let syapCandidates: [String] + let modelCandidates: [String] + + enum CodingKeys: String, CodingKey { + case osName = "os_name" + case osRelease = "os_release" + case arch + case elfEndianness = "elf_endianness" + case payloadFamily = "payload_family" + case deviceGeneration = "device_generation" + case supported + case reasonCode = "reason_code" + case reasonDetail = "reason_detail" + case syapCandidates = "syap_candidates" + case modelCandidates = "model_candidates" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.osName = try container.decodeIfPresent(String.self, forKey: .osName) + self.osRelease = try container.decodeIfPresent(String.self, forKey: .osRelease) + self.arch = try container.decodeIfPresent(String.self, forKey: .arch) + self.elfEndianness = try container.decodeIfPresent(String.self, forKey: .elfEndianness) + self.payloadFamily = try container.decodeIfPresent(String.self, forKey: .payloadFamily) + self.deviceGeneration = try container.decodeIfPresent(String.self, forKey: .deviceGeneration) + self.supported = try container.decodeIfPresent(Bool.self, forKey: .supported) + self.reasonCode = try container.decodeIfPresent(String.self, forKey: .reasonCode) + self.reasonDetail = try container.decodeIfPresent(String.self, forKey: .reasonDetail) + self.syapCandidates = try container.decodeIfPresent([String].self, forKey: .syapCandidates) ?? [] + self.modelCandidates = try container.decodeIfPresent([String].self, forKey: .modelCandidates) ?? [] + } +} + +struct DeployPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let host: String + let volumeRoot: String? + let payloadDir: String + let payloadFamily: String? + let netbsd4: Bool + let requiresReboot: Bool + let rebootRequired: Bool? + let uploads: [JSONValue] + let preUploadActions: [JSONValue] + let postUploadActions: [JSONValue] + let activationActions: [JSONValue] + let postDeployChecks: [PlannedCheckPayload] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case host + case volumeRoot = "volume_root" + case payloadDir = "payload_dir" + case payloadFamily = "payload_family" + case netbsd4 + case requiresReboot = "requires_reboot" + case rebootRequired = "reboot_required" + case uploads + case preUploadActions = "pre_upload_actions" + case postUploadActions = "post_upload_actions" + case activationActions = "activation_actions" + case postDeployChecks = "post_deploy_checks" + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.host = try container.decode(String.self, forKey: .host) + self.volumeRoot = try container.decodeIfPresent(String.self, forKey: .volumeRoot) + self.payloadDir = try container.decode(String.self, forKey: .payloadDir) + self.payloadFamily = try container.decodeIfPresent(String.self, forKey: .payloadFamily) + self.netbsd4 = try container.decode(Bool.self, forKey: .netbsd4) + self.requiresReboot = try container.decode(Bool.self, forKey: .requiresReboot) + self.rebootRequired = try container.decodeIfPresent(Bool.self, forKey: .rebootRequired) + self.uploads = try container.decodeIfPresent([JSONValue].self, forKey: .uploads) ?? [] + self.preUploadActions = try container.decodeIfPresent([JSONValue].self, forKey: .preUploadActions) ?? [] + self.postUploadActions = try container.decodeIfPresent([JSONValue].self, forKey: .postUploadActions) ?? [] + self.activationActions = try container.decodeIfPresent([JSONValue].self, forKey: .activationActions) ?? [] + self.postDeployChecks = try container.decodeIfPresent([PlannedCheckPayload].self, forKey: .postDeployChecks) ?? [] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct DeployResultPayload: Decodable, Equatable { + let schemaVersion: Int + let payloadDir: String + let netbsd4: Bool + let payloadFamily: String? + let requiresReboot: Bool + let rebooted: Bool? + let rebootRequested: Bool? + let waited: Bool? + let verified: Bool? + let message: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case payloadDir = "payload_dir" + case netbsd4 + case payloadFamily = "payload_family" + case requiresReboot = "requires_reboot" + case rebooted + case rebootRequested = "reboot_requested" + case waited + case verified + case message + case summary + } +} + +struct DoctorPayload: Decodable, Equatable { + let schemaVersion: Int + let fatal: Bool + let results: [DoctorCheckPayload] + let counts: [String: Int] + let error: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case fatal + case results + case counts + case error + case summary + } +} + +struct DoctorCheckPayload: Decodable, Equatable { + let status: String + let message: String + let details: JSONValue + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.status = try container.decode(String.self, forKey: .status) + self.message = try container.decode(String.self, forKey: .message) + self.details = try container.decodeIfPresent(JSONValue.self, forKey: .details) ?? .object([:]) + } + + enum CodingKeys: String, CodingKey { + case status + case message + case details + } +} + +struct FsckVolumeListPayload: Decodable, Equatable { + let schemaVersion: Int + let targets: [FsckTargetPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case targets + case counts + case summary + } +} + +struct FsckTargetPayload: Decodable, Equatable { + let label: String? + let device: String + let mountpoint: String +} + +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 retryable: Bool + let suggestedOperation: String? + let docsAnchor: String? + + enum CodingKeys: String, CodingKey { + case title + case message + case actions + case retryable + case suggestedOperation = "suggested_operation" + case docsAnchor = "docs_anchor" + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift new file mode 100644 index 00000000..61037c41 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendViewModels.swift @@ -0,0 +1,55 @@ +import Foundation + +struct OperationStageState: Equatable { + let operation: String + let stage: String + let risk: String? + let cancellable: Bool? + let description: String? + + init?(event: BackendEvent) { + guard event.type == "stage", let stage = event.stage else { + return nil + } + self.operation = event.operation + self.stage = stage + self.risk = event.risk + self.cancellable = event.cancellable + self.description = event.description + } +} + +struct BackendErrorViewModel: Equatable { + let operation: String + let code: String + let message: String + let recovery: BackendRecoveryPayload? + + init(event: BackendEvent) { + self.operation = event.operation + self.code = event.code ?? "operation_failed" + self.message = event.message ?? event.summary + self.recovery = try? event.recovery?.decode(BackendRecoveryPayload.self) + } + + init(operation: String, code: String, message: String, recovery: BackendRecoveryPayload? = nil) { + self.operation = operation + self.code = code + self.message = message + self.recovery = recovery + } +} + +extension BackendEvent { + var payloadSummaryText: String? { + guard let payload else { + return nil + } + for key in ["summary", "message", "summary_text"] { + if let value = payload.stringValue(for: key) { + return value + } + } + return nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift new file mode 100644 index 00000000..a9c99c64 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift @@ -0,0 +1,209 @@ +import SwiftUI + +struct ConnectView: View { + @ObservedObject var store: ConnectionWorkflowStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("panel.connect")) + .font(.title2.weight(.semibold)) + + HStack { + TextField(L10n.string("field.host"), text: $store.manualHost) + SecureField(L10n.string("field.password"), text: $password) + TextField(L10n.string("field.bonjour_timeout"), text: $store.bonjourTimeout) + .frame(width: 180) + } + + Toggle(L10n.string("toggle.enable_debug_logging"), isOn: $store.debugLogging) + + HStack { + Button { + store.runDiscover() + } label: { + Label(L10n.string("button.discover"), systemImage: "network") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + + Button { + store.runConfigure(password: password) + } label: { + Label(L10n.string("button.configure"), systemImage: "lock.open") + } + .disabled(!store.canConfigure(password: password)) + + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if !store.devices.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(store.devices) { device in + Button { + store.select(device) + } label: { + DeviceRow( + device: device, + selected: store.selectedDeviceID == device.id + ) + } + .buttonStyle(.plain) + } + } + } + + if let configuredDevice = store.configuredDevice { + ConfiguredDeviceView(device: configuredDevice) + } + + if let error = store.error { + ErrorRecoveryView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusIcon: String { + switch store.state { + case .idle: + return "circle" + case .discovering, .configuring: + return "hourglass" + case .discoveryReady, .configured: + return "checkmark.circle" + case .discoveryEmpty: + return "magnifyingglass" + case .discoveryFailed, .configureFailed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .discoveryReady, .configured: + return .green + case .discoveryFailed, .configureFailed: + return .red + default: + return .secondary + } + } +} + +private struct DeviceRow: View { + let device: DiscoveredDevice + let selected: Bool + + var body: some View { + HStack(spacing: 10) { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.body.weight(.medium)) + HStack(spacing: 8) { + if !device.host.isEmpty { + Text(device.host) + } + if !device.hostname.isEmpty { + Text(device.hostname) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if let model = device.model { + Text(model) + .font(.caption) + .foregroundStyle(.secondary) + } else if let syap = device.syap { + Text("syAP \(syap)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(selected ? Color.accentColor.opacity(0.12) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +private struct ConfiguredDeviceView: View { + let device: ConfiguredDeviceState + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Configured Host") + .foregroundStyle(.secondary) + Text(device.host) + } + GridRow { + Text("Config") + .foregroundStyle(.secondary) + Text(device.configPath) + .lineLimit(1) + .truncationMode(.middle) + } + if let model = device.model { + GridRow { + Text("Model") + .foregroundStyle(.secondary) + Text(model) + } + } + if let syap = device.syap { + GridRow { + Text("syAP") + .foregroundStyle(.secondary) + Text(syap) + } + } + if let compatibility = device.compatibility { + GridRow { + Text("Payload") + .foregroundStyle(.secondary) + Text(compatibility.payloadFamily ?? "unknown") + } + } + } + .font(.caption) + } +} + +private struct ErrorRecoveryView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift new file mode 100644 index 00000000..821d98e9 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift @@ -0,0 +1,355 @@ +import Combine +import Foundation + +enum ConnectionWorkflowState: String, CaseIterable, Equatable { + case idle + case discovering + case discoveryReady + case discoveryEmpty + case discoveryFailed + case configuring + case configured + case configureFailed + + var title: String { + switch self { + case .idle: + return "Idle" + case .discovering: + return "Discovering" + case .discoveryReady: + return "Devices Found" + case .discoveryEmpty: + return "No Devices Found" + case .discoveryFailed: + return "Discovery Failed" + case .configuring: + return "Configuring" + case .configured: + return "Configured" + case .configureFailed: + return "Configure Failed" + } + } +} + +struct DiscoveredDevice: Identifiable, Equatable { + let id: String + let name: String + let host: String + let hostname: String + let addresses: [String] + let syap: String? + let model: String? + let rawRecord: JSONValue + + init(record: BonjourResolvedServicePayload, index: Int) { + let stableParts = [ + record.fullname, + record.serviceType, + record.name, + record.hostname, + record.ipv4.joined(separator: ","), + record.ipv6.joined(separator: ",") + ] + let stableID = stableParts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "|") + + self.id = stableID.isEmpty ? "discovered-\(index)" : stableID + self.name = record.name.isEmpty ? (record.hostname.isEmpty ? "AirPort Device" : record.hostname) : record.name + self.hostname = record.hostname + self.addresses = record.ipv4 + record.ipv6 + self.host = Self.displayHost(record) + self.syap = record.properties["syAP"] ?? record.properties["syap"] + self.model = record.properties["model"] ?? record.properties["am"] + self.rawRecord = record.jsonValue + } + + private static func displayHost(_ record: BonjourResolvedServicePayload) -> String { + if let address = record.ipv4.first ?? record.ipv6.first { + return address + } + return record.hostname + } +} + +struct ConfiguredDeviceState: Equatable { + let host: String + let configPath: String + let configureId: String + let sshAuthenticated: Bool + let syap: String? + let model: String? + let compatibility: DeviceCompatibilityPayload? + + init(payload: ConfigurePayload) { + self.host = payload.host + self.configPath = payload.configPath + self.configureId = payload.configureId + self.sshAuthenticated = payload.sshAuthenticated + self.syap = payload.deviceSyap ?? payload.device?.syap + self.model = payload.deviceModel ?? payload.device?.model + self.compatibility = payload.compatibility + } +} + +@MainActor +final class ConnectionWorkflowStore: ObservableObject { + @Published var manualHost = "" + @Published var bonjourTimeout = "6" + @Published var debugLogging = false + @Published private(set) var state: ConnectionWorkflowState = .idle + @Published private(set) var devices: [DiscoveredDevice] = [] + @Published var selectedDeviceID: DiscoveredDevice.ID? + @Published private(set) var configuredDevice: ConfiguredDeviceState? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let backend: BackendClient + + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + var selectedDevice: DiscoveredDevice? { + guard let selectedDeviceID else { + return nil + } + return devices.first { $0.id == selectedDeviceID } + } + + func canConfigure(password: String) -> Bool { + !backend.isRunning + && !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (selectedDevice != nil || !manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + func runDiscover() { + guard let timeout = bonjourTimeoutValue else { + failLocally(operation: "discover", state: .discoveryFailed, message: "Bonjour timeout must be a non-negative number.") + return + } + resetRunState(clearDevices: true, clearConfiguredDevice: true) + state = .discovering + backend.run(operation: "discover", params: OperationParams.discover(timeout: timeout)) + } + + func runConfigure(password: String) { + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPassword.isEmpty else { + failLocally(operation: "configure", state: .configureFailed, message: "Password is required.") + return + } + let selectedDevice = selectedDevice + let trimmedHost = manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard selectedDevice != nil || !trimmedHost.isEmpty else { + failLocally(operation: "configure", state: .configureFailed, message: "Choose a discovered device or enter a host.") + return + } + + resetRunState(clearDevices: false, clearConfiguredDevice: true) + state = .configuring + let params = OperationParams.configure( + host: trimmedHost, + selectedRecord: selectedDevice?.rawRecord, + password: password, + debugLogging: debugLogging + ) + backend.run(operation: "configure", params: params) + } + + func select(_ device: DiscoveredDevice) { + selectedDeviceID = device.id + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + state = .idle + devices = [] + selectedDeviceID = nil + configuredDevice = nil + error = nil + currentStage = nil + } + + func cancel() { + backend.cancel() + } + + private func resetRunState(clearDevices: Bool, clearConfiguredDevice: Bool) { + backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + if clearDevices { + devices = [] + selectedDeviceID = nil + } + if clearConfiguredDevice { + configuredDevice = nil + } + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "discover" || event.operation == "configure" else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + + if event.ok == false { + applyFailureResult(event) + return + } + + switch event.operation { + case "discover": + applyDiscoverResult(event) + case "configure": + applyConfigureResult(event) + default: + break + } + } + + private func applyDiscoverResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(DiscoverPayload.self) + let discoveredDevices = payload.resolved.enumerated().map { index, record in + DiscoveredDevice(record: record, index: index) + } + devices = discoveredDevices + selectedDeviceID = discoveredDevices.count == 1 ? discoveredDevices[0].id : nil + error = nil + state = discoveredDevices.isEmpty ? .discoveryEmpty : .discoveryReady + } catch { + failContract(operation: "discover", state: .discoveryFailed, error: error) + } + } + + private func applyConfigureResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(ConfigurePayload.self) + configuredDevice = ConfiguredDeviceState(payload: payload) + error = nil + state = .configured + } catch { + failContract(operation: "configure", state: .configureFailed, error: error) + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + switch event.operation { + case "discover": + state = .discoveryFailed + case "configure": + state = .configureFailed + default: + break + } + } + + private func applyFailureResult(_ event: BackendEvent) { + let message = event.payloadSummaryText ?? event.summary + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: message + ) + switch event.operation { + case "discover": + state = .discoveryFailed + case "configure": + state = .configureFailed + default: + break + } + } + + private func failContract(operation: String, state: ConnectionWorkflowState, error: Error) { + self.error = BackendErrorViewModel( + operation: operation, + code: "contract_decode_failed", + message: error.localizedDescription + ) + self.state = state + } + + private func failLocally(operation: String, state: ConnectionWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: operation, + code: "validation_failed", + message: message + ) + currentStage = nil + self.state = state + } + + private func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 060871cd..f95e3fb3 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -1,22 +1,28 @@ import SwiftUI public struct ContentView: View { - @StateObject private var backend = BackendClient() + @StateObject private var backend: BackendClient + @StateObject private var readinessStore: ReadinessStore + @StateObject private var connectionStore: ConnectionWorkflowStore + @StateObject private var deployStore: DeployWorkflowStore + @StateObject private var doctorStore: DoctorStore @State private var selection: Screen = .readiness - @State private var host = "root@192.168.x.x" @State private var password = "" @State private var repairPath = "" @State private var volume = "" - @State private var nbnsEnabled = true @State private var noReboot = false - @State private var dryRun = true - @State private var configureDebugLogging = false - @State private var deployDebugLogging = false @State private var mountWait = "30" - @State private var bonjourTimeout = "6" @State private var noWait = false - public init() {} + @MainActor + public init() { + let backend = BackendClient() + _backend = StateObject(wrappedValue: backend) + _readinessStore = StateObject(wrappedValue: ReadinessStore(backend: backend)) + _connectionStore = StateObject(wrappedValue: ConnectionWorkflowStore(backend: backend)) + _deployStore = StateObject(wrappedValue: DeployWorkflowStore(backend: backend)) + _doctorStore = StateObject(wrappedValue: DoctorStore(backend: backend)) + } public var body: some View { NavigationSplitView { @@ -34,7 +40,7 @@ public struct ContentView: View { .toolbar { ToolbarItemGroup { Button { - backend.clear() + clearActive() } label: { Label(L10n.string("toolbar.clear"), systemImage: "trash") } @@ -80,96 +86,13 @@ public struct ContentView: View { private var form: some View { switch selection { case .readiness: - CommandPanel(title: L10n.string("screen.readiness")) { - TextField(L10n.string("field.helper"), text: $backend.helperPath) - HStack { - runButton(L10n.string("button.capabilities"), icon: "info.circle", operation: "capabilities") - runButton(L10n.string("button.paths"), icon: "folder", operation: "paths") - runButton(L10n.string("button.validate"), icon: "checkmark.seal", operation: "validate-install") - } - } + ReadinessView(store: readinessStore, helperPath: $backend.helperPath) case .connect: - CommandPanel(title: L10n.string("panel.connect")) { - TextField(L10n.string("field.host"), text: $host) - SecureField(L10n.string("field.password"), text: $password) - TextField(L10n.string("field.bonjour_timeout"), text: $bonjourTimeout) - Toggle(L10n.string("toggle.enable_debug_logging"), isOn: $configureDebugLogging) - HStack { - runButton( - L10n.string("button.discover"), - icon: "network", - operation: "discover", - params: OperationParams.discover(timeout: bonjourTimeoutValue ?? 6), - disabled: bonjourTimeoutValue == nil - ) - Button { - backend.run( - operation: "configure", - params: OperationParams.configure( - host: host, - password: password, - debugLogging: configureDebugLogging - ) - ) - } label: { - Label(L10n.string("button.configure"), systemImage: "lock.open") - } - .disabled(backend.isRunning || password.isEmpty) - } - } + ConnectView(store: connectionStore, password: $password) case .deploy: - CommandPanel(title: L10n.string("screen.deploy")) { - Toggle(L10n.string("toggle.enable_nbns"), isOn: $nbnsEnabled) - Toggle(L10n.string("toggle.no_reboot"), isOn: $noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) - Toggle(L10n.string("toggle.dry_run"), isOn: $dryRun) - Toggle(L10n.string("toggle.force_debug_logging"), isOn: $deployDebugLogging) - TextField(L10n.string("field.mount_wait"), text: $mountWait) - Button { - if dryRun { - backend.run( - operation: "deploy", - params: OperationParams.deployPlan( - noReboot: noReboot, - noWait: noWait, - nbnsEnabled: nbnsEnabled, - debugLogging: deployDebugLogging, - mountWait: mountWaitValue ?? 30, - password: password - ) - ) - } else { - backend.run( - operation: "deploy", - params: OperationParams.deployRun( - noReboot: noReboot, - noWait: noWait, - nbnsEnabled: nbnsEnabled, - debugLogging: deployDebugLogging, - mountWait: mountWaitValue ?? 30, - password: password - ) - ) - } - } label: { - Label( - dryRun ? L10n.string("button.plan_deploy") : L10n.string("button.deploy"), - systemImage: dryRun ? "doc.text.magnifyingglass" : "square.and.arrow.up" - ) - } - .disabled(backend.isRunning || mountWaitValue == nil) - } + DeployView(store: deployStore, password: $password) case .doctor: - CommandPanel(title: L10n.string("screen.doctor")) { - TextField(L10n.string("field.bonjour_timeout"), text: $bonjourTimeout) - runButton( - L10n.string("button.run_doctor"), - icon: "stethoscope", - operation: "doctor", - params: OperationParams.doctor(bonjourTimeout: bonjourTimeoutValue ?? 6, password: password), - disabled: bonjourTimeoutValue == nil - ) - } + DoctorView(store: doctorStore, password: $password) case .maintenance: CommandPanel(title: L10n.string("screen.maintenance")) { TextField(L10n.string("field.repair_xattrs_path"), text: $repairPath) @@ -294,24 +217,28 @@ public struct ContentView: View { .disabled(backend.isRunning || disabled) } - private var mountWaitValue: Double? { - nonNegativeIntegerDouble(mountWait) - } - - private var bonjourTimeoutValue: Double? { - nonNegativeDouble(bonjourTimeout) + private func clearActive() { + switch selection { + case .readiness: + readinessStore.clear() + case .connect: + connectionStore.clear() + case .deploy: + deployStore.clear() + case .doctor: + doctorStore.clear() + default: + backend.clear() + } } - private func nonNegativeDouble(_ text: String) -> Double? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = Double(trimmed), value.isFinite, value >= 0 else { - return nil - } - return value + private var mountWaitValue: Double? { + nonNegativeIntegerDouble(mountWait) } private func nonNegativeIntegerDouble(_ text: String) -> Double? { - guard let value = nonNegativeDouble(text), value.rounded(.towardZero) == value else { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0, value.rounded(.towardZero) == value else { return nil } return value diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift new file mode 100644 index 00000000..c625e06c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployView.swift @@ -0,0 +1,201 @@ +import SwiftUI + +struct DeployView: View { + @ObservedObject var store: DeployWorkflowStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.deploy")) + .font(.title2.weight(.semibold)) + + HStack { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $store.nbnsEnabled) + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $store.debugLogging) + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + } + + HStack { + Button { + store.runPlan(password: password) + } label: { + Label(L10n.string("button.plan_deploy"), systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + + Button { + store.runDeploy(password: password) + } label: { + Label(L10n.string("button.deploy"), systemImage: "square.and.arrow.up") + } + .disabled(!store.canDeploy) + + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let plan = store.plan { + DeployPlanSummaryView(plan: plan, stale: store.state == .planStale) + } + + if let result = store.result { + DeployResultSummaryView(result: result) + } + + if let error = store.error { + DeployErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusIcon: String { + switch store.state { + case .idle: + return "circle" + case .planning, .deploying: + return "hourglass" + case .planReady, .deployed: + return "checkmark.circle" + case .planStale, .awaitingConfirmation: + return "exclamationmark.circle" + case .planFailed, .deployFailed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .planReady, .deployed: + return .green + case .planStale, .awaitingConfirmation: + return .orange + case .planFailed, .deployFailed: + return .red + default: + return .secondary + } + } +} + +private struct DeployPlanSummaryView: View { + let plan: DeployPlanPayload + let stale: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(stale ? "Deploy Plan Stale" : "Deploy Plan") + .font(.body.weight(.medium)) + .foregroundStyle(stale ? .orange : .primary) + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Host").foregroundStyle(.secondary) + Text(plan.host) + } + GridRow { + Text("Payload").foregroundStyle(.secondary) + Text(plan.payloadFamily ?? "unknown") + } + GridRow { + Text("NetBSD4").foregroundStyle(.secondary) + Text(plan.netbsd4 ? "yes" : "no") + } + GridRow { + Text("Reboot").foregroundStyle(.secondary) + Text(plan.requiresReboot ? "required" : "not required") + } + GridRow { + Text("Payload Dir").foregroundStyle(.secondary) + Text(plan.payloadDir) + .lineLimit(1) + .truncationMode(.middle) + } + GridRow { + Text("Actions").foregroundStyle(.secondary) + Text("\(plan.preUploadActions.count) pre, \(plan.uploads.count) uploads, \(plan.postUploadActions.count) post, \(plan.activationActions.count) activation") + } + } + if !plan.postDeployChecks.isEmpty { + Text("Post-deploy checks: \(plan.postDeployChecks.map(\.description).joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .font(.caption) + } +} + +private struct DeployResultSummaryView: View { + let result: DeployResultPayload + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Deploy Result") + .font(.body.weight(.medium)) + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Payload Dir").foregroundStyle(.secondary) + Text(result.payloadDir) + .lineLimit(1) + .truncationMode(.middle) + } + GridRow { + Text("Reboot Requested").foregroundStyle(.secondary) + Text(result.rebootRequested == true ? "yes" : "no") + } + GridRow { + Text("Waited").foregroundStyle(.secondary) + Text(result.waited == true ? "yes" : "no") + } + GridRow { + Text("Verified").foregroundStyle(.secondary) + Text(result.verified == true ? "yes" : "no") + } + } + if let message = result.message { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .font(.caption) + } +} + +private struct DeployErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift new file mode 100644 index 00000000..0ca6f177 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift @@ -0,0 +1,320 @@ +import Combine +import Foundation + +struct DeployOptions: Equatable { + let nbnsEnabled: Bool + let noReboot: Bool + let noWait: Bool + let debugLogging: Bool + let mountWait: Int +} + +enum DeployWorkflowState: String, CaseIterable, Equatable { + case idle + case planning + case planReady + case planStale + case planFailed + case deploying + case awaitingConfirmation + case deployed + case deployFailed + + var title: String { + switch self { + case .idle: + return "Idle" + case .planning: + return "Planning" + case .planReady: + return "Plan Ready" + case .planStale: + return "Plan Stale" + case .planFailed: + return "Plan Failed" + case .deploying: + return "Deploying" + case .awaitingConfirmation: + return "Awaiting Confirmation" + case .deployed: + return "Deployed" + case .deployFailed: + return "Deploy Failed" + } + } +} + +@MainActor +final class DeployWorkflowStore: ObservableObject { + @Published var nbnsEnabled = true { + didSet { markPlanStaleIfNeeded() } + } + @Published var noReboot = false { + didSet { markPlanStaleIfNeeded() } + } + @Published var noWait = false { + didSet { markPlanStaleIfNeeded() } + } + @Published var debugLogging = false { + didSet { markPlanStaleIfNeeded() } + } + @Published var mountWait = "30" { + didSet { markPlanStaleIfNeeded() } + } + + @Published private(set) var state: DeployWorkflowState = .idle + @Published private(set) var plan: DeployPlanPayload? + @Published private(set) var result: DeployResultPayload? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var plannedOptions: DeployOptions? + + let backend: BackendClient + + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var mountWaitValue: Int? { + nonNegativeInteger(mountWait) + } + + var canDeploy: Bool { + !backend.isRunning && state == .planReady && plan != nil && currentOptions == plannedOptions + } + + func runPlan(password: String) { + guard let options = currentOptions else { + failLocally(state: .planFailed, message: "Mount wait must be a non-negative integer.") + return + } + backend.clear() + lastProcessedEventCount = 0 + state = .planning + plan = nil + result = nil + error = nil + currentStage = nil + plannedOptions = options + backend.run( + operation: "deploy", + params: OperationParams.deployPlan( + noReboot: options.noReboot, + noWait: options.noWait, + nbnsEnabled: options.nbnsEnabled, + debugLogging: options.debugLogging, + mountWait: Double(options.mountWait), + password: password + ) + ) + } + + func runDeploy(password: String) { + guard let options = plannedOptions, plan != nil, currentOptions == options else { + state = .planStale + error = BackendErrorViewModel( + operation: "deploy", + code: "plan_stale", + message: "Review and regenerate the deploy plan before deploying." + ) + return + } + guard state == .planReady else { + return + } + backend.clear() + lastProcessedEventCount = 0 + state = .deploying + result = nil + error = nil + currentStage = nil + backend.run( + operation: "deploy", + params: OperationParams.deployRun( + noReboot: options.noReboot, + noWait: options.noWait, + nbnsEnabled: options.nbnsEnabled, + debugLogging: options.debugLogging, + mountWait: Double(options.mountWait), + password: password + ) + ) + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + state = .idle + plan = nil + result = nil + error = nil + currentStage = nil + plannedOptions = nil + } + + func cancel() { + backend.cancel() + } + + private var currentOptions: DeployOptions? { + guard let mountWaitValue else { + return nil + } + return DeployOptions( + nbnsEnabled: nbnsEnabled, + noReboot: noReboot, + noWait: noWait, + debugLogging: debugLogging, + mountWait: mountWaitValue + ) + } + + private func markPlanStaleIfNeeded() { + guard state == .planReady, currentOptions != plannedOptions else { + return + } + state = .planStale + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "deploy" else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if state == .awaitingConfirmation { + state = .deploying + } + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + if event.ok == false { + applyFailureResult(event) + return + } + + switch state { + case .planning: + applyPlanResult(event) + case .deploying, .awaitingConfirmation: + applyDeployResult(event) + default: + break + } + } + + private func applyPlanResult(_ event: BackendEvent) { + do { + plan = try event.decodePayload(DeployPlanPayload.self) + result = nil + error = nil + state = .planReady + } catch { + failContract(state: .planFailed, error: error) + } + } + + private func applyDeployResult(_ event: BackendEvent) { + do { + result = try event.decodePayload(DeployResultPayload.self) + error = nil + state = .deployed + } catch { + failContract(state: .deployFailed, error: error) + } + } + + private func applyError(_ event: BackendEvent) { + if event.code == "confirmation_required" { + error = nil + state = .awaitingConfirmation + return + } + error = BackendErrorViewModel(event: event) + state = state == .planning ? .planFailed : .deployFailed + } + + private func applyFailureResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: "deploy", + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + state = state == .planning ? .planFailed : .deployFailed + } + + private func failContract(state: DeployWorkflowState, error: Error) { + self.error = BackendErrorViewModel( + operation: "deploy", + code: "contract_decode_failed", + message: error.localizedDescription + ) + self.state = state + } + + private func failLocally(state: DeployWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: "deploy", + code: "validation_failed", + message: message + ) + currentStage = nil + self.state = state + } + + private func nonNegativeInteger(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), value >= 0 else { + return nil + } + return value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift new file mode 100644 index 00000000..22800849 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift @@ -0,0 +1,250 @@ +import Combine +import Foundation + +struct DoctorOptions: Equatable { + let bonjourTimeout: Double + let skipSSH: Bool + let skipBonjour: Bool + let skipSMB: Bool +} + +enum DoctorWorkflowState: String, CaseIterable, Equatable { + case idle + case running + case passed + case warning + case failed + case runFailed + + var title: String { + switch self { + case .idle: + return "Idle" + case .running: + return "Running" + case .passed: + return "Passed" + case .warning: + return "Warning" + case .failed: + return "Failed" + case .runFailed: + return "Run Failed" + } + } +} + +struct DoctorCheckGroup: Identifiable, Equatable { + let domain: String + let checks: [DoctorCheckPayload] + + var id: String { + domain + } +} + +struct DoctorSummary: Equatable { + let passCount: Int + let warnCount: Int + let failCount: Int + let infoCount: Int + let groups: [DoctorCheckGroup] + + init(payload: DoctorPayload) { + self.passCount = Self.count(status: "PASS", in: payload) + self.warnCount = Self.count(status: "WARN", in: payload) + self.failCount = Self.count(status: "FAIL", in: payload) + self.infoCount = Self.count(status: "INFO", in: payload) + self.groups = Self.group(payload.results) + } + + private static func count(status: String, in payload: DoctorPayload) -> Int { + payload.counts[status] ?? payload.results.filter { $0.status == status }.count + } + + private static func group(_ checks: [DoctorCheckPayload]) -> [DoctorCheckGroup] { + let grouped = Dictionary(grouping: checks) { check in + check.details.stringValue(for: "domain") ?? "General" + } + return grouped + .map { DoctorCheckGroup(domain: $0.key, checks: $0.value) } + .sorted { left, right in + severityRank(left.checks) == severityRank(right.checks) + ? left.domain < right.domain + : severityRank(left.checks) < severityRank(right.checks) + } + } + + private static func severityRank(_ checks: [DoctorCheckPayload]) -> Int { + if checks.contains(where: { $0.status == "FAIL" }) { + return 0 + } + if checks.contains(where: { $0.status == "WARN" }) { + return 1 + } + return 2 + } +} + +@MainActor +final class DoctorStore: ObservableObject { + @Published var bonjourTimeout = "6" + @Published var skipSSH = false + @Published var skipBonjour = false + @Published var skipSMB = false + @Published private(set) var state: DoctorWorkflowState = .idle + @Published private(set) var payload: DoctorPayload? + @Published private(set) var summary: DoctorSummary? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let backend: BackendClient + + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + func runDoctor(password: String) { + guard let timeout = bonjourTimeoutValue else { + failLocally(message: "Bonjour timeout must be a non-negative number.") + return + } + backend.clear() + lastProcessedEventCount = 0 + state = .running + payload = nil + summary = nil + error = nil + currentStage = nil + backend.run( + operation: "doctor", + params: OperationParams.doctor( + bonjourTimeout: timeout, + password: password, + skipSSH: skipSSH, + skipBonjour: skipBonjour, + skipSMB: skipSMB + ) + ) + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + state = .idle + payload = nil + summary = nil + error = nil + currentStage = nil + } + + func cancel() { + backend.cancel() + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "doctor" else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + error = BackendErrorViewModel(event: event) + state = .runFailed + 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 + } + } catch { + self.error = BackendErrorViewModel( + operation: "doctor", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .runFailed + } + } + + private func failLocally(message: String) { + error = BackendErrorViewModel( + operation: "doctor", + code: "validation_failed", + message: message + ) + currentStage = nil + state = .runFailed + } + + private func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift new file mode 100644 index 00000000..b03568c8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +struct DoctorView: View { + @ObservedObject var store: DoctorStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.doctor")) + .font(.title2.weight(.semibold)) + + HStack { + TextField(L10n.string("field.bonjour_timeout"), text: $store.bonjourTimeout) + .frame(width: 180) + Toggle("Skip SSH", isOn: $store.skipSSH) + Toggle("Skip Bonjour", isOn: $store.skipBonjour) + Toggle("Skip SMB", isOn: $store.skipSMB) + } + + HStack { + Button { + store.runDoctor(password: password) + } label: { + Label(L10n.string("button.run_doctor"), systemImage: "stethoscope") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let summary = store.summary { + DoctorSummaryView(summary: summary) + } + + if let error = store.error { + DoctorErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusIcon: String { + switch store.state { + case .idle: + return "circle" + case .running: + return "hourglass" + case .passed: + return "checkmark.circle" + case .warning: + return "exclamationmark.circle" + case .failed, .runFailed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .passed: + return .green + case .warning: + return .orange + case .failed, .runFailed: + return .red + default: + return .secondary + } + } +} + +private struct DoctorSummaryView: View { + let summary: DoctorSummary + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text("PASS \(summary.passCount)").foregroundStyle(.green) + Text("WARN \(summary.warnCount)").foregroundStyle(.orange) + Text("FAIL \(summary.failCount)").foregroundStyle(.red) + Text("INFO \(summary.infoCount)").foregroundStyle(.secondary) + } + .font(.caption.weight(.medium)) + + ForEach(summary.groups) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.domain) + .font(.body.weight(.medium)) + ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in + HStack(alignment: .top) { + Text(check.status) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(color(for: check.status)) + .frame(width: 44, alignment: .leading) + Text(check.message) + .font(.caption) + } + } + } + } + } + } + + private func color(for status: String) -> Color { + switch status { + case "PASS": + return .green + case "WARN": + return .orange + case "FAIL": + return .red + default: + return .secondary + } + } +} + +private struct DoctorErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift index 75023d92..2ece6104 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -15,19 +15,39 @@ enum OperationParams { ["timeout": .number(timeout)] } - static func configure(host: String, password: String, debugLogging: Bool) -> [String: JSONValue] { + static func configure( + host: String = "", + selectedRecord: JSONValue? = nil, + password: String, + debugLogging: Bool + ) -> [String: JSONValue] { var params: [String: JSONValue] = [ - "host": .string(host), "password": .string(password) ] + if let selectedRecord { + params["selected_record"] = selectedRecord + } else { + params["host"] = .string(host) + } if debugLogging { params["debug_logging"] = .bool(true) } return params } - static func doctor(bonjourTimeout: Double, password: String) -> [String: JSONValue] { - withCredentials(["bonjour_timeout": .number(bonjourTimeout)], password: password) + static func doctor( + bonjourTimeout: Double, + password: String, + skipSSH: Bool = false, + skipBonjour: Bool = false, + skipSMB: Bool = false + ) -> [String: JSONValue] { + withCredentials([ + "bonjour_timeout": .number(bonjourTimeout), + "skip_ssh": .bool(skipSSH), + "skip_bonjour": .bool(skipBonjour), + "skip_smb": .bool(skipSMB) + ], password: password) } static func deployPlan( diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift new file mode 100644 index 00000000..41a1f755 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessStore.swift @@ -0,0 +1,216 @@ +import Combine +import Foundation + +enum ReadinessOperationState: String, CaseIterable, Equatable { + case idle + case running + case succeeded + case failed + + var title: String { + switch self { + case .idle: + return "Idle" + case .running: + return "Running" + case .succeeded: + return "Succeeded" + case .failed: + return "Failed" + } + } +} + +@MainActor +final class ReadinessStore: ObservableObject { + @Published private(set) var capabilitiesState: ReadinessOperationState = .idle + @Published private(set) var pathsState: ReadinessOperationState = .idle + @Published private(set) var validationState: ReadinessOperationState = .idle + @Published private(set) var capabilities: CapabilitiesPayload? + @Published private(set) var paths: PathsPayload? + @Published private(set) var validation: InstallValidationPayload? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let backend: BackendClient + + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + func runCapabilities() { + run(operation: "capabilities") + capabilitiesState = .running + } + + func runPaths() { + run(operation: "paths") + pathsState = .running + } + + func runValidateInstall() { + run(operation: "validate-install") + validationState = .running + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + capabilitiesState = .idle + pathsState = .idle + validationState = .idle + capabilities = nil + paths = nil + validation = nil + error = nil + currentStage = nil + } + + func cancel() { + backend.cancel() + } + + private func run(operation: String) { + backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + backend.run(operation: operation) + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard ["capabilities", "paths", "validate-install"].contains(event.operation) else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + + switch event.operation { + case "capabilities": + applyCapabilitiesResult(event) + case "paths": + applyPathsResult(event) + case "validate-install": + applyValidationResult(event) + default: + break + } + } + + private func applyCapabilitiesResult(_ event: BackendEvent) { + do { + capabilities = try event.decodePayload(CapabilitiesPayload.self) + capabilitiesState = event.ok == true ? .succeeded : .failed + error = event.ok == true ? nil : BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + } catch { + failContract(operation: "capabilities", error: error) + } + } + + private func applyPathsResult(_ event: BackendEvent) { + do { + paths = try event.decodePayload(PathsPayload.self) + pathsState = event.ok == true ? .succeeded : .failed + error = event.ok == true ? nil : BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + } catch { + failContract(operation: "paths", error: error) + } + } + + private func applyValidationResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(InstallValidationPayload.self) + validation = payload + validationState = payload.ok ? .succeeded : .failed + error = nil + } catch { + failContract(operation: "validate-install", error: error) + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + setState(.failed, for: event.operation) + } + + private func failContract(operation: String, error: Error) { + self.error = BackendErrorViewModel( + operation: operation, + code: "contract_decode_failed", + message: error.localizedDescription + ) + setState(.failed, for: operation) + } + + private func setState(_ state: ReadinessOperationState, for operation: String) { + switch operation { + case "capabilities": + capabilitiesState = state + case "paths": + pathsState = state + case "validate-install": + validationState = state + default: + break + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift new file mode 100644 index 00000000..b93680f8 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ReadinessView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct ReadinessView: View { + @ObservedObject var store: ReadinessStore + @Binding var helperPath: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.readiness")) + .font(.title2.weight(.semibold)) + + TextField(L10n.string("field.helper"), text: $helperPath) + + HStack { + readinessButton( + L10n.string("button.capabilities"), + icon: "info.circle", + state: store.capabilitiesState, + action: store.runCapabilities + ) + readinessButton( + L10n.string("button.paths"), + icon: "folder", + state: store.pathsState, + action: store.runPaths + ) + readinessButton( + L10n.string("button.validate"), + icon: "checkmark.seal", + state: store.validationState, + action: store.runValidateInstall + ) + } + + if let stage = store.currentStage { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let capabilities = store.capabilities { + CapabilitiesSummaryView(payload: capabilities) + } + + if let paths = store.paths { + PathsSummaryView(payload: paths) + } + + if let validation = store.validation { + ValidationSummaryView(payload: validation) + } + + if let error = store.error { + ReadinessErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func readinessButton( + _ title: String, + icon: String, + state: ReadinessOperationState, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label("\(title) (\(state.title))", systemImage: icon) + } + .disabled(store.isRunning) + } +} + +private struct CapabilitiesSummaryView: View { + let payload: CapabilitiesPayload + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Helper").foregroundStyle(.secondary) + Text("\(payload.helperVersion) (\(payload.helperVersionCode))") + } + GridRow { + Text("API Schema").foregroundStyle(.secondary) + Text(String(payload.apiSchemaVersion)) + } + GridRow { + Text("Confirmations").foregroundStyle(.secondary) + Text(String(payload.confirmationSchemaVersion)) + } + GridRow { + Text("Operations").foregroundStyle(.secondary) + Text(payload.operations.joined(separator: ", ")) + .lineLimit(2) + } + } + .font(.caption) + } +} + +private struct PathsSummaryView: View { + let payload: PathsPayload + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Distribution").foregroundStyle(.secondary) + Text(payload.distributionRoot).lineLimit(1).truncationMode(.middle) + } + GridRow { + Text("Config").foregroundStyle(.secondary) + Text(payload.configPath).lineLimit(1).truncationMode(.middle) + } + GridRow { + Text("State").foregroundStyle(.secondary) + Text(payload.stateDir).lineLimit(1).truncationMode(.middle) + } + } + if !payload.artifacts.isEmpty { + Text("Artifacts") + .font(.body.weight(.medium)) + ForEach(payload.artifacts, id: \.name) { artifact in + HStack { + Image(systemName: artifact.ok ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(artifact.ok ? .green : .red) + Text(artifact.name) + Text(artifact.repoRelativePath) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(artifact.message) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .font(.caption) + } + } + } + .font(.caption) + } +} + +private struct ValidationSummaryView: View { + let payload: InstallValidationPayload + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: payload.ok ? "checkmark.seal" : "xmark.seal") + .foregroundStyle(payload.ok ? .green : .red) + Text(payload.summary) + Text("\(payload.counts["pass"] ?? 0) passed, \(payload.counts["fail"] ?? 0) failed") + .foregroundStyle(.secondary) + } + ForEach(payload.checks, id: \.id) { check in + HStack { + Image(systemName: check.ok ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(check.ok ? .green : .red) + Text(check.id) + Text(check.message) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .font(.caption) + } + } + .font(.caption) + } +} + +private struct ReadinessErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift new file mode 100644 index 00000000..83360e97 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift @@ -0,0 +1,250 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class BackendPayloadTests: XCTestCase { + func testDecodesReadinessPayloads() throws { + let capabilities = try jsonValue(""" + { + "schema_version": 1, + "api_schema_version": 1, + "helper_version": "1.2.3", + "helper_version_code": 123, + "operations": ["discover", "configure"], + "distribution_root": "/repo", + "artifact_manifest_sha256": "abc", + "confirmation_schema_version": 1, + "summary": "helper capabilities resolved." + } + """).decode(CapabilitiesPayload.self) + + XCTAssertEqual(capabilities.helperVersion, "1.2.3") + XCTAssertEqual(capabilities.operations, ["discover", "configure"]) + + let paths = try jsonValue(""" + { + "schema_version": 1, + "distribution_root": "/repo", + "config_path": "/app/.env", + "state_dir": "/app", + "package_root": "/repo/src/timecapsulesmb", + "artifact_manifest": "/repo/src/timecapsulesmb/assets/artifact-manifest.json", + "artifacts": [{ + "name": "smbd", + "repo_relative_path": "bin/samba4/smbd", + "absolute_path": "/repo/bin/samba4/smbd", + "sha256": "hash", + "ok": true, + "message": "ok" + }], + "counts": {"artifacts": 1}, + "summary": "resolved app paths with 1 artifact path(s)." + } + """).decode(PathsPayload.self) + + XCTAssertEqual(paths.artifacts[0].repoRelativePath, "bin/samba4/smbd") + XCTAssertEqual(paths.counts["artifacts"], 1) + + let validation = try jsonValue(""" + { + "schema_version": 1, + "ok": false, + "checks": [{"id": "artifact_hashes", "ok": false, "message": "artifact validation failed", "details": {"failures": ["bad hash"]}}], + "counts": {"checks": 1, "pass": 0, "fail": 1}, + "summary": "install validation failed." + } + """).decode(InstallValidationPayload.self) + + XCTAssertFalse(validation.ok) + XCTAssertEqual(validation.checks[0].details, .object(["failures": .array([.string("bad hash")])])) + } + + func testDecodesDiscoveryAndConfigurePayloads() throws { + let discovery = try jsonValue(""" + { + "schema_version": 1, + "instances": [{"service_type": "_airport._tcp.local.", "name": "TC", "fullname": "TC._airport._tcp.local."}], + "resolved": [{ + "name": "TC", + "hostname": "tc.local.", + "service_type": "_airport._tcp.local.", + "port": 5009, + "ipv4": ["10.0.0.2"], + "ipv6": [], + "services": ["_airport._tcp.local."], + "properties": {"syAP": "119", "model": "Time Capsule"}, + "fullname": "TC._airport._tcp.local." + }], + "counts": {"instances": 1, "resolved": 1}, + "summary": "discovered 1 resolved AirPort service(s)." + } + """).decode(DiscoverPayload.self) + + XCTAssertEqual(discovery.resolved[0].name, "TC") + XCTAssertEqual(discovery.resolved[0].properties["syAP"], "119") + XCTAssertEqual(discovery.resolved[0].jsonValue.stringValue(for: "name"), "TC") + + let configure = try jsonValue(""" + { + "schema_version": 1, + "config_path": "/app/.env", + "host": "root@10.0.0.2", + "configure_id": "cfg-1", + "ssh_authenticated": true, + "device_syap": "119", + "device_model": "Time Capsule", + "compatibility": { + "os_name": "NetBSD", + "os_release": "6.0", + "arch": "evbarm", + "elf_endianness": "little", + "payload_family": "netbsd6_samba4", + "device_generation": "gen5", + "supported": true, + "reason_code": "supported_netbsd6", + "reason_detail": "", + "syap_candidates": ["119"], + "model_candidates": ["Time Capsule"] + }, + "device": {"host": "root@10.0.0.2", "syap": "119", "model": "Time Capsule"}, + "summary": "configuration saved and SSH authentication verified." + } + """).decode(ConfigurePayload.self) + + XCTAssertEqual(configure.host, "root@10.0.0.2") + XCTAssertEqual(configure.compatibility?.payloadFamily, "netbsd6_samba4") + XCTAssertEqual(ConfiguredDeviceState(payload: configure).model, "Time Capsule") + } + + func testDecodesDeployDoctorAndMaintenancePayloads() throws { + let deployPlan = try jsonValue(""" + { + "schema_version": 1, + "host": "root@10.0.0.2", + "volume_root": "/Volumes/dk2", + "payload_dir": "/Volumes/dk2/.samba4", + "payload_family": "netbsd6_samba4", + "netbsd4": false, + "requires_reboot": true, + "reboot_required": true, + "uploads": [{"description": "smbd"}], + "pre_upload_actions": [{"type": "stop_process"}], + "post_upload_actions": [], + "activation_actions": [], + "post_deploy_checks": [{"id": "ssh_returns_after_reboot", "description": "SSH returns after reboot"}], + "summary": "deployment dry-run plan generated." + } + """).decode(DeployPlanPayload.self) + + XCTAssertEqual(deployPlan.payloadFamily, "netbsd6_samba4") + XCTAssertTrue(deployPlan.requiresReboot) + XCTAssertEqual(deployPlan.uploads.count, 1) + + let deployResult = try jsonValue(""" + { + "schema_version": 1, + "payload_dir": "/Volumes/dk2/.samba4", + "netbsd4": false, + "payload_family": "netbsd6_samba4", + "requires_reboot": true, + "rebooted": true, + "reboot_requested": true, + "waited": true, + "verified": true, + "summary": "deployment completed." + } + """).decode(DeployResultPayload.self) + + XCTAssertEqual(deployResult.rebootRequested, true) + XCTAssertEqual(deployResult.verified, true) + + let doctor = try jsonValue(""" + { + "schema_version": 1, + "fatal": true, + "results": [{"status": "FAIL", "message": "smbd is not running", "details": {"domain": "runtime"}}], + "counts": {"FAIL": 1}, + "error": "smbd is not running", + "summary": "doctor found one or more fatal problems." + } + """).decode(DoctorPayload.self) + + XCTAssertTrue(doctor.fatal) + XCTAssertEqual(doctor.results[0].details, .object(["domain": .string("runtime")])) + + let fsckTargets = try jsonValue(""" + { + "schema_version": 1, + "targets": [{"device": "/dev/dk2", "mountpoint": "/Volumes/dk2", "name": "Data", "builtin": true}], + "counts": {"targets": 1}, + "summary": "found 1 mounted HFS volume(s)." + } + """).decode(FsckVolumeListPayload.self) + + XCTAssertEqual(fsckTargets.targets[0].device, "/dev/dk2") + + let maintenance = try jsonValue(""" + { + "schema_version": 1, + "summary": "uninstall completed.", + "requires_reboot": true, + "rebooted": true, + "reboot_requested": true, + "waited": true, + "verified": true, + "counts": {"payload_dirs": 1} + } + """).decode(MaintenanceResultPayload.self) + + XCTAssertEqual(maintenance.rebooted, true) + XCTAssertEqual(maintenance.counts?["payload_dirs"], 1) + } + + func testDecodesRecoveryAndReportsContractFailures() throws { + let event = BackendEvent( + type: "error", + operation: "deploy", + code: "remote_error", + message: "failed", + recovery: try jsonValue(""" + { + "title": "No HFS volumes found", + "message": "The device did not report a deployable HFS disk.", + "actions": ["Wake the disk.", "Retry deploy."], + "retryable": true, + "suggested_operation": "deploy", + "docs_anchor": "deploy" + } + """) + ) + + let error = BackendErrorViewModel(event: event) + + XCTAssertEqual(error.recovery?.title, "No HFS volumes found") + XCTAssertEqual(error.recovery?.actions, ["Wake the disk.", "Retry deploy."]) + XCTAssertEqual(error.recovery?.suggestedOperation, "deploy") + + XCTAssertThrowsError(try BackendEvent(type: "result", operation: "paths", ok: true).decodePayload(PathsPayload.self)) { thrown in + XCTAssertEqual(thrown as? BackendContractError, .missingPayload(operation: "paths")) + } + + XCTAssertThrowsError( + try BackendEvent( + type: "result", + operation: "paths", + ok: true, + payload: .object(["schema_version": .string("wrong")]) + ).decodePayload(PathsPayload.self) + ) { thrown in + guard case BackendContractError.payloadDecodeFailed(let operation, let payloadType, _)? = thrown as? BackendContractError else { + return XCTFail("Expected payloadDecodeFailed, got \(thrown)") + } + XCTAssertEqual(operation, "paths") + XCTAssertEqual(payloadType, "PathsPayload") + } + } + + private func jsonValue(_ text: String) throws -> JSONValue { + let data = Data(text.utf8) + return try JSONDecoder().decode(JSONValue.self, from: data) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift new file mode 100644 index 00000000..e750fe8a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift @@ -0,0 +1,426 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ConnectionWorkflowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(ConnectionWorkflowState.allCases, [ + .idle, + .discovering, + .discoveryReady, + .discoveryEmpty, + .discoveryFailed, + .configuring, + .configured, + .configureFailed + ]) + } + + func testInvalidDiscoverTimeoutMovesToDiscoveryFailedWithoutRunningHelper() { + let runner = WorkflowRecordingRunner(responses: []) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "bad" + + store.runDiscover() + + XCTAssertEqual(store.state, .discoveryFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testDiscoverSingleDeviceAutoSelectsAndRecordsStage() async throws { + let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "discover", stage: "bonjour_discovery", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "0.25" + + store.runDiscover() + + XCTAssertEqual(store.state, .discovering) + try await waitUntil { store.state == .discoveryReady } + XCTAssertEqual(store.currentStage?.stage, "bonjour_discovery") + XCTAssertEqual(store.devices.count, 1) + XCTAssertEqual(store.devices[0].name, "TC") + XCTAssertEqual(store.devices[0].syap, "119") + XCTAssertEqual(store.selectedDeviceID, store.devices[0].id) + XCTAssertEqual(runner.calls.first?.operation, "discover") + XCTAssertEqual(runner.calls.first?.params["timeout"], .number(0.25)) + } + + func testDiscoverEmptyResultMovesToDiscoveryEmpty() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryEmpty } + XCTAssertEqual(store.devices, []) + XCTAssertNil(store.selectedDeviceID) + } + + func testDiscoverMultipleDevicesRequiresExplicitSelection() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [ + deviceRecord(name: "TC One", ipv4: ["10.0.0.2"], syap: "119"), + deviceRecord(name: "TC Two", ipv4: ["10.0.0.3"], syap: "120") + ])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryReady } + XCTAssertEqual(store.devices.count, 2) + XCTAssertNil(store.selectedDeviceID) + + store.select(store.devices[1]) + + XCTAssertEqual(store.selectedDeviceID, store.devices[1].id) + XCTAssertEqual(store.selectedDevice?.name, "TC Two") + } + + func testDiscoverBackendErrorMovesToDiscoveryFailedWithRecovery() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "discover", + code: "operation_failed", + message: "Bonjour failed.", + recovery: recovery(title: "Discovery failed", actions: ["Retry discovery."]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryFailed } + XCTAssertEqual(store.error?.message, "Bonjour failed.") + XCTAssertEqual(store.error?.recovery?.title, "Discovery failed") + XCTAssertEqual(store.error?.recovery?.actions, ["Retry discovery."]) + } + + func testMalformedDiscoverPayloadMovesToDiscoveryFailed() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "discover", + ok: true, + payload: .object(["schema_version": .string("wrong")]) + ) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + + try await waitUntil { store.state == .discoveryFailed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testConfigureRejectsMissingPasswordWithoutRunningHelper() { + let runner = WorkflowRecordingRunner(responses: []) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = "root@10.0.0.2" + + store.runConfigure(password: " ") + + XCTAssertEqual(store.state, .configureFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testConfigureRejectsMissingTargetWithoutRunningHelper() { + let runner = WorkflowRecordingRunner(responses: []) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runConfigure(password: "pw") + + XCTAssertEqual(store.state, .configureFailed) + XCTAssertEqual(store.error?.message, "Choose a discovered device or enter a host.") + XCTAssertEqual(runner.calls, []) + } + + func testConfigureSelectedDeviceSendsSelectedRecordAndStoresResult() async throws { + let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "configure", stage: "ssh_probe", risk: "remote_read", cancellable: true), + BackendEvent(type: "result", operation: "configure", ok: true, payload: configurePayload(host: "root@10.0.0.2")) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + try await waitUntil { store.state == .discoveryReady } + store.runConfigure(password: "pw") + + XCTAssertEqual(store.state, .configuring) + try await waitUntil { store.state == .configured } + XCTAssertEqual(store.currentStage?.stage, "ssh_probe") + XCTAssertEqual(store.configuredDevice?.host, "root@10.0.0.2") + XCTAssertEqual(store.configuredDevice?.sshAuthenticated, true) + XCTAssertEqual(runner.calls.count, 2) + XCTAssertNil(runner.calls[1].params["host"]) + XCTAssertEqual(runner.calls[1].params["selected_record"], store.devices[0].rawRecord) + XCTAssertEqual(runner.calls[1].params["password"], .string("pw")) + } + + func testConfigureManualHostSendsHostWhenNoDeviceSelected() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: configurePayload(host: "root@10.0.0.9")) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = " root@10.0.0.9 " + store.debugLogging = true + + store.runConfigure(password: "pw") + + try await waitUntil { store.state == .configured } + XCTAssertEqual(runner.calls.first?.operation, "configure") + XCTAssertEqual(runner.calls.first?.params["host"], .string("root@10.0.0.9")) + XCTAssertNil(runner.calls.first?.params["selected_record"]) + XCTAssertEqual(runner.calls.first?.params["debug_logging"], .bool(true)) + } + + func testConfigureAuthFailurePreservesDiscoverySelectionAndShowsRecovery() async throws { + let record = deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "configure", + code: "auth_failed", + message: "The AirPort admin password did not work.", + recovery: recovery(title: "AirPort password rejected", actions: ["Re-enter the password."]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + try await waitUntil { store.state == .discoveryReady } + let selectedID = store.selectedDeviceID + store.runConfigure(password: "bad") + + try await waitUntil { store.state == .configureFailed } + XCTAssertEqual(store.selectedDeviceID, selectedID) + XCTAssertEqual(store.devices.count, 1) + XCTAssertEqual(store.error?.code, "auth_failed") + XCTAssertEqual(store.error?.recovery?.title, "AirPort password rejected") + } + + func testConfigureFalseResultMovesToConfigureFailed() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: false, + payload: .object(["summary": .string("configuration failed.")]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = "root@10.0.0.2" + + store.runConfigure(password: "pw") + + try await waitUntil { store.state == .configureFailed } + XCTAssertEqual(store.error?.message, "configuration failed.") + } + + func testMalformedConfigurePayloadMovesToConfigureFailed() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent( + type: "result", + operation: "configure", + ok: true, + payload: .object(["schema_version": .number(1)]) + ) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + store.manualHost = "root@10.0.0.2" + + store.runConfigure(password: "pw") + + try await waitUntil { store.state == .configureFailed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testClearReturnsWorkflowToIdle() async throws { + let runner = WorkflowRecordingRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: discoverPayload(records: [ + deviceRecord(name: "TC", ipv4: ["10.0.0.2"], syap: "119") + ])) + ]) + ]) + let store = ConnectionWorkflowStore(backend: BackendClient(runner: runner)) + + store.runDiscover() + try await waitUntil { store.state == .discoveryReady } + store.clear() + + XCTAssertEqual(store.state, .idle) + XCTAssertEqual(store.devices, []) + XCTAssertNil(store.selectedDeviceID) + XCTAssertNil(store.configuredDevice) + XCTAssertNil(store.error) + XCTAssertEqual(store.events.count, 0) + } + + private func waitUntil( + timeoutNanoseconds: UInt64 = 2_000_000_000, + _ condition: @escaping @MainActor () -> Bool + ) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !condition() { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + XCTFail("Timed out waiting for connection workflow state change.") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } + + private func deviceRecord(name: String, ipv4: [String], syap: String) -> JSONValue { + .object([ + "name": .string(name), + "hostname": .string("\(name.lowercased().replacingOccurrences(of: " ", with: "-")).local."), + "service_type": .string("_airport._tcp.local."), + "port": .number(5009), + "ipv4": .array(ipv4.map(JSONValue.string)), + "ipv6": .array([]), + "services": .array([.string("_airport._tcp.local.")]), + "properties": .object(["syAP": .string(syap)]), + "fullname": .string("\(name)._airport._tcp.local.") + ]) + } + + private func discoverPayload(records: [JSONValue]) -> JSONValue { + .object([ + "schema_version": .number(1), + "instances": .array([]), + "resolved": .array(records), + "counts": .object([ + "instances": .number(0), + "resolved": .number(Double(records.count)) + ]), + "summary": .string("discovered \(records.count) resolved AirPort service(s).") + ]) + } + + private func configurePayload(host: String) -> JSONValue { + .object([ + "schema_version": .number(1), + "config_path": .string("/app/.env"), + "host": .string(host), + "configure_id": .string("cfg-1"), + "ssh_authenticated": .bool(true), + "device_syap": .string("119"), + "device_model": .string("Time Capsule"), + "compatibility": .object([ + "payload_family": .string("netbsd6_samba4"), + "supported": .bool(true), + "syap_candidates": .array([.string("119")]), + "model_candidates": .array([.string("Time Capsule")]) + ]), + "device": .object([ + "host": .string(host), + "syap": .string("119"), + "model": .string("Time Capsule") + ]), + "summary": .string("configuration saved and SSH authentication verified.") + ]) + } + + private func recovery(title: String, actions: [String]) -> JSONValue { + .object([ + "title": .string(title), + "message": .string(title), + "actions": .array(actions.map(JSONValue.string)), + "retryable": .bool(true), + "suggested_operation": .string("configure") + ]) + } +} + +private final class WorkflowRecordingRunner: HelperRunning, @unchecked Sendable { + struct Call: Equatable, Sendable { + let helperPath: String? + let operation: String + let params: [String: JSONValue] + } + + struct Response: Sendable { + let events: [BackendEvent] + let result: HelperRunResult + + init( + events: [BackendEvent], + result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: "") + ) { + self.events = events + self.result = result + } + } + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.WorkflowRecordingRunner") + private var storedResponses: [Response] + private var storedCalls: [Call] = [] + + init(responses: [Response]) { + self.storedResponses = responses + } + + var calls: [Call] { + queue.sync { storedCalls } + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let response = queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params)) + if storedResponses.isEmpty { + return Response( + events: [BackendEvent.error(operation: operation, code: "missing_test_response", message: "No test response queued.")], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + } + return storedResponses.removeFirst() + } + + for event in response.events { + await onEvent(event) + } + return response.result + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift new file mode 100644 index 00000000..b59be7dc --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -0,0 +1,273 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeployWorkflowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DeployWorkflowState.allCases, [ + .idle, + .planning, + .planReady, + .planStale, + .planFailed, + .deploying, + .awaitingConfirmation, + .deployed, + .deployFailed + ]) + } + + func testInvalidMountWaitMovesToPlanFailedWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.mountWait = "1.5" + + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testPlanSendsDryRunParamsAndMovesToPlanReady() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "build_deployment_plan", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.mountWait = "45" + store.noReboot = true + store.noWait = true + store.nbnsEnabled = false + store.debugLogging = true + + store.runPlan(password: "pw") + + XCTAssertEqual(store.state, .planning) + try await waitUntilStoreState { store.state == .planReady } + XCTAssertEqual(store.currentStage?.stage, "build_deployment_plan") + XCTAssertEqual(store.plan?.payloadDir, "/Volumes/dk2/.samba4") + XCTAssertEqual(runner.calls.count, 1) + XCTAssertEqual(runner.calls[0].operation, "deploy") + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["no_reboot"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["no_wait"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["nbns_enabled"], .bool(false)) + XCTAssertEqual(runner.calls[0].params["debug_logging"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["mount_wait"], .number(45)) + XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + } + + func testMalformedPlanPayloadMovesToPlanFailed() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "") + + try await waitUntilStoreState { store.state == .planFailed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testDeployBeforePlanMarksPlanStaleWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + XCTAssertFalse(store.canDeploy) + store.runDeploy(password: "pw") + + XCTAssertEqual(store.state, .planStale) + XCTAssertEqual(store.error?.code, "plan_stale") + XCTAssertEqual(runner.calls, []) + } + + func testOptionChangeAfterPlanMarksPlanStale() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + + store.noWait = true + + XCTAssertEqual(store.state, .planStale) + XCTAssertFalse(store.canDeploy) + } + + func testDeploySendsRunParamsFromPlanOptionsAndStoresResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "upload_payload", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployResultPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + store.mountWait = "30" + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw2") + + XCTAssertEqual(store.state, .deploying) + try await waitUntilStoreState { store.state == .deployed } + XCTAssertEqual(store.currentStage?.stage, "upload_payload") + XCTAssertEqual(store.result?.verified, true) + XCTAssertEqual(runner.calls.count, 2) + XCTAssertEqual(runner.calls[1].params["dry_run"], .bool(false)) + XCTAssertEqual(runner.calls[1].params["mount_wait"], .number(30)) + XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) + } + + func testConfirmationRequiredMovesToAwaitingConfirmationThenConfirmedDeployCompletes() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deployment.", + details: .object([ + "title": .string("Confirm deployment"), + "message": .string("Deploy and reboot."), + "action_title": .string("Deploy"), + "confirmation_id": .string("confirm-1") + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "stage", operation: "deploy", stage: "pre_upload_actions", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployResultPayload()) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = DeployWorkflowStore(backend: backend) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + try await waitUntilStoreState { store.state == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.state == .deployed } + XCTAssertEqual(store.currentStage?.stage, "pre_upload_actions") + XCTAssertEqual(runner.calls.count, 3) + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("confirm-1")) + } + + func testDeployBackendErrorMovesToDeployFailedWithRecovery() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "remote_error", + message: "No HFS volumes found.", + recovery: recoveryValue(title: "No HFS volumes found", actions: ["Wake the disk."], suggestedOperation: "deploy") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + + try await waitUntilStoreState { store.state == .deployFailed } + XCTAssertEqual(store.error?.code, "remote_error") + XCTAssertEqual(store.error?.recovery?.title, "No HFS volumes found") + } + + func testFalseDeployResultMovesToDeployFailed() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: false, payload: .object(["summary": .string("deployment failed.")])) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.runDeploy(password: "pw") + + try await waitUntilStoreState { store.state == .deployFailed } + XCTAssertEqual(store.error?.message, "deployment failed.") + } + + func testClearResetsDeployState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: deployPlanPayload()) + ]) + ]) + let store = DeployWorkflowStore(backend: BackendClient(runner: runner)) + + store.runPlan(password: "pw") + try await waitUntilStoreState { store.state == .planReady } + store.clear() + + XCTAssertEqual(store.state, .idle) + XCTAssertNil(store.plan) + XCTAssertNil(store.result) + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + XCTAssertNil(store.plannedOptions) + } + + private func deployPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_root": .string("/Volumes/dk2"), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "payload_family": .string("netbsd6_samba4"), + "netbsd4": .bool(false), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "uploads": .array([.object(["description": .string("smbd")])]), + "pre_upload_actions": .array([.object(["type": .string("stop_process")])]), + "post_upload_actions": .array([]), + "activation_actions": .array([]), + "post_deploy_checks": .array([ + .object(["id": .string("ssh_returns_after_reboot"), "description": .string("SSH returns after reboot")]) + ]), + "summary": .string("deployment dry-run plan generated.") + ]) + } + + private func deployResultPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "netbsd4": .bool(false), + "payload_family": .string("netbsd6_samba4"), + "requires_reboot": .bool(true), + "rebooted": .bool(true), + "reboot_requested": .bool(true), + "waited": .bool(true), + "verified": .bool(true), + "summary": .string("deployment completed.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift new file mode 100644 index 00000000..2aa39521 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift @@ -0,0 +1,207 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DoctorStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DoctorWorkflowState.allCases, [ + .idle, + .running, + .passed, + .warning, + .failed, + .runFailed + ]) + } + + func testInvalidBonjourTimeoutMovesToRunFailedWithoutRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = DoctorStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "nan" + + store.runDoctor(password: "pw") + + XCTAssertEqual(store.state, .runFailed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + } + + func testRunSendsDoctorParamsAndPassedResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "doctor", stage: "run_checks", risk: "remote_read", cancellable: true), + BackendEvent(type: "result", operation: "doctor", ok: true, payload: doctorPayload( + fatal: false, + checks: [ + check(status: "PASS", message: "smbd is running", domain: "Runtime"), + check(status: "INFO", message: "bonjour visible", domain: "Bonjour") + ] + )) + ]) + ]) + let store = DoctorStore(backend: BackendClient(runner: runner)) + store.bonjourTimeout = "4.5" + store.skipSSH = true + store.skipBonjour = true + store.skipSMB = true + + store.runDoctor(password: "pw") + + XCTAssertEqual(store.state, .running) + try await waitUntilStoreState { store.state == .passed } + XCTAssertEqual(store.currentStage?.stage, "run_checks") + XCTAssertEqual(store.summary?.passCount, 1) + XCTAssertEqual(store.summary?.infoCount, 1) + XCTAssertEqual(runner.calls.first?.operation, "doctor") + XCTAssertEqual(runner.calls.first?.params["bonjour_timeout"], .number(4.5)) + XCTAssertEqual(runner.calls.first?.params["skip_ssh"], .bool(true)) + XCTAssertEqual(runner.calls.first?.params["skip_bonjour"], .bool(true)) + XCTAssertEqual(runner.calls.first?.params["skip_smb"], .bool(true)) + XCTAssertEqual(runner.calls.first?.params["credentials"], .object(["password": .string("pw")])) + } + + func 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/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index cae0980e..fe97352e 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -42,6 +42,27 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertNil(params["credentials"]) } + func testConfigureParamsUseSelectedRecordInsteadOfManualHostWhenProvided() { + let selectedRecord = JSONValue.object([ + "name": .string("TC"), + "hostname": .string("tc.local."), + "ipv4": .array([.string("10.0.0.2")]), + "properties": .object(["syAP": .string("119")]) + ]) + + let params = OperationParams.configure( + host: "root@manual", + selectedRecord: selectedRecord, + password: "pw", + debugLogging: true + ) + + XCTAssertNil(params["host"]) + XCTAssertEqual(params["selected_record"], selectedRecord) + XCTAssertEqual(params["password"], .string("pw")) + XCTAssertEqual(params["debug_logging"], .bool(true)) + } + func testPendingConfirmationBuildsFromBackendEvent() throws { let event = BackendEvent( type: "error", diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift new file mode 100644 index 00000000..de463bbe --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ReadinessStoreTests.swift @@ -0,0 +1,190 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ReadinessStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(ReadinessOperationState.allCases, [.idle, .running, .succeeded, .failed]) + } + + func testCapabilitiesSuccessStoresHelperMetadataAndStage() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "capabilities", stage: "summarize_capabilities", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runCapabilities() + + XCTAssertEqual(store.capabilitiesState, .running) + try await waitUntilStoreState { store.capabilitiesState == .succeeded } + XCTAssertEqual(store.currentStage?.stage, "summarize_capabilities") + XCTAssertEqual(store.capabilities?.helperVersion, "1.2.3") + XCTAssertEqual(runner.calls.first?.operation, "capabilities") + } + + func testPathsSuccessStoresArtifactRows() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "paths", ok: true, payload: pathsPayload()) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runPaths() + + try await waitUntilStoreState { store.pathsState == .succeeded } + XCTAssertEqual(store.paths?.artifacts.count, 1) + XCTAssertEqual(store.paths?.artifacts[0].name, "smbd") + XCTAssertEqual(store.paths?.counts["artifacts"], 1) + } + + func testValidationSuccessStoresPassCounts() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runValidateInstall() + + try await waitUntilStoreState { store.validationState == .succeeded } + XCTAssertEqual(store.validation?.counts["pass"], 1) + XCTAssertNil(store.error) + } + + func testValidationFailureStoresPayloadWithoutTransportError() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: false, payload: validationPayload(ok: false)) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runValidateInstall() + + try await waitUntilStoreState { store.validationState == .failed } + XCTAssertEqual(store.validation?.ok, false) + XCTAssertEqual(store.validation?.counts["fail"], 1) + XCTAssertNil(store.error) + } + + func testBackendErrorFailsOnlyMatchingOperationWithRecovery() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "paths", + code: "validation_failed", + message: "missing distribution root", + recovery: recoveryValue(title: "Deployment validation failed", actions: ["Open Readiness."], suggestedOperation: "validate-install") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runPaths() + + try await waitUntilStoreState { store.pathsState == .failed } + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.recovery?.title, "Deployment validation failed") + XCTAssertEqual(store.capabilitiesState, .idle) + XCTAssertEqual(store.validationState, .idle) + } + + func testMalformedPayloadFailsContract() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runCapabilities() + + try await waitUntilStoreState { store.capabilitiesState == .failed } + XCTAssertEqual(store.error?.code, "contract_decode_failed") + } + + func testClearResetsReadinessState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]) + ]) + let store = ReadinessStore(backend: BackendClient(runner: runner)) + + store.runCapabilities() + try await waitUntilStoreState { store.capabilitiesState == .succeeded } + store.clear() + + XCTAssertEqual(store.capabilitiesState, .idle) + XCTAssertEqual(store.pathsState, .idle) + XCTAssertEqual(store.validationState, .idle) + XCTAssertNil(store.capabilities) + XCTAssertNil(store.paths) + XCTAssertNil(store.validation) + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + } + + private func capabilitiesPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "api_schema_version": .number(1), + "helper_version": .string("1.2.3"), + "helper_version_code": .number(123), + "operations": .array([.string("discover"), .string("configure")]), + "distribution_root": .string("/repo"), + "artifact_manifest_sha256": .string("abc"), + "confirmation_schema_version": .number(1), + "summary": .string("helper capabilities resolved.") + ]) + } + + private func pathsPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "distribution_root": .string("/repo"), + "config_path": .string("/app/.env"), + "state_dir": .string("/app"), + "package_root": .string("/repo/src/timecapsulesmb"), + "artifact_manifest": .string("/repo/src/timecapsulesmb/assets/artifact-manifest.json"), + "artifacts": .array([ + .object([ + "name": .string("smbd"), + "repo_relative_path": .string("bin/samba4/smbd"), + "absolute_path": .string("/repo/bin/samba4/smbd"), + "sha256": .string("hash"), + "ok": .bool(true), + "message": .string("ok") + ]) + ]), + "counts": .object(["artifacts": .number(1)]), + "summary": .string("resolved app paths with 1 artifact path(s).") + ]) + } + + private func validationPayload(ok: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "ok": .bool(ok), + "checks": .array([ + .object([ + "id": .string(ok ? "python_modules" : "artifact_hashes"), + "ok": .bool(ok), + "message": .string(ok ? "required Python modules import" : "artifact validation failed") + ]) + ]), + "counts": .object([ + "checks": .number(1), + "pass": .number(ok ? 1 : 0), + "fail": .number(ok ? 0 : 1) + ]), + "summary": .string(ok ? "install validation passed." : "install validation failed.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift new file mode 100644 index 00000000..e6358023 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -0,0 +1,84 @@ +import Foundation +import XCTest +@testable import TimeCapsuleSMBApp + +final class StoreTestRunner: HelperRunning, @unchecked Sendable { + struct Call: Equatable, Sendable { + let helperPath: String? + let operation: String + let params: [String: JSONValue] + } + + struct Response: Sendable { + let events: [BackendEvent] + let result: HelperRunResult + + init( + events: [BackendEvent], + result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: "") + ) { + self.events = events + self.result = result + } + } + + private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.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], + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + let response = queue.sync { + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params)) + if storedResponses.isEmpty { + return Response( + events: [BackendEvent.error(operation: operation, code: "missing_test_response", message: "No test response queued.")], + result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "") + ) + } + return storedResponses.removeFirst() + } + + for event in response.events { + await onEvent(event) + } + return response.result + } +} + +@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") -> JSONValue { + .object([ + "title": .string(title), + "message": .string(title), + "actions": .array(actions.map(JSONValue.string)), + "retryable": .bool(true), + "suggested_operation": .string(suggestedOperation) + ]) +} From 364c98e63e2b65fb4b83bdd60b9b05c8c067eed2 Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 17:33:40 -0700 Subject: [PATCH 15/20] Implement structured macOS maintenance workflows --- .../TimeCapsuleSMBApp/BackendPayloads.swift | 170 ++++- .../TimeCapsuleSMBApp/ContentView.swift | 135 +--- .../TimeCapsuleSMBApp/MaintenanceStore.swift | 703 ++++++++++++++++++ .../TimeCapsuleSMBApp/MaintenanceView.swift | 298 ++++++++ .../TimeCapsuleSMBApp/OperationParams.swift | 6 +- .../MaintenanceStoreTests.swift | 592 +++++++++++++++ 6 files changed, 1772 insertions(+), 132 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift index 1619c309..31b02133 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift @@ -397,11 +397,179 @@ struct FsckVolumeListPayload: Decodable, Equatable { } struct FsckTargetPayload: Decodable, Equatable { - let label: String? + let name: String? + let builtin: Bool? let device: String let mountpoint: String } +struct ActivationPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let actions: [JSONValue] + let postActivationChecks: [PlannedCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case actions + case postActivationChecks = "post_activation_checks" + case counts + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.actions = try container.decodeIfPresent([JSONValue].self, forKey: .actions) ?? [] + self.postActivationChecks = try container.decodeIfPresent([PlannedCheckPayload].self, forKey: .postActivationChecks) ?? [] + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct ActivationResultPayload: Decodable, Equatable { + let schemaVersion: Int + let alreadyActive: Bool + let message: String? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case alreadyActive = "already_active" + case message + case summary + } +} + +struct UninstallPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let host: String + let volumeRoots: [String] + let payloadDirs: [String] + let remoteActions: [JSONValue] + let requiresReboot: Bool + let rebootRequired: Bool? + let postUninstallChecks: [PlannedCheckPayload] + let counts: [String: Int] + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case host + case volumeRoots = "volume_roots" + case payloadDirs = "payload_dirs" + case remoteActions = "remote_actions" + case requiresReboot = "requires_reboot" + case rebootRequired = "reboot_required" + case postUninstallChecks = "post_uninstall_checks" + case counts + case summary + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.host = try container.decode(String.self, forKey: .host) + self.volumeRoots = try container.decodeIfPresent([String].self, forKey: .volumeRoots) ?? [] + self.payloadDirs = try container.decodeIfPresent([String].self, forKey: .payloadDirs) ?? [] + self.remoteActions = try container.decodeIfPresent([JSONValue].self, forKey: .remoteActions) ?? [] + self.requiresReboot = try container.decode(Bool.self, forKey: .requiresReboot) + self.rebootRequired = try container.decodeIfPresent(Bool.self, forKey: .rebootRequired) + self.postUninstallChecks = try container.decodeIfPresent([PlannedCheckPayload].self, forKey: .postUninstallChecks) ?? [] + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.summary = try container.decode(String.self, forKey: .summary) + } +} + +struct FsckPlanPayload: Decodable, Equatable { + let schemaVersion: Int + let target: FsckTargetPayload? + let device: String + let mountpoint: String + let rebootRequired: Bool + let waitAfterReboot: Bool + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case target + case device + case mountpoint + case rebootRequired = "reboot_required" + case waitAfterReboot = "wait_after_reboot" + case summary + } +} + +struct FsckResultPayload: Decodable, Equatable { + let schemaVersion: Int + let device: String + let mountpoint: String + let returncode: Int? + let rebootRequested: Bool? + let waited: Bool? + let verified: Bool? + let summary: String + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case device + case mountpoint + case returncode + case rebootRequested = "reboot_requested" + case waited + case verified + case summary + } +} + +struct RepairXattrsPayload: Decodable, Equatable { + let schemaVersion: Int + let returncode: Int? + let root: String? + let findingCount: Int + let repairableCount: Int + let counts: [String: Int] + let stats: JSONValue? + let report: String? + let telemetryResult: JSONValue? + let error: String? + let summary: String + let summaryText: String? + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case returncode + case root + case findingCount = "finding_count" + case repairableCount = "repairable_count" + case counts + case stats + case report + case telemetryResult = "telemetry_result" + case error + case summary + case summaryText = "summary_text" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + self.returncode = try container.decodeIfPresent(Int.self, forKey: .returncode) + self.root = try container.decodeIfPresent(String.self, forKey: .root) + self.findingCount = try container.decodeIfPresent(Int.self, forKey: .findingCount) ?? 0 + self.repairableCount = try container.decodeIfPresent(Int.self, forKey: .repairableCount) ?? 0 + self.counts = try container.decodeIfPresent([String: Int].self, forKey: .counts) ?? [:] + self.stats = try container.decodeIfPresent(JSONValue.self, forKey: .stats) + self.report = try container.decodeIfPresent(String.self, forKey: .report) + self.telemetryResult = try container.decodeIfPresent(JSONValue.self, forKey: .telemetryResult) + self.error = try container.decodeIfPresent(String.self, forKey: .error) + self.summary = try container.decode(String.self, forKey: .summary) + self.summaryText = try container.decodeIfPresent(String.self, forKey: .summaryText) + } +} + struct MaintenanceResultPayload: Decodable, Equatable { let schemaVersion: Int let summary: String diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index f95e3fb3..8fd305ec 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -6,13 +6,9 @@ public struct ContentView: View { @StateObject private var connectionStore: ConnectionWorkflowStore @StateObject private var deployStore: DeployWorkflowStore @StateObject private var doctorStore: DoctorStore + @StateObject private var maintenanceStore: MaintenanceStore @State private var selection: Screen = .readiness @State private var password = "" - @State private var repairPath = "" - @State private var volume = "" - @State private var noReboot = false - @State private var mountWait = "30" - @State private var noWait = false @MainActor public init() { @@ -22,6 +18,7 @@ public struct ContentView: View { _connectionStore = StateObject(wrappedValue: ConnectionWorkflowStore(backend: backend)) _deployStore = StateObject(wrappedValue: DeployWorkflowStore(backend: backend)) _doctorStore = StateObject(wrappedValue: DoctorStore(backend: backend)) + _maintenanceStore = StateObject(wrappedValue: MaintenanceStore(backend: backend)) } public var body: some View { @@ -94,104 +91,7 @@ public struct ContentView: View { case .doctor: DoctorView(store: doctorStore, password: $password) case .maintenance: - CommandPanel(title: L10n.string("screen.maintenance")) { - TextField(L10n.string("field.repair_xattrs_path"), text: $repairPath) - TextField(L10n.string("field.fsck_volume"), text: $volume) - TextField(L10n.string("field.mount_wait"), text: $mountWait) - Toggle(L10n.string("toggle.no_reboot"), isOn: $noReboot) - Toggle(L10n.string("toggle.no_wait"), isOn: $noWait) - HStack { - Button { - backend.run(operation: "activate", params: OperationParams.activateRun(password: password)) - } label: { - Label(L10n.string("button.activate"), systemImage: "power") - } - .disabled(backend.isRunning) - runButton( - L10n.string("button.uninstall_plan"), - icon: "xmark.bin", - operation: "uninstall", - params: OperationParams.uninstallPlan( - noReboot: noReboot, - noWait: noWait, - mountWait: mountWaitValue ?? 30, - password: password - ), - disabled: mountWaitValue == nil - ) - Button { - backend.run( - operation: "uninstall", - params: OperationParams.uninstallRun( - noReboot: noReboot, - noWait: noWait, - mountWait: mountWaitValue ?? 30, - password: password - ) - ) - } label: { - Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") - } - .disabled(backend.isRunning || mountWaitValue == nil) - } - HStack { - runButton( - L10n.string("button.list_fsck_volumes"), - icon: "list.bullet.rectangle", - operation: "fsck", - params: OperationParams.fsckList(mountWait: mountWaitValue ?? 30, password: password), - disabled: mountWaitValue == nil - ) - runButton( - L10n.string("button.plan_fsck"), - icon: "doc.text.magnifyingglass", - operation: "fsck", - params: OperationParams.fsckPlan( - volume: volume, - noReboot: noReboot, - noWait: noWait, - mountWait: mountWaitValue ?? 30, - password: password - ), - disabled: mountWaitValue == nil - ) - Button { - backend.run( - operation: "fsck", - params: OperationParams.fsckRun( - volume: volume, - noReboot: noReboot, - noWait: noWait, - mountWait: mountWaitValue ?? 30, - password: password - ) - ) - } label: { - Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") - } - .disabled(backend.isRunning || mountWaitValue == nil) - } - HStack { - Button { - backend.run( - operation: "repair-xattrs", - params: OperationParams.repairXattrsScan(path: repairPath) - ) - } label: { - Label(L10n.string("button.scan_xattrs"), systemImage: "wand.and.stars") - } - .disabled(backend.isRunning || repairPath.isEmpty) - Button { - backend.run( - operation: "repair-xattrs", - params: OperationParams.repairXattrsRun(path: repairPath) - ) - } label: { - Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") - } - .disabled(backend.isRunning || repairPath.isEmpty) - } - } + MaintenanceView(store: maintenanceStore, password: $password) case .advanced: CommandPanel(title: L10n.string("screen.advanced")) { Text(L10n.string("advanced.flash_cli_only")) @@ -202,21 +102,6 @@ public struct ContentView: View { } } - private func runButton( - _ title: String, - icon: String, - operation: String, - params: [String: JSONValue] = [:], - disabled: Bool = false - ) -> some View { - Button { - backend.run(operation: operation, params: params) - } label: { - Label(title, systemImage: icon) - } - .disabled(backend.isRunning || disabled) - } - private func clearActive() { switch selection { case .readiness: @@ -227,23 +112,13 @@ public struct ContentView: View { deployStore.clear() case .doctor: doctorStore.clear() + case .maintenance: + maintenanceStore.clear() default: backend.clear() } } - private var mountWaitValue: Double? { - nonNegativeIntegerDouble(mountWait) - } - - private func nonNegativeIntegerDouble(_ text: String) -> Double? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = Double(trimmed), value.isFinite, value >= 0, value.rounded(.towardZero) == value else { - return nil - } - return value - } - } private enum Screen: String, CaseIterable, Identifiable { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift new file mode 100644 index 00000000..5564d6c3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift @@ -0,0 +1,703 @@ +import Combine +import Foundation + +enum MaintenanceWorkflow: String, CaseIterable, Equatable, Identifiable { + case activate + case uninstall + case fsck + case repairXattrs + + var id: String { rawValue } + + var title: String { + switch self { + case .activate: + return "Activate" + case .uninstall: + return "Uninstall" + case .fsck: + return "fsck" + case .repairXattrs: + return "Repair xattrs" + } + } +} + +enum MaintenanceOperationState: String, CaseIterable, Equatable { + case idle + case loading + case listReady + case planning + case planReady + case planStale + case scanning + case scanReady + case scanStale + case awaitingConfirmation + case running + case repairing + case succeeded + case repaired + case failed + + var title: String { + switch self { + case .idle: + return "Idle" + case .loading: + return "Loading" + case .listReady: + return "List Ready" + case .planning: + return "Planning" + case .planReady: + return "Plan Ready" + case .planStale: + return "Plan Stale" + case .scanning: + return "Scanning" + case .scanReady: + return "Scan Ready" + case .scanStale: + return "Scan Stale" + case .awaitingConfirmation: + return "Awaiting Confirmation" + case .running: + return "Running" + case .repairing: + return "Repairing" + case .succeeded: + return "Succeeded" + case .repaired: + return "Repaired" + case .failed: + return "Failed" + } + } +} + +struct MaintenanceOptions: Equatable { + let noReboot: Bool + let noWait: Bool + let mountWait: Int +} + +struct FsckTargetViewModel: Identifiable, Equatable { + let id: String + let device: String + let mountpoint: String + let name: String? + let builtin: Bool? + + init(payload: FsckTargetPayload) { + self.id = "\(payload.device)|\(payload.mountpoint)" + self.device = payload.device + self.mountpoint = payload.mountpoint + self.name = payload.name + self.builtin = payload.builtin + } + + var volumeParam: String { + device + } +} + +@MainActor +final class MaintenanceStore: ObservableObject { + @Published var selectedWorkflow: MaintenanceWorkflow = .activate + @Published var mountWait = "30" { + didSet { markPlansStaleForOptionChange() } + } + @Published var noReboot = false { + didSet { markPlansStaleForOptionChange() } + } + @Published var noWait = false { + didSet { markPlansStaleForOptionChange() } + } + @Published var repairPath = "" { + didSet { markRepairStaleForPathChange() } + } + @Published var selectedFsckTargetID: FsckTargetViewModel.ID? { + didSet { markFsckPlanStaleIfNeeded() } + } + + @Published private(set) var activateState: MaintenanceOperationState = .idle + @Published private(set) var uninstallState: MaintenanceOperationState = .idle + @Published private(set) var fsckState: MaintenanceOperationState = .idle + @Published private(set) var repairState: MaintenanceOperationState = .idle + + @Published private(set) var activationPlan: ActivationPlanPayload? + @Published private(set) var activationResult: ActivationResultPayload? + @Published private(set) var uninstallPlan: UninstallPlanPayload? + @Published private(set) var uninstallResult: MaintenanceResultPayload? + @Published private(set) var fsckTargets: [FsckTargetViewModel] = [] + @Published private(set) var fsckPlan: FsckPlanPayload? + @Published private(set) var fsckResult: FsckResultPayload? + @Published private(set) var repairScan: RepairXattrsPayload? + @Published private(set) var repairResult: RepairXattrsPayload? + @Published private(set) var currentStage: OperationStageState? + @Published private(set) var error: BackendErrorViewModel? + + let backend: BackendClient + + private var plannedUninstallOptions: MaintenanceOptions? + private var plannedFsckOptions: MaintenanceOptions? + private var plannedFsckTargetID: FsckTargetViewModel.ID? + private var scannedRepairPath: String? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var events: [BackendEvent] { + backend.events + } + + var isRunning: Bool { + backend.isRunning + } + + var canCancel: Bool { + backend.canCancel + } + + var mountWaitValue: Int? { + nonNegativeInteger(mountWait) + } + + var selectedFsckTarget: FsckTargetViewModel? { + guard let selectedFsckTargetID else { + return nil + } + return fsckTargets.first { $0.id == selectedFsckTargetID } + } + + var canRunActivation: Bool { + !backend.isRunning && activationPlan != nil && activateState == .planReady + } + + var canRunUninstall: Bool { + !backend.isRunning && uninstallPlan != nil && uninstallState == .planReady && currentOptions == plannedUninstallOptions + } + + var canPlanFsck: Bool { + !backend.isRunning && selectedFsckTarget != nil && currentOptions != nil + } + + var canRunFsck: Bool { + !backend.isRunning + && fsckPlan != nil + && fsckState == .planReady + && currentOptions == plannedFsckOptions + && selectedFsckTargetID == plannedFsckTargetID + } + + var canRepairXattrs: Bool { + !backend.isRunning + && repairState == .scanReady + && repairScan?.repairableCount ?? 0 > 0 + && scannedRepairPath == trimmedRepairPath + } + + func planActivation(password: String) { + resetRunState() + selectedWorkflow = .activate + activateState = .planning + activationPlan = nil + activationResult = nil + backend.run(operation: "activate", params: OperationParams.activatePlan(password: password)) + } + + func runActivation(password: String) { + guard canRunActivation else { + failLocally(workflow: .activate, message: "Plan NetBSD4 activation before running it.") + return + } + resetRunState() + selectedWorkflow = .activate + activateState = .running + activationResult = nil + backend.run(operation: "activate", params: OperationParams.activateRun(password: password)) + } + + func planUninstall(password: String) { + guard let options = currentOptions else { + failLocally(workflow: .uninstall, message: "Mount wait must be a non-negative integer.") + return + } + resetRunState() + selectedWorkflow = .uninstall + uninstallState = .planning + uninstallPlan = nil + uninstallResult = nil + plannedUninstallOptions = options + backend.run( + operation: "uninstall", + params: OperationParams.uninstallPlan( + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ) + ) + } + + func runUninstall(password: String) { + guard let options = plannedUninstallOptions, currentOptions == options, uninstallPlan != nil else { + uninstallState = .planStale + error = BackendErrorViewModel( + operation: "uninstall", + code: "plan_stale", + message: "Review and regenerate the uninstall plan before running it." + ) + return + } + guard uninstallState == .planReady else { + return + } + resetRunState() + selectedWorkflow = .uninstall + uninstallState = .running + uninstallResult = nil + backend.run( + operation: "uninstall", + params: OperationParams.uninstallRun( + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ) + ) + } + + func refreshFsckTargets(password: String) { + guard let mountWaitValue else { + failLocally(workflow: .fsck, message: "Mount wait must be a non-negative integer.") + return + } + resetRunState() + selectedWorkflow = .fsck + fsckState = .loading + fsckTargets = [] + selectedFsckTargetID = nil + fsckPlan = nil + fsckResult = nil + backend.run(operation: "fsck", params: OperationParams.fsckList(mountWait: Double(mountWaitValue), password: password)) + } + + func planFsck(password: String) { + guard let options = currentOptions else { + failLocally(workflow: .fsck, message: "Mount wait must be a non-negative integer.") + return + } + guard let target = selectedFsckTarget else { + failLocally(workflow: .fsck, message: "Select a mounted HFS volume before planning fsck.") + return + } + resetRunState() + selectedWorkflow = .fsck + fsckState = .planning + fsckPlan = nil + fsckResult = nil + plannedFsckOptions = options + plannedFsckTargetID = target.id + backend.run( + operation: "fsck", + params: OperationParams.fsckPlan( + volume: target.volumeParam, + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ) + ) + } + + func runFsck(password: String) { + guard let options = plannedFsckOptions, + let target = selectedFsckTarget, + selectedFsckTargetID == plannedFsckTargetID, + currentOptions == options, + fsckPlan != nil else { + fsckState = .planStale + error = BackendErrorViewModel( + operation: "fsck", + code: "plan_stale", + message: "Review and regenerate the fsck plan before running it." + ) + return + } + guard fsckState == .planReady else { + return + } + resetRunState() + selectedWorkflow = .fsck + fsckState = .running + fsckResult = nil + backend.run( + operation: "fsck", + params: OperationParams.fsckRun( + volume: target.volumeParam, + noReboot: options.noReboot, + noWait: options.noWait, + mountWait: Double(options.mountWait), + password: password + ) + ) + } + + func scanRepairXattrs() { + guard !trimmedRepairPath.isEmpty else { + failLocally(workflow: .repairXattrs, message: "Choose a mounted SMB share path before scanning.") + return + } + resetRunState() + selectedWorkflow = .repairXattrs + repairState = .scanning + repairScan = nil + repairResult = nil + scannedRepairPath = trimmedRepairPath + backend.run(operation: "repair-xattrs", params: OperationParams.repairXattrsScan(path: trimmedRepairPath)) + } + + func runRepairXattrs() { + guard canRepairXattrs else { + repairState = .scanStale + error = BackendErrorViewModel( + operation: "repair-xattrs", + code: "scan_stale", + message: "Run a fresh xattr scan before repairing." + ) + return + } + resetRunState() + selectedWorkflow = .repairXattrs + repairState = .repairing + repairResult = nil + backend.run(operation: "repair-xattrs", params: OperationParams.repairXattrsRun(path: trimmedRepairPath)) + } + + func clear() { + backend.clear() + lastProcessedEventCount = 0 + activateState = .idle + uninstallState = .idle + fsckState = .idle + repairState = .idle + activationPlan = nil + activationResult = nil + uninstallPlan = nil + uninstallResult = nil + fsckTargets = [] + selectedFsckTargetID = nil + fsckPlan = nil + fsckResult = nil + repairScan = nil + repairResult = nil + currentStage = nil + error = nil + plannedUninstallOptions = nil + plannedFsckOptions = nil + plannedFsckTargetID = nil + scannedRepairPath = nil + } + + func cancel() { + backend.cancel() + } + + private var currentOptions: MaintenanceOptions? { + guard let mountWaitValue else { + return nil + } + return MaintenanceOptions(noReboot: noReboot, noWait: noWait, mountWait: mountWaitValue) + } + + private var trimmedRepairPath: String { + repairPath.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func resetRunState() { + backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard ["activate", "uninstall", "fsck", "repair-xattrs"].contains(event.operation) else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + if event.operation == "activate", activateState == .awaitingConfirmation { + activateState = .running + } else if event.operation == "uninstall", uninstallState == .awaitingConfirmation { + uninstallState = .running + } else if event.operation == "fsck", fsckState == .awaitingConfirmation { + fsckState = .running + } else if event.operation == "repair-xattrs", repairState == .awaitingConfirmation { + repairState = .repairing + } + return + } + + if event.type == "error" { + applyError(event) + return + } + + guard event.type == "result" else { + return + } + + if event.ok == false { + applyFalseResult(event) + return + } + + switch event.operation { + case "activate": + handleActivateResult(event) + case "uninstall": + handleUninstallResult(event) + case "fsck": + handleFsckResult(event) + case "repair-xattrs": + handleRepairResult(event) + default: + break + } + } + + private func handleActivateResult(_ event: BackendEvent) { + if activateState == .planning { + do { + activationPlan = try event.decodePayload(ActivationPlanPayload.self) + activateState = .planReady + } catch { + failContract(workflow: .activate, error: error) + } + return + } + do { + activationResult = try event.decodePayload(ActivationResultPayload.self) + activateState = .succeeded + error = nil + } catch { + failContract(workflow: .activate, error: error) + } + } + + private func handleUninstallResult(_ event: BackendEvent) { + if uninstallState == .planning { + do { + uninstallPlan = try event.decodePayload(UninstallPlanPayload.self) + uninstallState = .planReady + } catch { + failContract(workflow: .uninstall, error: error) + } + return + } + do { + uninstallResult = try event.decodePayload(MaintenanceResultPayload.self) + uninstallState = .succeeded + error = nil + } catch { + failContract(workflow: .uninstall, error: error) + } + } + + private func handleFsckResult(_ event: BackendEvent) { + switch fsckState { + case .loading: + do { + let payload = try event.decodePayload(FsckVolumeListPayload.self) + fsckTargets = payload.targets.map(FsckTargetViewModel.init) + selectedFsckTargetID = fsckTargets.count == 1 ? fsckTargets[0].id : nil + fsckState = .listReady + error = nil + } catch { + failContract(workflow: .fsck, error: error) + } + case .planning: + do { + fsckPlan = try event.decodePayload(FsckPlanPayload.self) + fsckState = .planReady + error = nil + } catch { + failContract(workflow: .fsck, error: error) + } + default: + do { + fsckResult = try event.decodePayload(FsckResultPayload.self) + fsckState = .succeeded + error = nil + } catch { + failContract(workflow: .fsck, error: error) + } + } + } + + private func handleRepairResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(RepairXattrsPayload.self) + if repairState == .scanning { + repairScan = payload + repairState = .scanReady + } else { + repairResult = payload + repairState = .repaired + } + error = nil + } catch { + failContract(workflow: .repairXattrs, error: error) + } + } + + private func applyError(_ event: BackendEvent) { + if event.code == "confirmation_required" { + error = nil + switch event.operation { + case "activate": + activateState = .awaitingConfirmation + case "uninstall": + uninstallState = .awaitingConfirmation + case "fsck": + fsckState = .awaitingConfirmation + case "repair-xattrs": + repairState = .awaitingConfirmation + default: + break + } + return + } + error = BackendErrorViewModel(event: event) + failState(for: event.operation) + } + + private func applyFalseResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + failState(for: event.operation) + } + + private func failContract(workflow: MaintenanceWorkflow, error: Error) { + self.error = BackendErrorViewModel( + operation: operationName(for: workflow), + code: "contract_decode_failed", + message: error.localizedDescription + ) + setState(.failed, for: workflow) + } + + private func failLocally(workflow: MaintenanceWorkflow, message: String) { + error = BackendErrorViewModel( + operation: operationName(for: workflow), + code: "validation_failed", + message: message + ) + selectedWorkflow = workflow + currentStage = nil + setState(.failed, for: workflow) + } + + private func failState(for operation: String) { + switch operation { + case "activate": + activateState = .failed + case "uninstall": + uninstallState = .failed + case "fsck": + fsckState = .failed + case "repair-xattrs": + repairState = .failed + default: + break + } + } + + private func setState(_ state: MaintenanceOperationState, for workflow: MaintenanceWorkflow) { + switch workflow { + case .activate: + activateState = state + case .uninstall: + uninstallState = state + case .fsck: + fsckState = state + case .repairXattrs: + repairState = state + } + } + + private func operationName(for workflow: MaintenanceWorkflow) -> String { + switch workflow { + case .activate: + return "activate" + case .uninstall: + return "uninstall" + case .fsck: + return "fsck" + case .repairXattrs: + return "repair-xattrs" + } + } + + private func markPlansStaleForOptionChange() { + if uninstallState == .planReady, currentOptions != plannedUninstallOptions { + uninstallState = .planStale + } + markFsckPlanStaleIfNeeded() + } + + private func markFsckPlanStaleIfNeeded() { + if fsckState == .planReady, + currentOptions != plannedFsckOptions || selectedFsckTargetID != plannedFsckTargetID { + fsckState = .planStale + } + } + + private func markRepairStaleForPathChange() { + if repairState == .scanReady, scannedRepairPath != trimmedRepairPath { + repairState = .scanStale + } + } + + private func nonNegativeInteger(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), value >= 0 else { + return nil + } + return value + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift new file mode 100644 index 00000000..777d65b2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift @@ -0,0 +1,298 @@ +import SwiftUI + +struct MaintenanceView: View { + @ObservedObject var store: MaintenanceStore + @Binding var password: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.string("screen.maintenance")) + .font(.title2.weight(.semibold)) + + Picker("Maintenance", selection: $store.selectedWorkflow) { + ForEach(MaintenanceWorkflow.allCases) { workflow in + Text(workflow.title).tag(workflow) + } + } + .pickerStyle(.segmented) + + sharedOptions + + switch store.selectedWorkflow { + case .activate: + activatePanel + case .uninstall: + uninstallPanel + case .fsck: + fsckPanel + case .repairXattrs: + repairPanel + } + + if let stage = store.currentStage { + StageLine(stage: stage) + } + + if let error = store.error { + MaintenanceErrorView(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var sharedOptions: some View { + HStack { + TextField(L10n.string("field.mount_wait"), text: $store.mountWait) + .frame(width: 150) + Toggle(L10n.string("toggle.no_reboot"), isOn: $store.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $store.noWait) + } + } + + private var activatePanel: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + store.planActivation(password: password) + } label: { + Label("Plan Activation", systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning) + + Button { + store.runActivation(password: password) + } label: { + Label(L10n.string("button.activate"), systemImage: "power") + } + .disabled(!store.canRunActivation) + + StatusLabel(state: store.activateState) + } + + if let plan = store.activationPlan { + Text("\(plan.actions.count) action(s), \(plan.postActivationChecks.count) post-check(s)") + .font(.caption) + .foregroundStyle(.secondary) + } + if let result = store.activationResult { + Text(result.summary) + .font(.caption) + if let message = result.message { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + private var uninstallPanel: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + store.planUninstall(password: password) + } label: { + Label(L10n.string("button.uninstall_plan"), systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + + Button { + store.runUninstall(password: password) + } label: { + Label(L10n.string("button.uninstall"), systemImage: "xmark.bin.fill") + } + .disabled(!store.canRunUninstall) + + StatusLabel(state: store.uninstallState) + } + + if let plan = store.uninstallPlan { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text("Host").foregroundStyle(.secondary) + Text(plan.host) + } + GridRow { + Text("Reboot").foregroundStyle(.secondary) + Text(plan.requiresReboot ? "required" : "not required") + } + GridRow { + Text("Payload Dirs").foregroundStyle(.secondary) + Text(plan.payloadDirs.joined(separator: ", ")) + .lineLimit(1) + .truncationMode(.middle) + } + } + .font(.caption) + } + if let result = store.uninstallResult { + Text("\(result.summary) rebooted: \(yesNo(result.rebooted)), waited: \(yesNo(result.waited)), verified: \(yesNo(result.verified))") + .font(.caption) + } + } + } + + private var fsckPanel: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + store.refreshFsckTargets(password: password) + } label: { + Label(L10n.string("button.list_fsck_volumes"), systemImage: "list.bullet.rectangle") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + + Button { + store.planFsck(password: password) + } label: { + Label(L10n.string("button.plan_fsck"), systemImage: "doc.text.magnifyingglass") + } + .disabled(!store.canPlanFsck) + + Button { + store.runFsck(password: password) + } label: { + Label(L10n.string("button.run_fsck"), systemImage: "externaldrive.badge.checkmark") + } + .disabled(!store.canRunFsck) + + StatusLabel(state: store.fsckState) + } + + if !store.fsckTargets.isEmpty { + Picker("Volume", selection: $store.selectedFsckTargetID) { + Text("Select volume").tag(Optional.none) + ForEach(store.fsckTargets) { target in + Text("\(target.device) on \(target.mountpoint)").tag(Optional(target.id)) + } + } + .frame(maxWidth: 520) + } + if let plan = store.fsckPlan { + Text("Plan: \(plan.device) on \(plan.mountpoint), reboot: \(yesNo(plan.rebootRequired)), wait: \(yesNo(plan.waitAfterReboot))") + .font(.caption) + } + if let result = store.fsckResult { + Text("Result: \(result.device) return \(result.returncode.map(String.init) ?? "n/a"), waited: \(yesNo(result.waited)), verified: \(yesNo(result.verified))") + .font(.caption) + } + } + } + + private var repairPanel: some View { + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.string("field.repair_xattrs_path"), text: $store.repairPath) + HStack { + Button { + store.scanRepairXattrs() + } label: { + Label(L10n.string("button.scan_xattrs"), systemImage: "wand.and.stars") + } + .disabled(store.isRunning || store.repairPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button { + store.runRepairXattrs() + } label: { + Label(L10n.string("button.repair_xattrs"), systemImage: "wand.and.stars.inverse") + } + .disabled(!store.canRepairXattrs) + + StatusLabel(state: store.repairState) + } + + if let scan = store.repairScan { + Text("Scan: \(scan.findingCount) finding(s), \(scan.repairableCount) repairable.") + .font(.caption) + if let report = scan.report, !report.isEmpty { + Text(report) + .font(.system(.caption, design: .monospaced)) + .lineLimit(4) + .foregroundStyle(.secondary) + } + } + if let result = store.repairResult { + Text("Repair: \(result.summary)") + .font(.caption) + } + } + } + + private func yesNo(_ value: Bool?) -> String { + value == true ? "yes" : "no" + } +} + +private struct StatusLabel: View { + let state: MaintenanceOperationState + + var body: some View { + Label(state.title, systemImage: icon) + .foregroundStyle(color) + } + + private var icon: String { + switch state { + case .idle: + return "circle" + case .loading, .planning, .scanning, .running, .repairing: + return "hourglass" + case .listReady, .planReady, .scanReady, .succeeded, .repaired: + return "checkmark.circle" + case .planStale, .scanStale, .awaitingConfirmation: + return "exclamationmark.circle" + case .failed: + return "exclamationmark.triangle" + } + } + + private var color: Color { + switch state { + case .listReady, .planReady, .scanReady, .succeeded, .repaired: + return .green + case .planStale, .scanStale, .awaitingConfirmation: + return .orange + case .failed: + return .red + default: + return .secondary + } + } +} + +private struct 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) + } + } + } +} + +private struct MaintenanceErrorView: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift index 2ece6104..1815c62e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -104,8 +104,12 @@ enum OperationParams { ], password: password) } + static func activatePlan(password: String) -> [String: JSONValue] { + withCredentials(["dry_run": .bool(true)], password: password) + } + static func activateRun(password: String) -> [String: JSONValue] { - withCredentials([:], password: password) + withCredentials(["dry_run": .bool(false)], password: password) } static func fsckList(mountWait: Double, password: String) -> [String: JSONValue] { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift new file mode 100644 index 00000000..7817e22c --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift @@ -0,0 +1,592 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class MaintenanceStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(MaintenanceOperationState.allCases, [ + .idle, + .loading, + .listReady, + .planning, + .planReady, + .planStale, + .scanning, + .scanReady, + .scanStale, + .awaitingConfirmation, + .running, + .repairing, + .succeeded, + .repaired, + .failed + ]) + XCTAssertEqual(MaintenanceWorkflow.allCases, [.activate, .uninstall, .fsck, .repairXattrs]) + } + + func testActivationPlanAndAlreadyActiveResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "activate", stage: "build_activation_plan", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationResultPayload(alreadyActive: true)) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "pw") + + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "build_activation_plan") + XCTAssertEqual(store.activationPlan?.actions.count, 1) + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + + store.runActivation(password: "pw2") + + try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } + XCTAssertEqual(store.activationResult?.alreadyActive, true) + XCTAssertEqual(runner.calls[1].params["dry_run"], .bool(false)) + XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) + } + + func testActivationRequiresPlanAndHandlesConfirmationReplay() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "activate", id: "activate-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "stage", operation: "activate", stage: "run_activation", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationResultPayload(alreadyActive: false)) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + store.runActivation(password: "pw") + XCTAssertEqual(store.activateState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + + store.planActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .planReady && !store.isRunning } + store.runActivation(password: "pw") + try await waitUntilStoreState { store.activateState == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.activateState == .succeeded && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "run_activation") + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("activate-confirm")) + } + + func testActivationBackendErrorAndMalformedPayloadFail() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "activate", + code: "unsupported_device", + message: "NetBSD4 activation is not available.", + recovery: recoveryValue(title: "Activation unavailable", actions: ["Use deploy instead."], suggestedOperation: "deploy") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "") + try await waitUntilStoreState { store.activateState == .failed && !store.isRunning } + XCTAssertEqual(store.error?.code, "unsupported_device") + XCTAssertEqual(store.error?.recovery?.title, "Activation unavailable") + + store.planActivation(password: "") + try await waitUntilStoreState { store.activateState == .failed && store.error?.code == "contract_decode_failed" && !store.isRunning } + } + + func testUninstallPlanStaleRunAndBackendError() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallResultPayload(waited: false, verified: false)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "uninstall", + code: "remote_error", + message: "uninstall failed", + recovery: recoveryValue(title: "Uninstall failed", actions: ["Retry."], suggestedOperation: "uninstall") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.mountWait = "15" + store.noReboot = true + + store.planUninstall(password: "pw") + + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + XCTAssertEqual(store.uninstallPlan?.payloadDirs, ["/Volumes/dk2/.samba4"]) + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[0].params["mount_wait"], .number(15)) + + store.noWait = true + XCTAssertEqual(store.uninstallState, .planStale) + store.runUninstall(password: "pw") + XCTAssertEqual(store.error?.code, "plan_stale") + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .succeeded && !store.isRunning } + XCTAssertEqual(store.uninstallResult?.waited, false) + XCTAssertEqual(store.uninstallResult?.verified, false) + XCTAssertEqual(runner.calls[2].params["dry_run"], .bool(false)) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .failed } + XCTAssertEqual(store.error?.code, "remote_error") + XCTAssertEqual(store.error?.recovery?.title, "Uninstall failed") + } + + func testUninstallInvalidMountWaitAndMalformedPlanFail() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + store.mountWait = "bad" + + store.planUninstall(password: "") + + XCTAssertEqual(store.uninstallState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(runner.calls, []) + + store.mountWait = "30" + store.planUninstall(password: "") + + try await waitUntilStoreState { store.uninstallState == .failed && store.error?.code == "contract_decode_failed" && !store.isRunning } + } + + func testUninstallConfirmationReplayCompletes() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "uninstall", id: "uninstall-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "stage", operation: "uninstall", stage: "remove_payload", risk: "remote_write", cancellable: false), + BackendEvent(type: "result", operation: "uninstall", ok: true, payload: uninstallResultPayload(waited: true, verified: true)) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + store.planUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .planReady && !store.isRunning } + store.runUninstall(password: "pw") + try await waitUntilStoreState { store.uninstallState == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.uninstallState == .succeeded && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "remove_payload") + XCTAssertEqual(store.uninstallResult?.verified, true) + XCTAssertEqual(runner.calls[2].params["confirmation_id"], .string("uninstall-confirm")) + } + + func testFsckListPlanStaleAndRunConfirmation() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [fsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + ]), + .init(events: [ + confirmationRequired(operation: "fsck", id: "fsck-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckResultPayload(returncode: 0)) + ]) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + + store.refreshFsckTargets(password: "pw") + + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + XCTAssertEqual(store.fsckTargets.count, 1) + XCTAssertEqual(store.selectedFsckTarget?.name, "Data") + XCTAssertEqual(runner.calls[0].params["list_volumes"], .bool(true)) + + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + XCTAssertEqual(store.fsckPlan?.device, "/dev/dk2") + XCTAssertEqual(runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(runner.calls[1].params["volume"], .string("/dev/dk2")) + + store.noWait = true + XCTAssertEqual(store.fsckState, .planStale) + store.planFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + store.runFsck(password: "pw") + try await waitUntilStoreState { store.fsckState == .awaitingConfirmation && backend.pendingConfirmation != nil } + + backend.confirmPending() + + try await waitUntilStoreState { store.fsckState == .succeeded } + XCTAssertEqual(store.fsckResult?.returncode, 0) + XCTAssertEqual(runner.calls[4].params["confirmation_id"], .string("fsck-confirm")) + } + + func testFsckEmptyListPlanValidationAndFalseResult() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [fsckTargetPayload(name: "Data")])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: false, payload: fsckResultPayload(returncode: 1)) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + XCTAssertEqual(store.fsckTargets, []) + + store.planFsck(password: "") + XCTAssertEqual(store.fsckState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .listReady && store.fsckTargets.count == 1 && !store.isRunning } + store.planFsck(password: "") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + store.runFsck(password: "") + try await waitUntilStoreState { store.fsckState == .failed } + XCTAssertEqual(store.error?.code, "operation_failed") + } + + func testFsckFallbackVolumeParamTargetChangeBackendErrorAndMalformedPayloads() async throws { + let targetWithoutName = fsckTargetPayload(name: nil, device: "/dev/dk3", mountpoint: "/Volumes/External") + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckListPayload(targets: [ + targetWithoutName, + fsckTargetPayload(name: "Data") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: fsckPlanPayload(target: targetWithoutName, device: "/dev/dk3", mountpoint: "/Volumes/External")) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "fsck", + code: "validation_failed", + message: "No HFS volume selected.", + recovery: recoveryValue(title: "Select a volume", actions: ["List volumes again."], suggestedOperation: "fsck") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "fsck", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .listReady && !store.isRunning } + XCTAssertNil(store.selectedFsckTargetID) + store.selectedFsckTargetID = store.fsckTargets[0].id + + store.planFsck(password: "") + try await waitUntilStoreState { store.fsckState == .planReady && !store.isRunning } + XCTAssertEqual(runner.calls[1].params["volume"], .string("/dev/dk3")) + + store.selectedFsckTargetID = store.fsckTargets[1].id + XCTAssertEqual(store.fsckState, .planStale) + store.runFsck(password: "") + XCTAssertEqual(store.error?.code, "plan_stale") + + store.planFsck(password: "") + try await waitUntilStoreState { store.fsckState == .failed } + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.recovery?.title, "Select a volume") + + store.refreshFsckTargets(password: "") + try await waitUntilStoreState { store.fsckState == .failed && store.error?.code == "contract_decode_failed" } + } + + func testRepairXattrsScanRepairStaleConfirmationAndBackendError() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "repair-xattrs", stage: "scan_findings", risk: "local_read", cancellable: true), + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 1)) + ]), + .init(events: [ + confirmationRequired(operation: "repair-xattrs", id: "repair-confirm") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 2, repairable: 0)) + ]), + .init(events: [ + BackendEvent( + type: "error", + operation: "repair-xattrs", + code: "validation_failed", + message: "repair-xattrs must run on macOS", + recovery: recoveryValue(title: "repair-xattrs cannot run", actions: ["Run this from macOS."], suggestedOperation: "repair-xattrs") + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let backend = BackendClient(runner: runner) + let store = MaintenanceStore(backend: backend) + store.repairPath = "/Volumes/Data" + + store.scanRepairXattrs() + + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + XCTAssertEqual(store.currentStage?.stage, "scan_findings") + XCTAssertTrue(store.canRepairXattrs) + XCTAssertEqual(runner.calls[0].params["dry_run"], .bool(true)) + + store.repairPath = "/Volumes/Other" + XCTAssertEqual(store.repairState, .scanStale) + store.repairPath = "/Volumes/Data" + store.runRepairXattrs() + XCTAssertEqual(store.repairState, .scanStale) + XCTAssertEqual(store.error?.code, "scan_stale") + XCTAssertEqual(runner.calls.count, 1) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady && !store.isRunning } + store.runRepairXattrs() + try await waitUntilStoreState { store.repairState == .awaitingConfirmation && backend.pendingConfirmation != nil } + backend.confirmPending() + try await waitUntilStoreState { store.repairState == .repaired } + XCTAssertEqual(store.repairResult?.repairableCount, 0) + XCTAssertEqual(runner.calls[3].params["confirmation_id"], .string("repair-confirm")) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .failed } + XCTAssertEqual(store.error?.code, "validation_failed") + XCTAssertEqual(store.error?.recovery?.title, "repair-xattrs cannot run") + } + + func testRepairXattrsMissingPathZeroRepairableAndMalformedPayload() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: repairPayload(findings: 0, repairable: 0)) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "repair-xattrs", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.scanRepairXattrs() + XCTAssertEqual(store.repairState, .failed) + XCTAssertEqual(store.error?.code, "validation_failed") + + store.repairPath = "/Volumes/Data" + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .scanReady } + XCTAssertFalse(store.canRepairXattrs) + + store.scanRepairXattrs() + try await waitUntilStoreState { store.repairState == .failed && store.error?.code == "contract_decode_failed" } + } + + func testClearResetsMaintenanceState() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "activate", ok: true, payload: activationPlanPayload()) + ]) + ]) + let store = MaintenanceStore(backend: BackendClient(runner: runner)) + + store.planActivation(password: "") + try await waitUntilStoreState { store.activateState == .planReady } + store.clear() + + XCTAssertEqual(store.activateState, .idle) + XCTAssertEqual(store.uninstallState, .idle) + XCTAssertEqual(store.fsckState, .idle) + XCTAssertEqual(store.repairState, .idle) + XCTAssertNil(store.activationPlan) + XCTAssertNil(store.uninstallPlan) + XCTAssertNil(store.fsckPlan) + XCTAssertNil(store.repairScan) + XCTAssertNil(store.error) + XCTAssertNil(store.currentStage) + } + + private func confirmationRequired(operation: String, id: String) -> BackendEvent { + BackendEvent( + type: "error", + operation: operation, + code: "confirmation_required", + message: "Confirm \(operation).", + details: .object([ + "title": .string("Confirm \(operation)"), + "message": .string("Confirm \(operation)."), + "action_title": .string("Confirm"), + "confirmation_id": .string(id) + ]) + ) + } + + private func activationPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "actions": .array([.object(["type": .string("run_script")])]), + "post_activation_checks": .array([ + .object(["id": .string("runtime_ready"), "description": .string("runtime ready")]) + ]), + "counts": .object(["actions": .number(1)]), + "summary": .string("NetBSD4 activation dry-run plan generated.") + ]) + } + + private func activationResultPayload(alreadyActive: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "already_active": .bool(alreadyActive), + "summary": .string(alreadyActive ? "NetBSD4 payload was already active." : "NetBSD4 activation completed.") + ]) + } + + private func uninstallPlanPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_roots": .array([.string("/Volumes/dk2")]), + "payload_dirs": .array([.string("/Volumes/dk2/.samba4")]), + "remote_actions": .array([.object(["type": .string("remove_path")])]), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "post_uninstall_checks": .array([ + .object(["id": .string("managed_files_absent"), "description": .string("managed files absent")]) + ]), + "counts": .object(["payload_dirs": .number(1)]), + "summary": .string("uninstall dry-run plan generated.") + ]) + } + + private func uninstallResultPayload(waited: Bool, verified: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "summary": .string(verified ? "uninstall completed." : "uninstall completed without post-reboot verification."), + "requires_reboot": .bool(true), + "rebooted": .bool(false), + "reboot_requested": .bool(true), + "waited": .bool(waited), + "verified": .bool(verified) + ]) + } + + private func fsckListPayload(targets: [JSONValue]) -> JSONValue { + .object([ + "schema_version": .number(1), + "targets": .array(targets), + "counts": .object(["targets": .number(Double(targets.count))]), + "summary": .string("found \(targets.count) mounted HFS volume(s).") + ]) + } + + private func fsckTargetPayload( + name: String?, + device: String = "/dev/dk2", + mountpoint: String = "/Volumes/dk2" + ) -> JSONValue { + var payload: [String: JSONValue] = [ + "device": .string(device), + "mountpoint": .string(mountpoint), + "builtin": .bool(true) + ] + if let name { + payload["name"] = .string(name) + } + return .object(payload) + } + + private func fsckPlanPayload( + target: JSONValue? = nil, + device: String = "/dev/dk2", + mountpoint: String = "/Volumes/dk2" + ) -> JSONValue { + .object([ + "schema_version": .number(1), + "target": target ?? fsckTargetPayload(name: "Data"), + "device": .string(device), + "mountpoint": .string(mountpoint), + "reboot_required": .bool(true), + "wait_after_reboot": .bool(false), + "summary": .string("fsck dry-run plan generated.") + ]) + } + + private func fsckResultPayload(returncode: Int) -> JSONValue { + .object([ + "schema_version": .number(1), + "device": .string("/dev/dk2"), + "mountpoint": .string("/Volumes/dk2"), + "returncode": .number(Double(returncode)), + "reboot_requested": .bool(false), + "waited": .bool(false), + "verified": .bool(false), + "summary": .string("fsck completed.") + ]) + } + + private func repairPayload(findings: Int, repairable: Int) -> JSONValue { + .object([ + "schema_version": .number(1), + "returncode": .number(0), + "root": .string("/Volumes/Data"), + "finding_count": .number(Double(findings)), + "repairable_count": .number(Double(repairable)), + "counts": .object([ + "findings": .number(Double(findings)), + "repairable": .number(Double(repairable)) + ]), + "stats": .object([:]), + "report": .string("report"), + "summary": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable."), + "summary_text": .string("repair-xattrs found \(findings) issue(s), \(repairable) repairable.") + ]) + } +} From 30d69e29f2dae39cc165ba64da28b528c92623da Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 19:40:47 -0700 Subject: [PATCH 16/20] Implement bundled GUI runtime readiness foundation --- gui.md | 778 ++++++++++++++++++ .../TimeCapsuleSMBApp/AppReadinessStore.swift | 292 +++++++ .../TimeCapsuleSMBApp/BundleLayout.swift | 152 ++++ .../TimeCapsuleSMBApp/ContentView.swift | 205 ++++- .../TimeCapsuleSMBApp/HelperLocator.swift | 76 +- .../AppReadinessStoreTests.swift | 287 +++++++ .../BundleLayoutTests.swift | 100 +++ .../HelperLocatorTests.swift | 101 +++ macos/TimeCapsuleSMB/tools/package_app.py | 220 +++++ 9 files changed, 2184 insertions(+), 27 deletions(-) create mode 100644 gui.md create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift create mode 100755 macos/TimeCapsuleSMB/tools/package_app.py diff --git a/gui.md b/gui.md new file mode 100644 index 00000000..5b035a3d --- /dev/null +++ b/gui.md @@ -0,0 +1,778 @@ +# TimeCapsuleSMB GUI UX Brainstorm + +This document describes what the macOS GUI should feel like and how its user +experience should be shaped. It is based on the CLI product surface and README, +translated into a native app product surface. + +## Product Direction + +The app should feel like a device manager for old Time Capsules, not like a +terminal wrapper. + +The main user job is: + +1. Find one or more Time Capsules on the network. +2. Save them as named devices. +3. Install or update modern SMB support. +4. Verify Finder and Time Machine readiness. +5. Recover from common disk, metadata, Bonjour, SSH, reboot, or NetBSD4 issues. +6. Remove the install safely if desired. + +The app should not expose repo-oriented setup commands. `bootstrap`, `paths`, +and `validate-install` should run as app readiness checks in the background. +Normal users should never see those as actions. If the bundled app is damaged or +missing binaries, the app should say the app install is damaged and point the +user to reinstall the app. + +The app should support multiple saved Time Capsules from the beginning. A user +may own more than one unit, may test Gen 5 and Gen 1-4 devices side by side, or +may need to manage a friend's device temporarily. + +## Visual Tone + +This should be a quiet Mac utility: + +- sidebar + detail layout +- dense but readable status rows +- clear progress timelines for long operations +- simple colored health badges +- native controls and sheets +- no decorative landing page +- no raw JSON as a primary UX +- no "wizard wall of text" + +Use short, concrete text. Prefer device facts and next actions over explanation. +Deep logs, raw events, payload details, and advanced flags should exist, but +behind disclosure controls. + +## App Shell + +Recommended top-level structure: + +- Sidebar + - All Time Capsules + - Add Time Capsule + - Activity + - Settings + - Help + +- Device detail area + - selected device summary + - primary action + - health and warnings + - workflow tabs or sections + +- Bottom or collapsible activity drawer + - latest operation progress + - log lines + - copy diagnostics button + +The sidebar device rows should show: + +- user nickname +- Bonjour/device name +- host or IP +- health badge +- last seen time +- small NetBSD4 marker when relevant + +Example row statuses: + +- Not set up +- Ready to install +- Installing +- Rebooting +- Verifying +- Healthy +- Needs activation +- Warning +- Failed +- Removed +- Offline + +## First Launch + +The first launch should do background app readiness immediately: + +- verify bundled helper/runtime is present +- verify bundled Samba, mDNS, NBNS, scripts, and manifest are present +- check app version support, using cached network metadata when available +- detect host macOS version and Time Machine warning status +- start Bonjour discovery + +The user-facing first screen should be an empty device list with active +discovery results, not a setup checklist. + +Empty state: + +- title: "No Time Capsules saved" +- primary button: "Add Time Capsule" +- secondary button: "Enter Address Manually" +- inline list of discovered candidates if any + +Do not ask the user to run setup or install dependencies. If a required bundled +asset is missing, show a blocking app readiness alert: + +"TimeCapsuleSMB is incomplete. Reinstall the app." + +Advanced details can show the failed checks, but the main remediation should be +reinstalling the app. + +## Multiple Saved Devices + +Each saved device should be a profile with a stable app-level identity. + +User-visible profile fields: + +- nickname +- Bonjour name +- host/IP +- model +- generation +- OS family +- payload family +- last known SMB URL +- last doctor result +- last successful deploy/update time +- NetBSD4 activation reminder status +- flash backup availability if any + +Credentials should live in Keychain. The app should not repeatedly ask for the +password unless the Keychain item is missing or authentication fails. + +The app should allow: + +- rename device +- forget device +- refresh identity +- update saved host/IP +- replace stored password +- duplicate profile is detected and merged or warned + +Discovery should not create profiles automatically. It should present candidates +that can be saved. + +## Add Device Flow + +The add-device flow should be one guided panel with clear stages: + +1. Discover +2. Select +3. Authenticate +4. Enable SSH if needed +5. Identify device +6. Save + +Discovery screen: + +- list AirPort/Time Capsule candidates from Bonjour +- show name, host, IPv4, model hint, and service status +- support manual address entry +- warn when only link-local `169.254.x.x` is available + +Authentication screen: + +- password field labeled "Time Capsule password" +- short note: "This password is also used for SMB login after install." +- "Save in Keychain" should be on by default + +SSH state handling: + +- if SSH is reachable and auth works, continue +- if SSH is closed, explain that the app can enable SSH using the Time Capsule + admin protocol and the device will reboot +- after enabling SSH, show a reboot wait progress state +- if password fails, ask again without saving a broken profile + +Device identity result: + +- model and syAP +- NetBSD version and architecture +- supported/unsupported status +- payload family +- expected behavior: + - Gen 5 / NetBSD 6: persistent install, reboot after deploy + - Gen 1-4 / NetBSD 4: deploy activates now, needs activation after later reboots unless flash patch is used + +Save screen: + +- nickname defaulted from Bonjour name +- primary button: "Save Time Capsule" +- next suggested action: "Install SMB" + +## Device Dashboard + +The device dashboard should answer four questions at a glance: + +- Is this device reachable? +- Is TimeCapsuleSMB installed? +- Is SMB currently working? +- What should I do next? + +Suggested layout: + +- Header + - nickname + - model/generation + - health badge + - last checked + +- Primary action strip + - "Install SMB" for not installed + - "Update SMB" for installed but app bundle has newer payload + - "Run Activation" for NetBSD4 deployed but inactive + - "Open in Finder" for healthy devices + - "Run Checkup" for warning/failed state + +- Health sections + - Connection + - Runtime + - Finder/Bonjour + - SMB auth + - Time Machine + +- Secondary actions + - Maintenance + - Uninstall + - Advanced + +The dashboard should run a lightweight refresh when selected. Full doctor can be +manual or automatically offered after deploy/update. + +## Known macOS Time Machine Warnings + +The app should proactively warn when the host macOS version is known to have +Time Machine network backup issues. + +Known warning policy: + +- macOS 15.7.5 +- macOS 15.7.6 +- macOS 15.7.7 +- macOS 26.4.x + +Warning behavior: + +- show a top-level banner on launch when the current Mac matches +- repeat the warning before deploy verification if the user expects Time Machine + validation +- do not block installation +- make clear that normal Finder SMB file sharing can still work +- make clear that Time Machine failure on this Mac may be a macOS issue, not a + TimeCapsuleSMB install failure + +Suggested text: + +"This macOS version has known Time Machine network backup issues. Finder SMB +access may still work, but Time Machine validation may fail on this Mac. Use a +different macOS version or update macOS before treating Time Machine failure as a +device problem." + +This should be data-driven so a later app update can change the warning list +without redesigning the UI. + +## Install And Update UX + +The deploy CLI should become an "Install SMB" or "Update SMB" workflow. + +The workflow should always start with a plan. + +Plan screen should show: + +- target device +- detected generation and OS +- payload family +- install location on disk +- files to upload, summarized +- mDNS/NBNS behavior +- reboot behavior +- NetBSD4 activation behavior +- expected downtime +- whether Time Machine warning applies on this Mac + +The normal user should see: + +- "This will install Samba 4.24.1 on the Time Capsule." +- "The device will reboot and may be unavailable for several minutes." +- "After it returns, the app will verify Finder and SMB access." + +Advanced disclosure should show: + +- upload count +- boot files +- payload directory +- selected volume +- mount wait setting +- NBNS toggle +- debug logging toggle + +Deploy progress should be a timeline: + +- Preparing +- Checking device +- Checking bundled files +- Finding disk +- Building plan +- Uploading +- Syncing to disk +- Rebooting or activating +- Waiting for device +- Verifying SMB +- Done + +Post-success screen: + +- show SMB URL +- "Open in Finder" +- "Run Time Machine Check" +- "Run Full Checkup" +- for NetBSD4, show activation reminder: + "This device needs activation after each reboot unless the flash boot hook is patched." + +## Doctor / Checkup UX + +The CLI `doctor` should be a "Checkup" workflow. + +It should group results by domain: + +- App + - bundled files + - local helper/tools + - app version +- Device + - SSH + - model and OS + - payload family + - interface/IP +- Runtime + - Samba process + - TCP 445 + - mDNS takeover + - NBNS if enabled + - persistent xattr database +- Finder/Bonjour + - advertised names + - resolved addresses + - `_smb._tcp` + - `_adisk._tcp` +- SMB + - authenticated listing + - share names + - file operation test +- Time Machine + - share flags + - host macOS warning + +Each check row should have: + +- status icon: pass, warning, fail, info +- human message +- "What to do" action if available +- raw detail disclosure + +Doctor failure should not be a wall of logs. The top should say: + +- "SMB is not running" +- "Bonjour is advertising the wrong name" +- "The disk did not mount" +- "This may be a macOS Time Machine issue" + +Recovery actions should be buttons: + +- Retry Checkup +- Reboot Device +- Run Activation +- Run Disk Repair +- Repair xattrs +- Open Finder to SMB URL +- Copy Diagnostics + +## Maintenance UX + +Maintenance should be available per saved device. It should be visually +separate from the primary install/checkup path because several actions are +destructive or specialized. + +Recommended sections: + +- NetBSD4 Activation +- Disk Repair +- File Metadata Repair +- Uninstall +- Firmware Flash, disabled or experimental + +### NetBSD4 Activation + +Show this only when the saved or probed device is NetBSD4, or keep it disabled +with an explanation. + +States: + +- not needed +- needs activation +- planning +- ready to activate +- activating +- verifying +- active +- failed + +UX: + +- "Start SMB now" +- dry-run plan shown first +- confirmation required before modifying runtime state +- after success, show "Open in Finder" and "Run Checkup" + +### Disk Repair + +This maps to `fsck`. + +The UX should be careful because it can stop sharing, unmount disks, run +`fsck_hfs`, and reboot. + +Flow: + +1. List mounted HFS volumes. +2. Select volume. +3. Build repair plan. +4. Confirm. +5. Run repair. +6. Reboot/wait if required. +7. Suggest Checkup. + +Volume picker should show: + +- device path, for example `/dev/dk2` +- mountpoint +- volume name +- internal/external marker + +Default should be conservative: + +- reboot after fsck +- wait for device to return +- do not expose `--no-reboot` and `--no-wait` unless advanced options are shown + +### File Metadata Repair + +This maps to `repair-xattrs`. + +This is a local macOS-side workflow for mounted SMB shares. It should use a path +picker instead of asking users to type paths. + +Flow: + +1. Choose mounted SMB share or folder. +2. Scan. +3. Show findings. +4. Repair known-safe issues. +5. Show summary. + +Defaults: + +- recursive scan on +- skip hidden paths +- skip Time Machine bundles +- do not fix permissions unless advanced +- do not include Time Machine unless advanced and heavily warned + +If the host is not macOS, disable the feature with a simple explanation. + +If no mounted matching share is found, show: + +- "Open in Finder" +- "Choose Folder" +- "Connect to SMB URL" + +### Uninstall + +Uninstall should be a destructive advanced action, but still polished. + +Flow: + +1. Build uninstall plan. +2. Show what will be removed. +3. Confirm. +4. Remove managed files. +5. Reboot or leave running state as explicitly chosen. +6. Verify removal when possible. + +Plan should show: + +- flash hooks to remove +- payload directories to remove +- whether reboot is required +- whether post-reboot verification will run + +Default should be reboot and verify. `No reboot` should be advanced. + +## Flash UX + +Flash should be planned now, but disabled before release unless it has gone +through separate acceptance testing. + +Product label: + +"Persistent NetBSD4 Boot Hook" + +Do not call the main entry point "flash" in the normal UI. The word can appear +inside advanced details. + +Release gating: + +- hidden by default +- visible only in an Advanced or Experimental section +- write actions disabled in release builds until explicitly enabled +- read-only backup/analyze may be available earlier, but only for NetBSD4 + +Eligibility checks: + +- saved device exists +- device is NetBSD4 +- SSH is reachable and authenticated +- app can read both firmware banks +- app can read ACP checksum properties +- app can identify the active bank or explain ambiguity +- app can classify the live `LOGIN` hook + +Flash landing screen should say: + +"This experimental workflow can back up and inspect the two firmware banks on a +NetBSD4 Time Capsule. Write modes can modify firmware. A failed or interrupted +write can make the device difficult or impossible to recover without hardware +tools." + +Modes: + +- Back Up and Inspect +- Check Against Apple Firmware +- Download Apple Firmware Only +- Patch Boot Hook, disabled by default +- Restore Apple Firmware, disabled by default + +Read-only analysis result should show: + +- backup directory +- primary bank validity +- secondary bank validity +- active bank +- how active bank was selected +- LOGIN classification: stock, patched, unknown +- patch feasibility +- restore feasibility +- Apple firmware match if checked + +Patch plan screen: + +- target bank: primary +- inactive bank remains untouched +- backup validity for both banks +- target payload checksum +- warnings +- manual power-cycle requirement + +Restore plan screen: + +- target bank: active bank only +- Apple firmware source/version +- payload checksum +- optional reboot after restore +- post-restore check required + +Write confirmation should be stronger than normal: + +- require explicit checkbox: "I have saved the firmware backup." +- require explicit checkbox: "I understand only the selected bank will be written." +- require typed confirmation such as the device nickname +- show power warning + +After patch write: + +- do not offer software reboot +- show "Unplug the Time Capsule, wait 10 seconds, plug it back in." +- show a timer and then "Run Checkup" +- remind user that one bank was left untouched + +After restore write: + +- allow optional reboot +- suggest "Check Apple Firmware" +- then suggest normal deploy if the user wants TimeCapsuleSMB again + +## Settings + +App-level settings: + +- default Bonjour timeout +- default mount wait +- diagnostics sharing/telemetry preference +- show advanced options +- check for app updates +- Time Machine warning policy version + +Device-level settings: + +- nickname +- host/IP +- stored password status +- NBNS enabled +- debug logging for future deploys +- advanced SSH options, hidden +- forget device + +## Background Jobs + +The app should run these without presenting them as commands: + +- app bundle validation +- payload manifest validation +- version support check +- host macOS warning check +- periodic Bonjour discovery +- lightweight selected-device reachability refresh +- Keychain availability check + +If background jobs fail: + +- app damaged: blocking alert +- update required: blocking or strong warning based on version metadata +- missing optional verification tool: degraded checkup warning, not install blocker +- Bonjour unavailable: non-blocking warning with manual address option + +## User-Facing Copy Principles + +Use familiar words first: + +- "Install SMB" instead of "deploy" +- "Checkup" instead of "doctor" +- "Start SMB" instead of "activate" except in advanced text +- "Disk Repair" instead of `fsck` +- "File Metadata Repair" instead of `repair-xattrs` +- "Persistent NetBSD4 Boot Hook" instead of `flash` + +Use technical names in secondary labels or details so expert users can map GUI +actions back to CLI commands. + +Do not expose implementation path names unless the user opens details. + +## Suggested Screen Map + +```text +All Time Capsules + Device Detail + Overview + Install / Update + Checkup + Maintenance + NetBSD4 Activation + Disk Repair + File Metadata Repair + Uninstall + Firmware Boot Hook (experimental) + Advanced + logs + raw operation events + copy diagnostics + +Add Time Capsule + Discover + Manual Address + Authenticate + Enable SSH + Identify + Save + +Activity + current operation + historical operations + copied diagnostics + +Settings + app defaults + warning policy + updates +``` + +## Important UX States + +Global app states: + +- app ready +- app bundle damaged +- update required +- host macOS has Time Machine warning +- no saved devices +- discovery running +- discovery unavailable + +Device states: + +- discovered unsaved +- saved, unchecked +- password needed +- SSH disabled +- enabling SSH +- rebooting after SSH enable +- unsupported device +- ready to install +- install planned +- installing +- rebooting after install +- verifying after install +- healthy +- warning +- failed +- NetBSD4 activation needed +- removed +- offline + +Operation states: + +- idle +- preparing +- planning +- ready for review +- awaiting confirmation +- running +- waiting for reboot +- verifying +- succeeded +- warning +- failed +- cancelled + +Flash-specific states: + +- unavailable +- disabled in this build +- eligible for read-only analysis +- reading banks +- saving backup +- analyzing banks +- plan available +- write locked +- awaiting strong confirmation +- writing +- readback validating +- write validated +- manual power cycle required +- restore rebooting +- check Apple firmware needed +- failed + +## Release Recommendation + +For the first polished GUI release: + +- include multi-device save/select +- include add-device, install/update, checkup, NetBSD4 activation, disk repair, + xattr repair, and uninstall +- run app readiness in the background +- show macOS Time Machine warning proactively +- include flash read-only planning only if stable enough +- keep flash write actions disabled + +The first release should make the normal Time Capsule owner successful without +teaching them the command set. The advanced tools should be available, but they +should feel like guarded recovery workflows rather than ordinary setup steps. diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift new file mode 100644 index 00000000..fece908d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift @@ -0,0 +1,292 @@ +import Combine +import Foundation + +enum AppReadinessStateKind: String, CaseIterable, Equatable { + case idle + case resolvingBundle + case checkingCapabilities + case validatingInstall + case ready + case degraded + case blocked +} + +struct AppReadinessSummary: Equatable { + let runtimeMode: BundleRuntimeMode + let helperVersion: String + let distributionRoot: String + let validationSummary: String + let validationCounts: [String: Int] +} + +enum AppReadinessState: Equatable { + case idle + case resolvingBundle + case checkingCapabilities + case validatingInstall + case ready(AppReadinessSummary) + case degraded(AppReadinessSummary, [BundleRuntimeIssue]) + case blocked(BundleRuntimeIssue) + + var kind: AppReadinessStateKind { + switch self { + case .idle: + return .idle + case .resolvingBundle: + return .resolvingBundle + case .checkingCapabilities: + return .checkingCapabilities + case .validatingInstall: + return .validatingInstall + case .ready: + return .ready + case .degraded: + return .degraded + case .blocked: + return .blocked + } + } +} + +protocol AppRuntimeResolving { + func resolve(helperPath: String?) throws -> HelperResolution + func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] +} + +extension HelperLocator: AppRuntimeResolving {} + +@MainActor +final class AppReadinessStore: ObservableObject { + @Published private(set) var state: AppReadinessState = .idle + @Published private(set) var capabilities: CapabilitiesPayload? + @Published private(set) var validation: InstallValidationPayload? + @Published private(set) var issues: [BundleRuntimeIssue] = [] + @Published private(set) var currentStage: OperationStageState? + + let backend: BackendClient + + private let runtimeResolver: any AppRuntimeResolving + private let helperPathProvider: () -> String + private var runtimeMode: BundleRuntimeMode = .developmentCheckout + private var pendingOperation: String? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + convenience init(backend: BackendClient) { + self.init( + backend: backend, + runtimeResolver: HelperLocator(), + helperPathProvider: { backend.helperPath } + ) + } + + init( + backend: BackendClient, + runtimeResolver: any AppRuntimeResolving, + helperPathProvider: @escaping () -> String + ) { + self.backend = backend + self.runtimeResolver = runtimeResolver + self.helperPathProvider = helperPathProvider + backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + Task { @MainActor in + self?.runPendingOperation() + } + } + .store(in: &cancellables) + } + + var canRetry: Bool { + !backend.isRunning + } + + func start() { + guard !backend.isRunning else { return } + backend.clear() + capabilities = nil + validation = nil + issues = [] + currentStage = nil + pendingOperation = nil + lastProcessedEventCount = 0 + state = .resolvingBundle + + let helperPath = normalized(helperPathProvider()) + do { + let resolution = try runtimeResolver.resolve(helperPath: helperPath) + runtimeMode = resolution.mode + issues = runtimeResolver.runtimeIssues(for: resolution) + if let blockingIssue = issues.first(where: { $0.severity == .error }) { + state = .blocked(blockingIssue) + return + } + } catch { + state = .blocked(BundleRuntimeIssue( + code: .helperMissing, + severity: .error, + message: error.localizedDescription, + recovery: "Reinstall TimeCapsuleSMB or choose a valid helper in Diagnostics." + )) + return + } + + state = .checkingCapabilities + backend.run(operation: "capabilities") + } + + func clear() { + backend.clear() + capabilities = nil + validation = nil + issues = [] + currentStage = nil + pendingOperation = nil + lastProcessedEventCount = 0 + state = .idle + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard ["capabilities", "validate-install"].contains(event.operation) else { + return + } + + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + + if event.type == "error" { + state = .blocked(issue(from: event)) + return + } + + guard event.type == "result" else { + return + } + + switch event.operation { + case "capabilities": + applyCapabilitiesResult(event) + case "validate-install": + applyValidationResult(event) + default: + break + } + } + + private func applyCapabilitiesResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(CapabilitiesPayload.self) + capabilities = payload + guard event.ok == true else { + state = .blocked(BundleRuntimeIssue( + code: .operationFailed, + severity: .error, + message: payload.summary, + recovery: "Open Diagnostics and retry app readiness." + )) + return + } + pendingOperation = "validate-install" + runPendingOperation() + } catch { + state = .blocked(contractIssue(operation: "capabilities", error: error)) + } + } + + private func applyValidationResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(InstallValidationPayload.self) + validation = payload + guard payload.ok else { + state = .blocked(BundleRuntimeIssue( + code: .installValidationFailed, + severity: .error, + message: payload.summary, + recovery: "Reinstall TimeCapsuleSMB or open Diagnostics for the failed checks." + )) + return + } + finishReady(validation: payload) + } catch { + state = .blocked(contractIssue(operation: "validate-install", error: error)) + } + } + + private func finishReady(validation: InstallValidationPayload) { + let summary = AppReadinessSummary( + runtimeMode: runtimeMode, + helperVersion: capabilities?.helperVersion ?? "", + distributionRoot: capabilities?.distributionRoot ?? "", + validationSummary: validation.summary, + validationCounts: validation.counts + ) + let warnings = issues.filter { $0.severity == .warning } + state = warnings.isEmpty ? .ready(summary) : .degraded(summary, warnings) + } + + private func runPendingOperation() { + guard let operation = pendingOperation, !backend.isRunning else { + return + } + pendingOperation = nil + if operation == "validate-install" { + state = .validatingInstall + } + backend.run(operation: operation) + } + + private func issue(from event: BackendEvent) -> BundleRuntimeIssue { + let code: BundleRuntimeIssueCode + switch event.code { + case "helper_not_found": + code = .helperMissing + case "helper_launch_failed": + code = .helperLaunchFailed + default: + code = .operationFailed + } + return BundleRuntimeIssue( + code: code, + severity: .error, + message: event.message ?? event.summary, + recovery: BackendErrorViewModel(event: event).recovery?.message ?? "Open Diagnostics and retry app readiness." + ) + } + + private func contractIssue(operation: String, error: Error) -> BundleRuntimeIssue { + BundleRuntimeIssue( + code: .contractDecodeFailed, + severity: .error, + message: "\(operation) returned an unexpected payload: \(error.localizedDescription)", + recovery: "Update or reinstall TimeCapsuleSMB so the app and helper use the same API contract." + ) + } + + private func normalized(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift new file mode 100644 index 00000000..bfb51eec --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BundleLayout.swift @@ -0,0 +1,152 @@ +import Foundation + +public enum BundleRuntimeMode: String, CaseIterable, Equatable, Sendable { + case explicit + case productionBundle + case developmentCheckout +} + +public enum BundleRuntimeIssueSeverity: String, CaseIterable, Equatable, Sendable { + case warning + case error +} + +public enum BundleRuntimeIssueCode: String, CaseIterable, Equatable, Sendable { + case helperMissing + case helperNotExecutable + case distributionRootMissing + case toolsDirectoryMissing + case installValidationFailed + case helperLaunchFailed + case contractDecodeFailed + case operationFailed +} + +public struct BundleRuntimeIssue: Identifiable, Equatable, Sendable { + public var id: String { + "\(code.rawValue):\(message)" + } + + public let code: BundleRuntimeIssueCode + public let severity: BundleRuntimeIssueSeverity + public let message: String + public let recovery: String + + public init( + code: BundleRuntimeIssueCode, + severity: BundleRuntimeIssueSeverity, + message: String, + recovery: String + ) { + self.code = code + self.severity = severity + self.message = message + self.recovery = recovery + } +} + +public struct BundleLayout: Equatable, Sendable { + public let appBundleURL: URL + public let executableURL: URL? + public let resourceURL: URL + public let helperURL: URL + public let distributionRootURL: URL + public let toolsBinURL: URL + public let pythonRuntimeURL: URL? + public let applicationSupportURL: URL + public let configURL: URL + public let stateDirectoryURL: URL + + public init( + appBundleURL: URL, + executableURL: URL? = nil, + resourceURL: URL, + helperURL: URL, + distributionRootURL: URL? = nil, + toolsBinURL: URL? = nil, + pythonRuntimeURL: URL? = nil, + applicationSupportURL: URL, + configURL: URL? = nil, + stateDirectoryURL: URL? = nil + ) { + self.appBundleURL = appBundleURL + self.executableURL = executableURL + self.resourceURL = resourceURL + self.helperURL = helperURL + self.distributionRootURL = distributionRootURL ?? resourceURL.appendingPathComponent("Distribution", isDirectory: true) + self.toolsBinURL = toolsBinURL ?? resourceURL.appendingPathComponent("Tools/bin", isDirectory: true) + self.pythonRuntimeURL = pythonRuntimeURL + self.applicationSupportURL = applicationSupportURL + self.configURL = configURL ?? applicationSupportURL.appendingPathComponent(".env") + self.stateDirectoryURL = stateDirectoryURL ?? applicationSupportURL + } + + public static func productionCandidate( + bundle: Bundle = .main, + fileManager: FileManager = .default, + applicationSupportURL: URL? = nil + ) -> BundleLayout? { + let resources = bundle.resourceURL ?? bundle.bundleURL.appendingPathComponent("Contents/Resources", isDirectory: true) + let helper = bundle.bundleURL + .appendingPathComponent("Contents", isDirectory: true) + .appendingPathComponent("Helpers", isDirectory: true) + .appendingPathComponent("tcapsule") + guard let appSupport = applicationSupportURL ?? applicationSupportDirectory(fileManager: fileManager) else { + return nil + } + return BundleLayout( + appBundleURL: bundle.bundleURL, + executableURL: bundle.executableURL, + resourceURL: resources, + helperURL: helper, + applicationSupportURL: appSupport + ) + } + + public static func applicationSupportDirectory(fileManager: FileManager = .default) -> URL? { + fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("TimeCapsuleSMB", isDirectory: true) + } + + public func validationIssues(fileManager: FileManager = .default) -> [BundleRuntimeIssue] { + var issues: [BundleRuntimeIssue] = [] + if !fileManager.fileExists(atPath: helperURL.path) { + issues.append(BundleRuntimeIssue( + code: .helperMissing, + severity: .error, + message: "The bundled TimeCapsuleSMB helper is missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } else if !fileManager.isExecutableFile(atPath: helperURL.path) { + issues.append(BundleRuntimeIssue( + code: .helperNotExecutable, + severity: .error, + message: "The bundled TimeCapsuleSMB helper is not executable.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } + if !isDirectory(distributionRootURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .distributionRootMissing, + severity: .error, + message: "The bundled TimeCapsuleSMB distribution is missing.", + recovery: "Reinstall TimeCapsuleSMB." + )) + } + if !isDirectory(toolsBinURL, fileManager: fileManager) { + issues.append(BundleRuntimeIssue( + code: .toolsDirectoryMissing, + severity: .warning, + message: "Bundled command-line tools are missing.", + recovery: "Some diagnostics may be unavailable until the app bundle is repaired." + )) + } + return issues + } + + private func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 8fd305ec..a2065a07 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -2,19 +2,20 @@ import SwiftUI public struct ContentView: View { @StateObject private var backend: BackendClient - @StateObject private var readinessStore: ReadinessStore + @StateObject private var appReadinessStore: AppReadinessStore @StateObject private var connectionStore: ConnectionWorkflowStore @StateObject private var deployStore: DeployWorkflowStore @StateObject private var doctorStore: DoctorStore @StateObject private var maintenanceStore: MaintenanceStore - @State private var selection: Screen = .readiness + @State private var selection: Screen = .connect + @State private var diagnosticsPresented = false @State private var password = "" @MainActor public init() { let backend = BackendClient() _backend = StateObject(wrappedValue: backend) - _readinessStore = StateObject(wrappedValue: ReadinessStore(backend: backend)) + _appReadinessStore = StateObject(wrappedValue: AppReadinessStore(backend: backend)) _connectionStore = StateObject(wrappedValue: ConnectionWorkflowStore(backend: backend)) _deployStore = StateObject(wrappedValue: DeployWorkflowStore(backend: backend)) _doctorStore = StateObject(wrappedValue: DoctorStore(backend: backend)) @@ -30,12 +31,26 @@ public struct ContentView: View { .navigationTitle("TimeCapsuleSMB") } detail: { VStack(spacing: 0) { - form + if case .blocked = appReadinessStore.state { + AppReadinessBlockedView(store: appReadinessStore) { + diagnosticsPresented = true + } + } else { + AppReadinessBannerView(store: appReadinessStore) { + diagnosticsPresented = true + } + form + } Divider() - EventList(events: backend.events) + EventList(events: visibleEvents) } .toolbar { ToolbarItemGroup { + Button { + diagnosticsPresented = true + } label: { + Label("Diagnostics", systemImage: "wrench.and.screwdriver") + } Button { clearActive() } label: { @@ -52,6 +67,16 @@ public struct ContentView: View { } } .frame(minWidth: 980, minHeight: 680) + .task { + appReadinessStore.start() + } + .sheet(isPresented: $diagnosticsPresented) { + AppDiagnosticsView( + store: appReadinessStore, + events: backend.events, + helperPath: $backend.helperPath + ) + } .alert( backend.pendingConfirmation?.title ?? "", isPresented: confirmationPresented, @@ -82,8 +107,6 @@ public struct ContentView: View { @ViewBuilder private var form: some View { switch selection { - case .readiness: - ReadinessView(store: readinessStore, helperPath: $backend.helperPath) case .connect: ConnectView(store: connectionStore, password: $password) case .deploy: @@ -104,8 +127,6 @@ public struct ContentView: View { private func clearActive() { switch selection { - case .readiness: - readinessStore.clear() case .connect: connectionStore.clear() case .deploy: @@ -119,10 +140,13 @@ public struct ContentView: View { } } + private var visibleEvents: [BackendEvent] { + backend.events.filter { !["capabilities", "validate-install"].contains($0.operation) } + } + } private enum Screen: String, CaseIterable, Identifiable { - case readiness case connect case deploy case doctor @@ -133,7 +157,6 @@ private enum Screen: String, CaseIterable, Identifiable { var title: String { switch self { - case .readiness: return L10n.string("screen.readiness") case .connect: return L10n.string("screen.connect") case .deploy: return L10n.string("screen.deploy") case .doctor: return L10n.string("screen.doctor") @@ -144,7 +167,6 @@ private enum Screen: String, CaseIterable, Identifiable { var icon: String { switch self { - case .readiness: return "checklist" case .connect: return "network" case .deploy: return "square.and.arrow.up" case .doctor: return "stethoscope" @@ -154,6 +176,165 @@ private enum Screen: String, CaseIterable, Identifiable { } } +private struct AppReadinessBannerView: View { + @ObservedObject var store: AppReadinessStore + let showDiagnostics: () -> Void + + var body: some View { + switch store.state { + case .idle, .ready: + EmptyView() + case .resolvingBundle, .checkingCapabilities, .validatingInstall: + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(title) + .font(.caption) + if let stage = store.currentStage?.description ?? store.currentStage?.stage { + Text(stage) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.08)) + case .degraded(_, let issues): + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + Text(issues.first?.message ?? "TimeCapsuleSMB is running with warnings.") + .font(.caption) + Spacer() + Button("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 "Preparing app runtime" + case .checkingCapabilities: + return "Checking helper" + case .validatingInstall: + return "Validating bundled files" + default: + return "" + } + } +} + +private struct AppReadinessBlockedView: View { + @ObservedObject var store: AppReadinessStore + let showDiagnostics: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label("TimeCapsuleSMB cannot start", 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("Retry", systemImage: "arrow.clockwise") + } + .disabled(!store.canRetry) + + Button { + showDiagnostics() + } label: { + Label("Diagnostics", systemImage: "wrench.and.screwdriver") + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +private struct AppDiagnosticsView: View { + @ObservedObject var store: AppReadinessStore + let events: [BackendEvent] + @Binding var helperPath: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Diagnostics") + .font(.title2.weight(.semibold)) + Spacer() + Button("Done") { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + + TextField(L10n.string("field.helper"), text: $helperPath) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + Text("State").foregroundStyle(.secondary) + Text(store.state.kind.rawValue) + } + if let capabilities = store.capabilities { + GridRow { + Text("Helper").foregroundStyle(.secondary) + Text(capabilities.helperVersion) + } + GridRow { + Text("Distribution").foregroundStyle(.secondary) + Text(capabilities.distributionRoot) + .lineLimit(1) + .truncationMode(.middle) + } + } + if let validation = store.validation { + GridRow { + Text("Validation").foregroundStyle(.secondary) + Text(validation.summary) + } + } + } + .font(.caption) + + if !store.issues.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Runtime Issues") + .font(.headline) + ForEach(store.issues) { issue in + VStack(alignment: .leading, spacing: 2) { + Text(issue.message) + Text(issue.recovery) + .foregroundStyle(.secondary) + } + .font(.caption) + } + } + } + + Text("Backend Events") + .font(.headline) + EventList(events: events) + } + .padding() + .frame(minWidth: 720, minHeight: 520) + } +} + private struct CommandPanel: View { let title: String @ViewBuilder var content: Content diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift index 9ee981dd..08ac68d5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift @@ -3,6 +3,8 @@ 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] } @@ -46,11 +48,13 @@ public struct HelperLocator { } for candidate in bundledHelperCandidates() + devHelperCandidates() { - attempts.append(candidate.path) - if isExecutable(candidate) { + attempts.append(candidate.url.path) + if isExecutable(candidate.url) { return HelperResolution( - executableURL: candidate, - distributionRootURL: distributionRoot(for: candidate), + executableURL: candidate.url, + distributionRootURL: distributionRoot(for: candidate.url, mode: candidate.mode), + toolsBinURL: toolsBinURL(for: candidate.mode), + mode: candidate.mode, attemptedPaths: attempts ) } @@ -72,9 +76,22 @@ public struct HelperLocator { if output["TCAPSULE_DISTRIBUTION_ROOT"] == nil, let distributionRoot = resolution.distributionRootURL { output["TCAPSULE_DISTRIBUTION_ROOT"] = distributionRoot.path } + if let toolsBin = resolution.toolsBinURL, isDirectory(toolsBin) { + output["PATH"] = pathByPrepending(toolsBin.path, to: output["PATH"]) + } + output["PYTHONNOUSERSITE"] = "1" return output } + public func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] { + guard resolution.mode == .productionBundle, + let layout = BundleLayout.productionCandidate(bundle: bundle, fileManager: fileManager) + else { + return [] + } + return layout.validationIssues(fileManager: fileManager) + } + private func resolveExplicitPath(_ path: String, attempts: inout [String]) throws -> HelperResolution { let candidate = url(forPath: path) attempts.append(candidate.path) @@ -83,7 +100,9 @@ public struct HelperLocator { } return HelperResolution( executableURL: candidate, - distributionRootURL: distributionRoot(for: candidate), + distributionRootURL: distributionRoot(for: candidate, mode: .explicit), + toolsBinURL: toolsBinURL(for: .explicit), + mode: .explicit, attemptedPaths: attempts ) } @@ -101,30 +120,40 @@ public struct HelperLocator { return currentDirectory.appendingPathComponent(path) } - private func bundledHelperCandidates() -> [URL] { - var candidates: [URL] = [] + 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(helper) + candidates.append(HelperCandidate(url: helper, mode: .productionBundle)) } if let helper = bundle.url(forResource: "tcapsule", withExtension: nil) { - candidates.append(helper) + candidates.append(HelperCandidate(url: helper, mode: .productionBundle)) } return candidates } - private func devHelperCandidates() -> [URL] { + 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 { $0.appendingPathComponent(".venv/bin/tcapsule") } + return unique(roots).map { + HelperCandidate(url: $0.appendingPathComponent(".venv/bin/tcapsule"), mode: .developmentCheckout) + } } - private func distributionRoot(for helperURL: URL) -> URL? { + 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 } @@ -134,6 +163,13 @@ public struct HelperLocator { 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) { @@ -188,8 +224,18 @@ public struct HelperLocator { } private func applicationSupportDirectory() -> URL? { - fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first? - .appendingPathComponent("TimeCapsuleSMB", isDirectory: true) + 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/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift new file mode 100644 index 00000000..83c70e24 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift @@ -0,0 +1,287 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AppReadinessStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual( + AppReadinessStateKind.allCases, + [.idle, .resolvingBundle, .checkingCapabilities, .validatingInstall, .ready, .degraded, .blocked] + ) + } + + func testSuccessfulReadinessRunsCapabilitiesThenValidation() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "stage", operation: "capabilities", stage: "summarize_capabilities"), + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "stage", operation: "validate-install", stage: "validate_install"), + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + XCTAssertEqual(store.state.kind, .checkingCapabilities) + try await waitUntilStoreState { store.state.kind == .ready } + XCTAssertEqual(runner.calls.map(\.operation), ["capabilities", "validate-install"]) + XCTAssertEqual(store.currentStage?.stage, "validate_install") + guard case .ready(let summary) = store.state else { + return XCTFail("Expected ready state.") + } + XCTAssertEqual(summary.runtimeMode, .productionBundle) + XCTAssertEqual(summary.helperVersion, "1.2.3") + XCTAssertEqual(summary.distributionRoot, "/bundle/Distribution") + XCTAssertEqual(summary.validationCounts["pass"], 1) + } + + func testValidationFailureBlocksApp() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: false, payload: validationPayload(ok: false)) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .installValidationFailed) + XCTAssertEqual(store.validation?.ok, false) + } + + func testRuntimeWarningProducesDegradedStateAfterValidationSuccess() async throws { + let warning = BundleRuntimeIssue( + code: .toolsDirectoryMissing, + severity: .warning, + message: "missing tools", + recovery: "repair app" + ) + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore(runner: runner, issues: [warning]) + + store.start() + + try await waitUntilStoreState { store.state.kind == .degraded } + guard case .degraded(let summary, let issues) = store.state else { + return XCTFail("Expected degraded state.") + } + XCTAssertEqual(summary.helperVersion, "1.2.3") + XCTAssertEqual(issues, [warning]) + } + + func testRuntimeErrorBlocksBeforeRunningHelper() { + let issue = BundleRuntimeIssue( + code: .distributionRootMissing, + severity: .error, + message: "missing distribution", + recovery: "reinstall" + ) + let runner = StoreTestRunner(responses: []) + let store = makeStore(runner: runner, issues: [issue]) + + store.start() + + XCTAssertEqual(store.state.kind, .blocked) + XCTAssertEqual(runner.calls, []) + guard case .blocked(let blockedIssue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(blockedIssue.code, .distributionRootMissing) + } + + func testResolveFailureBlocksBeforeRunningHelper() { + let runner = StoreTestRunner(responses: []) + let store = makeStore(runner: runner, resolveError: NSError(domain: "test", code: 1)) + + store.start() + + XCTAssertEqual(store.state.kind, .blocked) + XCTAssertEqual(runner.calls, []) + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .helperMissing) + } + + func testMalformedCapabilitiesPayloadBlocksWithContractIssue() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .contractDecodeFailed) + XCTAssertEqual(runner.calls.map(\.operation), ["capabilities"]) + } + + func testMalformedValidationPayloadBlocksWithContractIssue() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: .object(["schema_version": .string("wrong")])) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .contractDecodeFailed) + XCTAssertEqual(runner.calls.map(\.operation), ["capabilities", "validate-install"]) + } + + func testHelperLaunchErrorBlocksWithLaunchIssue() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "capabilities", code: "helper_launch_failed", message: "launch failed") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { store.state.kind == .blocked } + guard case .blocked(let issue) = store.state else { + return XCTFail("Expected blocked state.") + } + XCTAssertEqual(issue.code, .helperLaunchFailed) + XCTAssertEqual(issue.message, "launch failed") + } + + func testUnrelatedEventsDoNotAdvanceReadiness() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "paths", ok: true, payload: .object(["ok": .bool(true)])) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + + try await waitUntilStoreState { !store.backend.isRunning } + XCTAssertEqual(store.state.kind, .checkingCapabilities) + XCTAssertNil(store.capabilities) + XCTAssertNil(store.validation) + } + + func testClearResetsStateAndPayloads() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "capabilities", ok: true, payload: capabilitiesPayload()) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "validate-install", ok: true, payload: validationPayload(ok: true)) + ]) + ]) + let store = makeStore(runner: runner) + + store.start() + try await waitUntilStoreState { store.state.kind == .ready } + store.clear() + + XCTAssertEqual(store.state.kind, .idle) + XCTAssertNil(store.capabilities) + XCTAssertNil(store.validation) + XCTAssertEqual(store.issues, []) + XCTAssertNil(store.currentStage) + } + + private func makeStore( + runner: StoreTestRunner, + issues: [BundleRuntimeIssue] = [], + resolveError: Error? = nil + ) -> AppReadinessStore { + let backend = BackendClient(runner: runner) + let resolver = TestRuntimeResolver(issues: issues, resolveError: resolveError) + return AppReadinessStore( + backend: backend, + runtimeResolver: resolver, + helperPathProvider: { "" } + ) + } + + private func capabilitiesPayload() -> JSONValue { + .object([ + "schema_version": .number(1), + "api_schema_version": .number(1), + "helper_version": .string("1.2.3"), + "helper_version_code": .number(123), + "operations": .array([.string("discover"), .string("configure"), .string("validate-install")]), + "distribution_root": .string("/bundle/Distribution"), + "artifact_manifest_sha256": .string("abc"), + "confirmation_schema_version": .number(1), + "summary": .string("helper capabilities resolved.") + ]) + } + + private func validationPayload(ok: Bool) -> JSONValue { + .object([ + "schema_version": .number(1), + "ok": .bool(ok), + "checks": .array([ + .object([ + "id": .string(ok ? "python_modules" : "artifact_hashes"), + "ok": .bool(ok), + "message": .string(ok ? "required Python modules import" : "artifact validation failed") + ]) + ]), + "counts": .object([ + "checks": .number(1), + "pass": .number(ok ? 1 : 0), + "fail": .number(ok ? 0 : 1) + ]), + "summary": .string(ok ? "install validation passed." : "install validation failed.") + ]) + } +} + +private struct TestRuntimeResolver: AppRuntimeResolving { + let issues: [BundleRuntimeIssue] + let resolveError: Error? + + func resolve(helperPath: String?) throws -> HelperResolution { + if let resolveError { + throw resolveError + } + return HelperResolution( + executableURL: URL(fileURLWithPath: "/bundle/Contents/Helpers/tcapsule"), + distributionRootURL: URL(fileURLWithPath: "/bundle/Contents/Resources/Distribution", isDirectory: true), + toolsBinURL: URL(fileURLWithPath: "/bundle/Contents/Resources/Tools/bin", isDirectory: true), + mode: .productionBundle, + attemptedPaths: ["/bundle/Contents/Helpers/tcapsule"] + ) + } + + func runtimeIssues(for resolution: HelperResolution) -> [BundleRuntimeIssue] { + issues + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift new file mode 100644 index 00000000..9d4e19d5 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BundleLayoutTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class BundleLayoutTests: XCTestCase { + func testStateInventoriesAreExplicit() { + XCTAssertEqual(BundleRuntimeMode.allCases, [.explicit, .productionBundle, .developmentCheckout]) + XCTAssertEqual(BundleRuntimeIssueSeverity.allCases, [.warning, .error]) + XCTAssertEqual( + BundleRuntimeIssueCode.allCases, + [ + .helperMissing, + .helperNotExecutable, + .distributionRootMissing, + .toolsDirectoryMissing, + .installValidationFailed, + .helperLaunchFailed, + .contractDecodeFailed, + .operationFailed + ] + ) + } + + func testValidProductionLayoutHasNoIssues() throws { + let layout = try makeLayout() + + XCTAssertEqual(layout.validationIssues(), []) + } + + func testMissingHelperIsBlockingIssue() throws { + let layout = try makeLayout(createHelper: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .helperMissing && $0.severity == .error })) + } + + func testNonExecutableHelperIsBlockingIssue() throws { + let layout = try makeLayout(helperPermissions: 0o644) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .helperNotExecutable && $0.severity == .error })) + } + + func testMissingDistributionRootIsBlockingIssue() throws { + let layout = try makeLayout(createDistribution: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .distributionRootMissing && $0.severity == .error })) + } + + func testMissingToolsDirectoryIsWarningIssue() throws { + let layout = try makeLayout(createTools: false) + + let issues = layout.validationIssues() + + XCTAssertTrue(issues.contains(where: { $0.code == .toolsDirectoryMissing && $0.severity == .warning })) + } + + private func makeLayout( + createHelper: Bool = true, + helperPermissions: Int = 0o755, + createDistribution: Bool = true, + createTools: Bool = true + ) throws -> BundleLayout { + let temp = try TemporaryDirectory() + let app = temp.url.appendingPathComponent("TimeCapsuleSMB.app", isDirectory: true) + let resources = app.appendingPathComponent("Contents/Resources", isDirectory: true) + let helpers = app.appendingPathComponent("Contents/Helpers", isDirectory: true) + let appSupport = temp.url.appendingPathComponent("Application Support", isDirectory: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + + let helper = helpers.appendingPathComponent("tcapsule") + if createHelper { + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: helperPermissions], ofItemAtPath: helper.path) + } + if createDistribution { + try FileManager.default.createDirectory( + at: resources.appendingPathComponent("Distribution", isDirectory: true), + withIntermediateDirectories: true + ) + } + if createTools { + try FileManager.default.createDirectory( + at: resources.appendingPathComponent("Tools/bin", isDirectory: true), + withIntermediateDirectories: true + ) + } + return BundleLayout( + appBundleURL: app, + resourceURL: resources, + helperURL: helper, + applicationSupportURL: appSupport + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift index 0aaf26bc..d6e7a781 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -20,8 +20,11 @@ final class HelperLocatorTests: XCTestCase { let environment = locator.helperEnvironment(for: resolution) XCTAssertEqual(resolution.executableURL.path, helper.path) + XCTAssertEqual(resolution.mode, .explicit) + XCTAssertNil(resolution.toolsBinURL) XCTAssertNotNil(environment["TCAPSULE_CONFIG"]) XCTAssertNotNil(environment["TCAPSULE_STATE_DIR"]) + XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1") } func testLocatorDiscoversRepoHelperFromSourceRoot() throws { @@ -47,9 +50,59 @@ final class HelperLocatorTests: XCTestCase { 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( @@ -66,4 +119,52 @@ final class HelperLocatorTests: XCTestCase { XCTAssertFalse(attempts.isEmpty) } } + + private func makeRepo(in directory: URL) throws -> URL { + let repo = directory.appendingPathComponent("Repo", isDirectory: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent(".venv/bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("bin", isDirectory: true), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo.appendingPathComponent("src/timecapsulesmb", isDirectory: true), withIntermediateDirectories: true) + try "".write(to: repo.appendingPathComponent("pyproject.toml"), atomically: true, encoding: .utf8) + let helper = repo.appendingPathComponent(".venv/bin/tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + return repo + } + + private func makeAppBundle(in directory: URL, createTools: Bool = true) throws -> Bundle { + let app = directory.appendingPathComponent("TimeCapsuleSMB.app", isDirectory: true) + let contents = app.appendingPathComponent("Contents", isDirectory: true) + let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) + let resources = contents.appendingPathComponent("Resources", isDirectory: true) + let helpers = contents.appendingPathComponent("Helpers", isDirectory: true) + try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: helpers, withIntermediateDirectories: true) + try """ + + + + + CFBundleExecutable + TimeCapsuleSMB + CFBundleIdentifier + test.TimeCapsuleSMB + CFBundlePackageType + APPL + + + """.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) + try "#!/bin/sh\nexit 0\n".write(to: macOS.appendingPathComponent("TimeCapsuleSMB"), atomically: true, encoding: .utf8) + try "#!/bin/sh\nexit 0\n".write(to: helpers.appendingPathComponent("tcapsule"), atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helpers.appendingPathComponent("tcapsule").path) + try FileManager.default.createDirectory(at: resources.appendingPathComponent("Distribution", isDirectory: true), withIntermediateDirectories: true) + if createTools { + try FileManager.default.createDirectory(at: resources.appendingPathComponent("Tools/bin", isDirectory: true), withIntermediateDirectories: true) + } + guard let bundle = Bundle(url: app) else { + throw NSError(domain: "HelperLocatorTests", code: 1) + } + return bundle + } } diff --git a/macos/TimeCapsuleSMB/tools/package_app.py b/macos/TimeCapsuleSMB/tools/package_app.py new file mode 100755 index 00000000..7d7bf741 --- /dev/null +++ b/macos/TimeCapsuleSMB/tools/package_app.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import plistlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = PACKAGE_ROOT.parents[1] +APP_NAME = "TimeCapsuleSMB" +PRODUCT_NAME = "TimeCapsuleSMB" + + +def run(cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + env=env, + input=input_text, + text=True, + check=True, + stdout=subprocess.PIPE if input_text is not None else None, + stderr=subprocess.PIPE if input_text is not None else None, + ) + + +def build_swift(configuration: str) -> Path: + run(["swift", "build", "-c", configuration, "--product", PRODUCT_NAME], cwd=PACKAGE_ROOT) + executable = PACKAGE_ROOT / ".build" / configuration / PRODUCT_NAME + if not executable.is_file(): + raise RuntimeError(f"Swift build did not produce {executable}") + return executable + + +def copy_resources(configuration: str, resources_dir: Path) -> None: + build_dir = PACKAGE_ROOT / ".build" / configuration + for resource_bundle in build_dir.glob("*.bundle"): + destination = resources_dir / resource_bundle.name + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(resource_bundle, destination) + + +def write_info_plist(contents_dir: Path) -> None: + info = { + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": APP_NAME, + "CFBundleExecutable": PRODUCT_NAME, + "CFBundleIdentifier": "com.timecapsulesmb.TimeCapsuleSMB", + "CFBundleName": APP_NAME, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "0.1.0", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "13.0", + "NSHighResolutionCapable": True, + } + with (contents_dir / "Info.plist").open("wb") as handle: + plistlib.dump(info, handle) + (contents_dir / "PkgInfo").write_text("APPL????", encoding="utf-8") + + +def write_helper_wrapper(helper_path: Path) -> None: + helper_path.write_text( + """#!/bin/sh +set -eu + +CONTENTS_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +RESOURCES_DIR="$CONTENTS_DIR/Resources" +PYTHON="$RESOURCES_DIR/Python/bin/python" + +if [ -z "${TCAPSULE_STATE_DIR:-}" ]; then + export TCAPSULE_STATE_DIR="$HOME/Library/Application Support/TimeCapsuleSMB" +fi +if [ -z "${TCAPSULE_CONFIG:-}" ]; then + export TCAPSULE_CONFIG="$TCAPSULE_STATE_DIR/.env" +fi +if [ -z "${TCAPSULE_DISTRIBUTION_ROOT:-}" ]; then + export TCAPSULE_DISTRIBUTION_ROOT="$RESOURCES_DIR/Distribution" +fi + +mkdir -p "$TCAPSULE_STATE_DIR" +export PATH="$RESOURCES_DIR/Tools/bin:${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}" +export PYTHONNOUSERSITE=1 + +exec "$PYTHON" -m timecapsulesmb.cli.main "$@" +""", + encoding="utf-8", + ) + helper_path.chmod(0o755) + + +def create_python_runtime(python: str, resources_dir: Path) -> None: + runtime = resources_dir / "Python" + if runtime.exists(): + shutil.rmtree(runtime) + run([python, "-m", "venv", str(runtime)]) + runtime_python = runtime / "bin" / "python" + run([str(runtime_python), "-m", "pip", "install", "-U", "pip"]) + generated_build_lib = REPO_ROOT / "build" / "lib" + build_lib_existed = generated_build_lib.exists() + try: + run([str(runtime_python), "-m", "pip", "install", str(REPO_ROOT)]) + finally: + if not build_lib_existed and generated_build_lib.exists(): + shutil.rmtree(generated_build_lib) + + +def copy_distribution(resources_dir: Path) -> None: + distribution = resources_dir / "Distribution" + if distribution.exists(): + shutil.rmtree(distribution) + distribution.mkdir(parents=True) + shutil.copytree(REPO_ROOT / "bin", distribution / "bin") + + +def copy_tool(name: str, tools_bin: Path) -> bool: + source = shutil.which(name) + if not source: + return False + destination = tools_bin / name + shutil.copy2(source, destination) + destination.chmod(0o755) + return True + + +def copy_tools(resources_dir: Path, require_tools: bool) -> None: + tools_bin = resources_dir / "Tools" / "bin" + tools_bin.mkdir(parents=True, exist_ok=True) + missing = [tool for tool in ("sshpass", "smbclient") if not copy_tool(tool, tools_bin)] + if missing and require_tools: + joined = ", ".join(missing) + raise RuntimeError(f"Missing required host tool(s) for bundling: {joined}") + if missing: + print(f"warning: missing optional bundled tool(s): {', '.join(missing)}", file=sys.stderr) + + +def smoke_request(helper: Path, operation: str, state_dir: Path) -> None: + env = os.environ.copy() + env["TCAPSULE_STATE_DIR"] = str(state_dir) + env["TCAPSULE_CONFIG"] = str(state_dir / ".env") + request = json.dumps({"operation": operation, "params": {}}) + completed = run([str(helper), "api"], input_text=request, env=env) + if '"type":"result"' not in completed.stdout and '"type": "result"' not in completed.stdout: + raise RuntimeError(f"{operation} smoke test did not emit a result event:\n{completed.stdout}\n{completed.stderr}") + if '"ok":false' in completed.stdout or '"ok": false' in completed.stdout: + raise RuntimeError(f"{operation} smoke test failed:\n{completed.stdout}\n{completed.stderr}") + + +def smoke_test(app: Path) -> None: + helper = app / "Contents" / "Helpers" / "tcapsule" + with tempfile.TemporaryDirectory(prefix="timecapsulesmb-package-smoke-") as tmp: + state_dir = Path(tmp) + smoke_request(helper, "capabilities", state_dir) + smoke_request(helper, "validate-install", state_dir) + + +def package_app(args: argparse.Namespace) -> Path: + executable = build_swift(args.configuration) + output_dir = args.output.resolve() + app = output_dir / f"{APP_NAME}.app" + contents = app / "Contents" + macos = contents / "MacOS" + helpers = contents / "Helpers" + resources = contents / "Resources" + + if app.exists(): + shutil.rmtree(app) + macos.mkdir(parents=True) + helpers.mkdir() + resources.mkdir() + + write_info_plist(contents) + shutil.copy2(executable, macos / PRODUCT_NAME) + copy_resources(args.configuration, resources) + write_helper_wrapper(helpers / "tcapsule") + create_python_runtime(args.python, resources) + copy_distribution(resources) + copy_tools(resources, args.require_tools) + + if not args.skip_smoke: + smoke_test(app) + return app + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build a self-contained TimeCapsuleSMB.app bundle.") + parser.add_argument("--output", type=Path, default=PACKAGE_ROOT / "dist", help="Directory that will receive TimeCapsuleSMB.app.") + parser.add_argument("--configuration", choices=("debug", "release"), default="release", help="Swift build configuration.") + parser.add_argument("--python", default=sys.executable, help="Python interpreter used to create the bundled runtime.") + parser.add_argument("--require-tools", action="store_true", help="Fail if sshpass or smbclient cannot be copied into the app bundle.") + parser.add_argument("--skip-smoke", action="store_true", help="Skip bundled helper capabilities and validate-install smoke tests.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + try: + app = package_app(parse_args(argv or sys.argv[1:])) + except subprocess.CalledProcessError as exc: + print(f"command failed with exit code {exc.returncode}: {exc.cmd}", file=sys.stderr) + if exc.stdout: + print(exc.stdout, file=sys.stderr) + if exc.stderr: + print(exc.stderr, file=sys.stderr) + return exc.returncode or 1 + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + print(app) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a914319b3ec4c53398b926b6a67a02813c96e73e Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 22:59:38 -0700 Subject: [PATCH 17/20] Build multi-device GUI dashboard foundation Add saved Time Capsule profiles, Keychain-backed passwords, profile-scoped backend execution, and the dashboard/add-device app shell. Unify the GUI and CLI discovery contract around deduped device candidates, preserve root SSH targeting, and document the target GUI architecture. Fix operation ownership so rejected starts do not enter running states and dashboard snapshots are attributed to the profile that started the operation. --- GUI_ARCH.md | 366 +++++++ .../AddDeviceFlowStore.swift | 472 +++++++++ .../Sources/TimeCapsuleSMBApp/AppStore.swift | 164 ++++ .../TimeCapsuleSMBApp/BackendClient.swift | 22 +- .../TimeCapsuleSMBApp/BackendPayloads.swift | 67 ++ .../ConnectionWorkflowStore.swift | 17 +- .../TimeCapsuleSMBApp/ContentView.swift | 903 ++++++++++++++++-- .../TimeCapsuleSMBApp/DashboardStore.swift | 182 ++++ .../DeployWorkflowStore.swift | 116 ++- .../TimeCapsuleSMBApp/DeviceProfile.swift | 181 ++++ .../DeviceRegistryStore.swift | 216 +++++ .../TimeCapsuleSMBApp/DoctorStore.swift | 80 +- .../TimeCapsuleSMBApp/HelperLocator.swift | 7 +- .../TimeCapsuleSMBApp/HelperRunner.swift | 10 +- .../HostCompatibilityPolicy.swift | 28 + .../TimeCapsuleSMBApp/MaintenanceStore.swift | 284 ++++-- .../OperationCoordinator.swift | 124 +++ .../TimeCapsuleSMBApp/OperationParams.swift | 13 +- .../TimeCapsuleSMBApp/PasswordStore.swift | 166 ++++ .../PendingConfirmation.swift | 5 +- .../TimeCapsuleSMBExecutable/main.swift | 8 + .../AddDeviceFlowStoreTests.swift | 390 ++++++++ .../BackendClientTests.swift | 98 +- .../BackendPayloadTests.swift | 33 +- .../ConnectionWorkflowStoreTests.swift | 4 +- .../DashboardStoreTests.swift | 234 +++++ .../DeployWorkflowStoreTests.swift | 20 + .../DeviceProfileTests.swift | 94 ++ .../DeviceRegistryStoreTests.swift | 110 +++ .../DoctorStoreTests.swift | 20 + .../HelperLocatorTests.swift | 20 + .../HostCompatibilityPolicyTests.swift | 20 + .../MaintenanceStoreTests.swift | 20 + .../PasswordStoreTests.swift | 55 ++ .../PendingConfirmationTests.swift | 12 + .../StoreTestSupport.swift | 246 ++++- src/timecapsulesmb/app/contracts.py | 4 +- src/timecapsulesmb/app/ops/configure.py | 9 +- src/timecapsulesmb/app/ops/readiness.py | 3 + src/timecapsulesmb/cli/configure.py | 33 +- src/timecapsulesmb/discovery/devices.py | 169 ++++ tests/test_app_api.py | 78 +- tests/test_discovery_devices.py | 104 ++ 43 files changed, 4962 insertions(+), 245 deletions(-) create mode 100644 GUI_ARCH.md create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift create mode 100644 src/timecapsulesmb/discovery/devices.py create mode 100644 tests/test_discovery_devices.py diff --git a/GUI_ARCH.md b/GUI_ARCH.md new file mode 100644 index 00000000..c461abef --- /dev/null +++ b/GUI_ARCH.md @@ -0,0 +1,366 @@ +# TimeCapsuleSMB GUI Architecture + +This is the living architecture target for the macOS GUI. Future GUI changes +should reference this file and keep the implementation moving toward these +boundaries. + +## Product Shape + +The GUI is a native multi-device manager for Apple Time Capsules. It should not +feel like a wrapper around CLI commands. + +The main user flows are: + +1. Add one or more Time Capsules. +2. Save device profiles with per-device config files. +3. Store passwords in Keychain only. +4. Install or update SMB support. +5. Run checkups and show structured health. +6. Run maintenance tasks with explicit plans and confirmations. +7. Surface advanced logs and helper details only when needed. + +`bootstrap`, `paths`, and `validate-install` are app readiness concerns. They +run in the background or diagnostics surfaces, not as first-class user actions. +The bundled app should already contain the helper, runtime, tools, artifacts, +and manifests needed by those checks. + +## Architectural Principles + +- The app is profile-first. Screens operate on `DeviceProfile`, not loose host + fields or a shared `.env`. +- Views are thin. They render state and send user intents to stores. +- Stores own state machines. Each workflow has explicit states, terminal states, + validation, and event-to-model parsing. +- Backend execution is centralized. There is one global `OperationCoordinator` + and one active helper operation at a time. +- Backend contracts are typed at the GUI boundary. Swift decodes payloads into + models and does not parse human log text for app behavior. +- Credentials never persist to `.env`. GUI passwords live in Keychain and are + passed per operation as credentials. +- Runtime context is explicit. Profile-scoped operations always carry + `DeviceRuntimeContext`. +- Device snapshots are attributed to the operation profile ID, not the currently + selected sidebar item. +- Advanced diagnostics exist, but normal workflows use user-facing language: + Install / Update, Checkup, Maintenance, Add Time Capsule. + +## Layer Map + +Target source organization: + +```text +TimeCapsuleSMBApp/ + App/ + AppStore.swift + AppReadinessStore.swift + Backend/ + BackendClient.swift + BackendPayloads.swift + HelperLocator.swift + HelperRunner.swift + OperationCoordinator.swift + OperationParams.swift + PendingConfirmation.swift + Profiles/ + DeviceProfile.swift + DeviceRegistryStore.swift + PasswordStore.swift + Policies/ + HostCompatibilityPolicy.swift + Workflows/ + AddDeviceFlowStore.swift + DashboardStore.swift + DeployWorkflowStore.swift + DoctorStore.swift + MaintenanceStore.swift + Views/ + Shell/ + AddDevice/ + Dashboard/ + Diagnostics/ + Components/ +``` + +The current code can keep file names during transition, but new substantial +screen code should move toward this split instead of growing `ContentView.swift`. + +## Ownership + +### AppStore + +`AppStore` is the app composition root. It owns: + +- `AppReadinessStore` +- `DeviceRegistryStore` +- `OperationCoordinator` +- `PasswordStore` +- selected profile ID +- high-level navigation state + +`AppStore` should not parse backend events. It may derive cross-cutting summary +state such as the dashboard primary action, host compatibility warnings, and +password availability. + +### DeviceRegistryStore + +`DeviceRegistryStore` owns persistent device profiles: + +```text +~/Library/Application Support/TimeCapsuleSMB/devices.json +~/Library/Application Support/TimeCapsuleSMB/Devices//.env +``` + +The registry is responsible for: + +- loading and saving `devices.json` +- creating per-device config directories +- duplicate matching by Bonjour fullname and normalized host +- deleting profile config directories +- persisting checkup and deploy snapshots + +It must not delete corrupt registries automatically. Corrupt registry state +goes to diagnostics and waits for explicit user recovery. + +### PasswordStore + +`PasswordStore` abstracts Keychain access. + +Production storage: + +```text +service = TimeCapsuleSMB.DevicePassword +account = +``` + +Rules: + +- Add Device saves a password only after `configure` succeeds. +- `.env` files never contain `TC_PASSWORD`. +- Missing Keychain item maps to `passwordNeeded` or `.missing`. +- Keychain access errors map to `.keychainUnavailable`. +- Auth failures mark the password invalid, but do not delete it automatically. +- Forget Device deletes the profile, per-device config directory, and Keychain + item as one user-visible action. + +## Backend Execution + +`BackendClient` owns process execution state and raw events. It should not know +about UI screens. + +`OperationCoordinator` is the only workflow-facing entry point for helper runs: + +```swift +run(operation:params:profile:password:) +run(operation:params:context:activeDeviceID:password:) +``` + +Responsibilities: + +- reject a second operation while one is running +- expose active operation and active profile ID +- inject password credentials when provided +- delegate profile context to `BackendClient` +- preserve context through confirmation replay +- support cancel and clear semantics + +Profile-scoped operations must pass `DeviceRuntimeContext`. The backend layer +injects: + +- `params["config"] = context.configURL.path` +- `TCAPSULE_CONFIG = context.configURL.path` + +`TCAPSULE_STATE_DIR` remains app-level so bootstrap/version/cache state is not +multiplied per profile. + +## Operation Attribution + +Workflow stores must attribute terminal results to the profile that started the +operation. + +Do not write snapshots using `selectedProfile` at result time. The user can +change sidebar selection while an operation runs. A workflow should capture +`activeProfileID` when it starts, then use that ID when persisting: + +- `DeviceCheckupSnapshot` +- `DeviceDeploySnapshot` +- future maintenance snapshots + +If `OperationCoordinator` rejects a run, the caller must leave or restore its +state to a non-running failure state. No workflow should enter `running`, +`planning`, `configuring`, or `saving` unless the operation actually started. + +## Backend Contract + +The Python app API is the source of truth for structured payloads. GUI-facing +payloads should remain stable and versioned. + +Important contracts: + +- `discover` returns `devices`, a deduped list of selectable Time Capsules. +- Each discovered device includes `selected_record`, which the GUI passes back + to `configure`. +- `configure` accepts either `selected_record` or `host`. +- Manual `host` values are treated as root SSH targets by the backend. +- GUI `configure` sends `persist_password: false`. +- Deploy, doctor, activate, uninstall, and fsck receive credentials from + Keychain-backed GUI state. + +Swift should prefer decoding structured fields over reading `summary` strings. +Raw summaries are for display only. + +## Add Device Flow + +Add Device is a state machine with mutually exclusive entry modes: + +- Discover +- Manual Address + +States: + +```text +idle +discovering +discoveryEmpty +discoveryReady +manualEntry +passwordEntry +configuring +savingProfile +saved +authFailed +unsupported +failed +``` + +Discover mode: + +- runs backend `discover` +- shows only `payload.devices` +- auto-selects if there is exactly one device +- fills and disables Host/IP from the selected device +- routes already saved devices to their existing profile + +Manual mode: + +- clears discovered candidates from the active flow +- enables Host/IP entry +- assumes root SSH unless the user explicitly enters a user + +Save rules: + +- no profile is saved until `configure` succeeds +- wrong password saves nothing +- unsupported device saves nothing +- duplicate host or Bonjour fullname updates the existing profile +- Keychain save failure may keep the profile, but marks password state missing + +## Dashboard + +The dashboard has these user-facing tabs: + +- Overview +- Install / Update +- Checkup +- Maintenance +- Advanced + +Overview is decision-oriented. It shows device identity, password state, host +macOS warnings, last checkup, last install/update, and one primary action. + +Install / Update wraps deploy planning and deploy execution. Dry-run planning +should remain first-class. + +Checkup wraps doctor and shows grouped checks by domain and status. + +Maintenance wraps: + +- NetBSD4 activation +- uninstall +- fsck +- repair xattrs +- future flash workflow + +Advanced contains raw events, helper path, profile ID, config path, and other +technical diagnostics. + +## App Readiness And Bundling + +Readiness runs at app launch and validates the bundled runtime. It is not a +device workflow. + +Production bundle target: + +```text +Contents/MacOS/TimeCapsuleSMB +Contents/Helpers/tcapsule +Contents/Resources/Distribution/... +Contents/Resources/Tools/... +``` + +The app sets: + +- `TCAPSULE_CONFIG` per profile operation +- `TCAPSULE_STATE_DIR` to app support +- `TCAPSULE_DISTRIBUTION_ROOT` to bundled distribution resources +- `PATH` to bundled tools where required + +If bundled resources are missing or invalid, normal workflows are blocked and +diagnostics explain that the app install is incomplete. + +## Host Compatibility + +`HostCompatibilityPolicy` is pure Swift and side-effect free. It warns +non-blockingly for host macOS versions with known Time Machine network backup +issues: + +- macOS 15.7.5 +- macOS 15.7.6 +- macOS 15.7.7 +- macOS 26.4.x + +Warnings appear globally or on dashboards, but they do not prevent SMB install +or maintenance. + +## Error Handling + +Errors should preserve machine-readable codes and user-facing recovery. + +Workflow stores should map backend errors into: + +- state transition +- concise visible message +- recovery action, when available +- raw details in Advanced or Diagnostics + +Authentication failures must prompt for password replacement without deleting +the existing Keychain item automatically. + +Unsupported devices must show the compatibility explanation and avoid creating +profiles. + +## Testing Standards + +Every workflow state enum should have an inventory test. Tests should verify +state transitions and side effects through mocks, not string grep checks. + +Required coverage areas: + +- missing, corrupt, save, update, duplicate, and delete registry behavior +- Keychain save/read/update/delete, missing item, and unavailable item +- backend context injection and confirmation replay context preservation +- operation rejection while another operation is active +- add-device discover/manual/auth/unsupported/duplicate/password-save failure +- dashboard primary action derivation +- operation snapshots attributed to active operation profile ID +- host compatibility warning matrix +- helper locator production and development environment behavior + +Regression runs: + +```bash +cd macos/TimeCapsuleSMB && swift test +.venv/bin/pytest +``` + +Run Python tests from the repo root. Run Swift tests from +`macos/TimeCapsuleSMB`. diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift new file mode 100644 index 00000000..af6f540c --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift @@ -0,0 +1,472 @@ +import Combine +import Foundation + +enum AddDeviceFlowState: String, CaseIterable, Equatable { + case idle + case discovering + case discoveryEmpty + case discoveryReady + case manualEntry + case passwordEntry + case configuring + case savingProfile + case saved + case authFailed + case unsupported + case failed + + var title: String { + switch self { + case .idle: + return "Idle" + case .discovering: + return "Discovering" + case .discoveryEmpty: + return "No Devices Found" + case .discoveryReady: + return "Devices Found" + case .manualEntry: + return "Manual Address" + case .passwordEntry: + return "Password Required" + case .configuring: + return "Configuring" + case .savingProfile: + return "Saving" + case .saved: + return "Saved" + case .authFailed: + return "Password Rejected" + case .unsupported: + return "Unsupported" + case .failed: + return "Failed" + } + } +} + +enum AddDeviceEntryMode: String, CaseIterable, Equatable, Identifiable { + case discover + case manual + + var id: String { rawValue } + + var title: String { + switch self { + case .discover: + return "Discover" + case .manual: + return "Manual Address" + } + } +} + +@MainActor +final class AddDeviceFlowStore: ObservableObject { + @Published private(set) var entryMode: AddDeviceEntryMode = .discover + @Published var manualHost = "" + @Published var bonjourTimeout = "6" + @Published var password = "" + @Published var debugLogging = false + @Published private(set) var state: AddDeviceFlowState = .idle + @Published private(set) var devices: [DiscoveredDevice] = [] + @Published var selectedDeviceID: DiscoveredDevice.ID? + @Published private(set) var savedProfile: DeviceProfile? + @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var currentStage: OperationStageState? + + let coordinator: OperationCoordinator + let registry: DeviceRegistryStore + let passwordStore: PasswordStore + + private var pendingProfileID: DeviceProfile.ID? + private var pendingDiscoveredDevice: DiscoveredDevice? + private var activeOperation: ActiveOperation? + private var lastProcessedEventCount = 0 + private var cancellables: Set = [] + + init( + coordinator: OperationCoordinator, + registry: DeviceRegistryStore, + passwordStore: PasswordStore + ) { + self.coordinator = coordinator + self.registry = registry + self.passwordStore = passwordStore + coordinator.backend.$events + .sink { [weak self] events in + Task { @MainActor in + self?.process(events) + } + } + .store(in: &cancellables) + } + + var isRunning: Bool { + coordinator.backend.isRunning + } + + var canCancel: Bool { + coordinator.backend.canCancel + } + + var selectedDevice: DiscoveredDevice? { + guard let selectedDeviceID else { + return nil + } + return devices.first { $0.id == selectedDeviceID } + } + + var hostFieldText: String { + switch entryMode { + case .discover: + return selectedDevice?.host ?? "" + case .manual: + return manualHost + } + } + + var isHostFieldEditable: Bool { + entryMode == .manual + } + + var bonjourTimeoutValue: Double? { + nonNegativeDouble(bonjourTimeout) + } + + var canConfigure: Bool { + let hasTarget: Bool + switch entryMode { + case .discover: + hasTarget = selectedDevice != nil + case .manual: + hasTarget = !manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return !isRunning + && !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && hasTarget + } + + func setEntryMode(_ mode: AddDeviceEntryMode) { + guard entryMode != mode else { + return + } + switch mode { + case .discover: + entryMode = .discover + selectedDeviceID = nil + manualHost = "" + savedProfile = nil + error = nil + currentStage = nil + state = devices.isEmpty ? .idle : .discoveryReady + case .manual: + startManualEntry() + } + } + + func startManualEntry() { + entryMode = .manual + state = .manualEntry + devices = [] + selectedDeviceID = nil + savedProfile = nil + error = nil + currentStage = nil + } + + func promptForPassword() { + guard hasSelectedTarget else { + failLocally("Choose a discovered device or enter a host.") + return + } + state = .passwordEntry + error = nil + } + + func runDiscover() { + guard let timeout = bonjourTimeoutValue else { + failLocally("Bonjour timeout must be a non-negative number.") + return + } + guard !coordinator.backend.isRunning else { + rejectRun("Another operation is already running.") + return + } + resetRunState(clearDevices: true) + entryMode = .discover + manualHost = "" + switch coordinator.run(operation: "discover", params: OperationParams.discover(timeout: timeout), profile: nil) { + case .started(let operation): + activeOperation = operation + state = .discovering + case .rejected(let message): + rejectRun(message) + } + } + + func runConfigure() { + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPassword.isEmpty else { + state = .passwordEntry + failLocally("Time Capsule password is required.") + return + } + let selectedDevice = entryMode == .discover ? selectedDevice : nil + let trimmedHost = manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard selectedDevice != nil || (entryMode == .manual && !trimmedHost.isEmpty) else { + failLocally("Choose a discovered device or enter a host.") + return + } + + let targetHost = selectedDevice?.host ?? trimmedHost + let existing = registry.matchingProfile(host: targetHost, bonjourFullname: selectedDevice?.fullname) + let profileID = existing?.id ?? UUID().uuidString.lowercased() + pendingProfileID = profileID + pendingDiscoveredDevice = selectedDevice + + let context = DeviceRuntimeContext( + profileID: profileID, + configURL: DeviceProfile.configURL(for: profileID, applicationSupportURL: registry.applicationSupportURL) + ) + + guard !coordinator.backend.isRunning else { + pendingProfileID = nil + pendingDiscoveredDevice = nil + rejectRun("Another operation is already running.") + return + } + resetRunState(clearDevices: false) + switch coordinator.run( + operation: "configure", + params: OperationParams.configure( + host: targetHost, + selectedRecord: selectedDevice?.rawRecord, + password: password, + debugLogging: debugLogging + ), + context: context, + activeDeviceID: profileID + ) { + case .started(let operation): + activeOperation = operation + state = .configuring + case .rejected(let message): + pendingProfileID = nil + pendingDiscoveredDevice = nil + rejectRun(message) + } + } + + func select(_ device: DiscoveredDevice) { + entryMode = .discover + selectedDeviceID = device.id + manualHost = device.host + if let existing = registry.matchingProfile(host: device.host, bonjourFullname: device.fullname) { + savedProfile = existing + state = .saved + error = nil + return + } + state = .passwordEntry + } + + func reset() { + coordinator.backend.clear() + devices = [] + selectedDeviceID = nil + entryMode = .discover + manualHost = "" + password = "" + savedProfile = nil + error = nil + currentStage = nil + pendingProfileID = nil + pendingDiscoveredDevice = nil + activeOperation = nil + lastProcessedEventCount = 0 + state = .idle + } + + func cancel() { + coordinator.cancel() + } + + private func resetRunState(clearDevices: Bool) { + coordinator.backend.clear() + lastProcessedEventCount = 0 + error = nil + currentStage = nil + savedProfile = nil + activeOperation = nil + if clearDevices { + devices = [] + selectedDeviceID = nil + if entryMode == .discover { + manualHost = "" + } + } + } + + private func process(_ events: [BackendEvent]) { + if events.count < lastProcessedEventCount { + lastProcessedEventCount = 0 + } + guard events.count > lastProcessedEventCount else { + return + } + for event in events.dropFirst(lastProcessedEventCount) { + handle(event) + } + lastProcessedEventCount = events.count + } + + private func handle(_ event: BackendEvent) { + guard event.operation == "discover" || event.operation == "configure" else { + return + } + guard activeOperation?.operation == event.operation else { + return + } + if let stage = OperationStageState(event: event) { + currentStage = stage + return + } + if event.type == "error" { + applyError(event) + return + } + guard event.type == "result" else { + return + } + if event.ok == false { + failFromResult(event) + return + } + switch event.operation { + case "discover": + applyDiscoverResult(event) + case "configure": + applyConfigureResult(event) + default: + break + } + } + + private func applyDiscoverResult(_ event: BackendEvent) { + do { + let payload = try event.decodePayload(DiscoverPayload.self) + devices = payload.devices.enumerated().map { index, device in + DiscoveredDevice(payload: device, index: index) + } + selectedDeviceID = devices.count == 1 ? devices[0].id : nil + manualHost = devices.count == 1 ? devices[0].host : "" + state = devices.isEmpty ? .discoveryEmpty : .discoveryReady + error = nil + activeOperation = nil + } catch { + failContract(error) + } + } + + private func applyConfigureResult(_ event: BackendEvent) { + do { + state = .savingProfile + let payload = try event.decodePayload(ConfigurePayload.self) + let configured = ConfiguredDeviceState(payload: payload) + let profileID = pendingProfileID ?? UUID().uuidString.lowercased() + let profile = try registry.saveConfiguredDevice( + configuredDevice: configured, + discoveredDevice: pendingDiscoveredDevice, + passwordState: .missing, + preferredID: profileID + ) + do { + try passwordStore.save(password, for: profile.keychainAccount) + var saved = profile + saved.passwordState = .available + saved = try registry.save(saved) + savedProfile = saved + } catch { + registry.updatePasswordState(.missing, for: profile.id) + savedProfile = registry.profile(id: profile.id) ?? profile + } + error = nil + state = .saved + activeOperation = nil + } catch { + failContract(error) + } + } + + private func applyError(_ event: BackendEvent) { + error = BackendErrorViewModel(event: event) + switch event.code { + case "auth_failed": + state = .authFailed + case "unsupported_device": + state = .unsupported + default: + state = .failed + } + activeOperation = nil + } + + private func failFromResult(_ event: BackendEvent) { + error = BackendErrorViewModel( + operation: event.operation, + code: "operation_failed", + message: event.payloadSummaryText ?? event.summary + ) + state = .failed + activeOperation = nil + } + + private func failContract(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "add-device", + code: "contract_decode_failed", + message: error.localizedDescription + ) + state = .failed + activeOperation = nil + } + + private func failLocally(_ message: String) { + error = BackendErrorViewModel( + operation: "add-device", + code: "validation_failed", + message: message + ) + currentStage = nil + state = .failed + } + + private func rejectRun(_ message: String) { + error = BackendErrorViewModel( + operation: "add-device", + code: "operation_rejected", + message: message + ) + currentStage = nil + state = .failed + activeOperation = nil + } + + private func nonNegativeDouble(_ text: String) -> Double? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(trimmed), value.isFinite, value >= 0 else { + return nil + } + return value + } + + private var hasSelectedTarget: Bool { + switch entryMode { + case .discover: + return selectedDevice != nil + case .manual: + return !manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift new file mode 100644 index 00000000..c17d5f27 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift @@ -0,0 +1,164 @@ +import Combine +import Foundation + +enum DashboardPrimaryAction: String, Equatable { + case addDevice + case replacePassword + case runCheckup + case installSMB + case viewCheckup + case openSMB +} + +struct DeviceDashboardSummary: Equatable { + let profile: DeviceProfile + let passwordState: DevicePasswordState + let primaryAction: DashboardPrimaryAction + let hostWarning: HostCompatibilityWarning? +} + +@MainActor +final class AppStore: ObservableObject { + @Published var selectedDeviceID: DeviceProfile.ID? + @Published var showingAddDevice = false + + let appReadinessStore: AppReadinessStore + let deviceRegistry: DeviceRegistryStore + let operationCoordinator: OperationCoordinator + let passwordStore: PasswordStore + + private var cancellables: Set = [] + + convenience init() { + let coordinator = OperationCoordinator() + self.init( + appReadinessStore: AppReadinessStore(backend: coordinator.backend), + deviceRegistry: DeviceRegistryStore(), + operationCoordinator: coordinator, + passwordStore: KeychainPasswordStore() + ) + } + + init( + appReadinessStore: AppReadinessStore, + deviceRegistry: DeviceRegistryStore, + operationCoordinator: OperationCoordinator, + passwordStore: PasswordStore + ) { + self.appReadinessStore = appReadinessStore + self.deviceRegistry = deviceRegistry + self.operationCoordinator = operationCoordinator + self.passwordStore = passwordStore + + appReadinessStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + deviceRegistry.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + operationCoordinator.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + deviceRegistry.$profiles + .sink { [weak self] profiles in + Task { @MainActor in + self?.syncSelection(profiles: profiles) + } + } + .store(in: &cancellables) + } + + var selectedProfile: DeviceProfile? { + deviceRegistry.profile(id: selectedDeviceID) + } + + var backend: BackendClient { + operationCoordinator.backend + } + + func start() { + deviceRegistry.load() + refreshPasswordStates() + appReadinessStore.start() + } + + func select(_ profile: DeviceProfile) { + selectedDeviceID = profile.id + showingAddDevice = false + } + + func showAddDevice() { + selectedDeviceID = nil + showingAddDevice = true + } + + func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary { + let passwordState = passwordStore.state(for: profile.keychainAccount) + let primaryAction: DashboardPrimaryAction + if passwordState != .available { + primaryAction = .replacePassword + } else if profile.lastCheckup == nil { + primaryAction = .runCheckup + } else if profile.lastDeploy == nil { + primaryAction = .installSMB + } else if profile.lastCheckup?.failCount ?? 0 > 0 || profile.lastCheckup?.warnCount ?? 0 > 0 { + primaryAction = .viewCheckup + } else { + primaryAction = .openSMB + } + return DeviceDashboardSummary( + profile: profile, + passwordState: passwordState, + primaryAction: primaryAction, + hostWarning: HostCompatibilityPolicy.warning() + ) + } + + func password(for profile: DeviceProfile) -> String? { + do { + return try passwordStore.password(for: profile.keychainAccount) + } catch PasswordStoreError.missing { + deviceRegistry.updatePasswordState(.missing, for: profile.id) + return nil + } catch { + deviceRegistry.updatePasswordState(.keychainUnavailable, for: profile.id) + return nil + } + } + + func savePassword(_ password: String, for profile: DeviceProfile) throws { + try passwordStore.save(password, for: profile.keychainAccount) + deviceRegistry.updatePasswordState(.available, for: profile.id) + } + + func forget(_ profile: DeviceProfile) throws { + try passwordStore.deletePassword(for: profile.keychainAccount) + try deviceRegistry.delete(profile) + if selectedDeviceID == profile.id { + selectedDeviceID = deviceRegistry.profiles.first?.id + showingAddDevice = false + } + } + + func refreshPasswordStates() { + for profile in deviceRegistry.profiles { + deviceRegistry.updatePasswordState(passwordStore.state(for: profile.keychainAccount), for: profile.id) + } + } + + private func syncSelection(profiles: [DeviceProfile]) { + if let selectedDeviceID, profiles.contains(where: { $0.id == selectedDeviceID }) { + return + } + selectedDeviceID = profiles.first?.id + if !profiles.isEmpty { + showingAddDevice = false + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index b11d6ed3..c452de77 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -24,6 +24,9 @@ final class BackendClient: ObservableObject { } func clear() { + guard !isRunning else { + return + } events.removeAll() lastExitCode = nil pendingConfirmation = nil @@ -36,15 +39,19 @@ final class BackendClient: ObservableObject { isRunning && (currentCancellable ?? true) } - func run(operation: String, params: [String: JSONValue] = [:]) { + func run(operation: String, params: [String: JSONValue] = [:], context: DeviceRuntimeContext? = nil) { guard !isRunning else { return } + var runParams = params + if let context, runParams["config"] == nil { + runParams["config"] = .string(context.configURL.path) + } isRunning = true lastExitCode = nil pendingConfirmation = nil currentStage = nil currentRisk = nil currentCancellable = nil - activeCall = BackendCall(operation: operation, params: params) + activeCall = BackendCall(operation: operation, params: runParams, context: context) let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) let runner = self.runner let updateTarget = BackendClientUpdateTarget( @@ -55,11 +62,12 @@ final class BackendClient: ObservableObject { self?.finishRun(exitCode: exitCode) } ) - runTask = Task.detached(priority: .userInitiated) { [runner, updateTarget, helperPath, operation, params] in + runTask = Task.detached(priority: .userInitiated) { [runner, updateTarget, helperPath, operation, runParams, context] in let result = await runner.run( helperPath: helperPath.isEmpty ? nil : helperPath, operation: operation, - params: params + params: runParams, + context: context ) { event in await updateTarget.appendEvent(event) } @@ -75,7 +83,7 @@ final class BackendClient: ObservableObject { func confirmPending() { guard let confirmation = pendingConfirmation, !isRunning else { return } pendingConfirmation = nil - run(operation: confirmation.operation, params: confirmation.params) + run(operation: confirmation.operation, params: confirmation.params, context: confirmation.context) } fileprivate func appendEvent(_ event: BackendEvent) { @@ -86,7 +94,8 @@ final class BackendClient: ObservableObject { } if let activeCall, let confirmation = PendingConfirmation( confirmationEvent: event, - originalParams: activeCall.params + originalParams: activeCall.params, + context: activeCall.context ) { pendingConfirmation = confirmation } @@ -104,6 +113,7 @@ final class BackendClient: ObservableObject { private struct BackendCall: Sendable { let operation: String let params: [String: JSONValue] + let context: DeviceRuntimeContext? } private final class BackendClientUpdateTarget: Sendable { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift index 31b02133..f6596ac5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift @@ -93,6 +93,7 @@ struct DiscoverPayload: Decodable, Equatable { let schemaVersion: Int let instances: [BonjourServiceInstancePayload] let resolved: [BonjourResolvedServicePayload] + let devices: [DiscoveredDevicePayload] let counts: [String: Int] let summary: String @@ -100,9 +101,75 @@ struct DiscoverPayload: Decodable, Equatable { 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 { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift index 821d98e9..ec47013a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectionWorkflowStore.swift @@ -43,6 +43,17 @@ struct DiscoveredDevice: Identifiable, Equatable { let model: String? let rawRecord: JSONValue + init(payload: DiscoveredDevicePayload, index: Int) { + self.id = payload.id.isEmpty ? "discovered-\(index)" : payload.id + self.name = payload.name.isEmpty ? (payload.hostname.isEmpty ? "AirPort Device" : payload.hostname) : payload.name + self.host = payload.host + self.hostname = payload.hostname + self.addresses = payload.addresses.isEmpty ? payload.ipv4 + payload.ipv6 : payload.addresses + self.syap = payload.syap + self.model = payload.model + self.rawRecord = payload.selectedRecord + } + init(record: BonjourResolvedServicePayload, index: Int) { let stableParts = [ record.fullname, @@ -274,9 +285,9 @@ final class ConnectionWorkflowStore: ObservableObject { private func applyDiscoverResult(_ event: BackendEvent) { do { let payload = try event.decodePayload(DiscoverPayload.self) - let discoveredDevices = payload.resolved.enumerated().map { index, record in - DiscoveredDevice(record: record, index: index) - } + let discoveredDevices = payload.devices.isEmpty + ? payload.resolved.enumerated().map { index, record in DiscoveredDevice(record: record, index: index) } + : payload.devices.enumerated().map { index, device in DiscoveredDevice(payload: device, index: index) } devices = discoveredDevices selectedDeviceID = discoveredDevices.count == 1 ? discoveredDevices[0].id : nil error = nil diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index a2065a07..256fcbbf 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -1,181 +1,887 @@ +import AppKit import SwiftUI public struct ContentView: View { - @StateObject private var backend: BackendClient - @StateObject private var appReadinessStore: AppReadinessStore - @StateObject private var connectionStore: ConnectionWorkflowStore - @StateObject private var deployStore: DeployWorkflowStore - @StateObject private var doctorStore: DoctorStore - @StateObject private var maintenanceStore: MaintenanceStore - @State private var selection: Screen = .connect + @StateObject private var appStore: AppStore + @StateObject private var addDeviceStore: AddDeviceFlowStore + @StateObject private var dashboardStore: DashboardStore @State private var diagnosticsPresented = false - @State private var password = "" + @State private var replacementPassword = "" + @State private var profilePendingDeletion: DeviceProfile? + @State private var deleteErrorMessage: String? @MainActor public init() { - let backend = BackendClient() - _backend = StateObject(wrappedValue: backend) - _appReadinessStore = StateObject(wrappedValue: AppReadinessStore(backend: backend)) - _connectionStore = StateObject(wrappedValue: ConnectionWorkflowStore(backend: backend)) - _deployStore = StateObject(wrappedValue: DeployWorkflowStore(backend: backend)) - _doctorStore = StateObject(wrappedValue: DoctorStore(backend: backend)) - _maintenanceStore = StateObject(wrappedValue: MaintenanceStore(backend: backend)) + let appStore = AppStore() + _appStore = StateObject(wrappedValue: appStore) + _addDeviceStore = StateObject(wrappedValue: AddDeviceFlowStore( + coordinator: appStore.operationCoordinator, + registry: appStore.deviceRegistry, + passwordStore: appStore.passwordStore + )) + _dashboardStore = StateObject(wrappedValue: DashboardStore(appStore: appStore)) } public var body: some View { NavigationSplitView { - List(Screen.allCases, selection: $selection) { screen in - Label(screen.title, systemImage: screen.icon) - .tag(screen) - } - .navigationTitle("TimeCapsuleSMB") + sidebar } detail: { VStack(spacing: 0) { - if case .blocked = appReadinessStore.state { - AppReadinessBlockedView(store: appReadinessStore) { + if case .blocked = appStore.appReadinessStore.state { + AppReadinessBlockedView(store: appStore.appReadinessStore) { diagnosticsPresented = true } } else { - AppReadinessBannerView(store: appReadinessStore) { + AppReadinessBannerView(store: appStore.appReadinessStore) { diagnosticsPresented = true } - form + detail } - Divider() - EventList(events: visibleEvents) } .toolbar { ToolbarItemGroup { + Button { + appStore.showAddDevice() + } label: { + Label("Add", systemImage: "plus") + } Button { diagnosticsPresented = true } label: { Label("Diagnostics", systemImage: "wrench.and.screwdriver") } Button { - clearActive() + if let profile = appStore.selectedProfile { + profilePendingDeletion = profile + } else { + appStore.operationCoordinator.clear() + } } label: { - Label(L10n.string("toolbar.clear"), systemImage: "trash") + Label(appStore.selectedProfile == nil ? L10n.string("toolbar.clear") : "Forget", systemImage: "trash") } - .disabled(backend.isRunning) + .disabled(appStore.backend.isRunning) Button { - backend.cancel() + appStore.operationCoordinator.cancel() } label: { Label(L10n.string("toolbar.cancel"), systemImage: "xmark.circle") } - .disabled(!backend.canCancel) + .disabled(!appStore.backend.canCancel) } } } - .frame(minWidth: 980, minHeight: 680) + .frame(minWidth: 1080, minHeight: 720) .task { - appReadinessStore.start() + appStore.start() + } + .onChange(of: addDeviceStore.savedProfile) { profile in + guard let profile else { return } + appStore.select(profile) } .sheet(isPresented: $diagnosticsPresented) { AppDiagnosticsView( - store: appReadinessStore, - events: backend.events, - helperPath: $backend.helperPath + store: appStore.appReadinessStore, + events: appStore.backend.events, + helperPath: Binding( + get: { appStore.backend.helperPath }, + set: { appStore.backend.helperPath = $0 } + ) ) } + .confirmationDialog( + "Forget Time Capsule?", + isPresented: deleteConfirmationPresented, + presenting: profilePendingDeletion + ) { profile in + Button("Forget \(profile.title)", role: .destructive) { + do { + try appStore.forget(profile) + profilePendingDeletion = nil + } catch { + deleteErrorMessage = error.localizedDescription + } + } + Button(L10n.string("action.cancel"), role: .cancel) { + profilePendingDeletion = nil + } + } message: { profile in + Text("Remove \(profile.title) from this Mac. This does not uninstall SMB from the Time Capsule.") + } + .alert("Could Not Forget Time Capsule", isPresented: deleteErrorPresented) { + Button("OK", role: .cancel) { + deleteErrorMessage = nil + } + } message: { + Text(deleteErrorMessage ?? "") + } .alert( - backend.pendingConfirmation?.title ?? "", + appStore.backend.pendingConfirmation?.title ?? "", isPresented: confirmationPresented, - presenting: backend.pendingConfirmation + presenting: appStore.backend.pendingConfirmation ) { confirmation in Button(confirmation.actionTitle, role: .destructive) { - backend.confirmPending() + appStore.backend.confirmPending() } Button(L10n.string("action.cancel"), role: .cancel) { - backend.pendingConfirmation = nil + appStore.backend.pendingConfirmation = nil } } message: { confirmation in Text(confirmation.message) } } + private var deleteConfirmationPresented: Binding { + Binding( + get: { profilePendingDeletion != nil }, + set: { isPresented in + if !isPresented { + profilePendingDeletion = nil + } + } + ) + } + + private var deleteErrorPresented: Binding { + Binding( + get: { deleteErrorMessage != nil }, + set: { isPresented in + if !isPresented { + deleteErrorMessage = nil + } + } + ) + } + private var confirmationPresented: Binding { Binding( - get: { backend.pendingConfirmation != nil }, + get: { appStore.backend.pendingConfirmation != nil }, set: { isPresented in if !isPresented { - backend.pendingConfirmation = nil + appStore.backend.pendingConfirmation = nil } } ) } + private var sidebarSelection: Binding { + Binding( + get: { + if appStore.showingAddDevice { + return "add" + } + if let selectedDeviceID = appStore.selectedDeviceID { + return "device:\(selectedDeviceID)" + } + return "all" + }, + set: { value in + guard let value else { return } + if value == "add" { + appStore.showAddDevice() + } else if value == "all" { + appStore.selectedDeviceID = nil + appStore.showingAddDevice = false + } else if value.hasPrefix("device:") { + let id = String(value.dropFirst("device:".count)) + if let profile = appStore.deviceRegistry.profile(id: id) { + appStore.select(profile) + } + } + } + ) + } + + private var sidebar: some View { + List(selection: sidebarSelection) { + Label("All Time Capsules", systemImage: "externaldrive.connected.to.line.below") + .tag("all") + + Section("Devices") { + ForEach(appStore.deviceRegistry.profiles) { profile in + Label(profile.title, systemImage: "externaldrive") + .tag("device:\(profile.id)") + } + } + + Section { + Label("Add Time Capsule", systemImage: "plus.circle") + .tag("add") + } + } + .navigationTitle("TimeCapsuleSMB") + .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360) + } + @ViewBuilder - private var form: some View { - switch selection { - case .connect: - ConnectView(store: connectionStore, password: $password) - case .deploy: - DeployView(store: deployStore, password: $password) - case .doctor: - DoctorView(store: doctorStore, password: $password) - case .maintenance: - MaintenanceView(store: maintenanceStore, password: $password) - case .advanced: - CommandPanel(title: L10n.string("screen.advanced")) { - Text(L10n.string("advanced.flash_cli_only")) + private var detail: some View { + if appStore.showingAddDevice { + AddDeviceView(store: addDeviceStore) + } else if let profile = appStore.selectedProfile { + DeviceDashboardView( + profile: profile, + dashboardStore: dashboardStore, + appStore: appStore, + replacementPassword: $replacementPassword + ) + } else { + DeviceListOverviewView(appStore: appStore) + } + } +} + +private struct DeviceListOverviewView: View { + @ObservedObject var appStore: AppStore + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(appStore.deviceRegistry.profiles.isEmpty ? "No Time Capsules Saved" : "All Time Capsules") + .font(.title2.weight(.semibold)) + if appStore.deviceRegistry.profiles.isEmpty { + Text("Add a Time Capsule to configure SMB, run checkups, and manage maintenance tasks.") .foregroundStyle(.secondary) - Text(L10n.string("advanced.flash_help")) - .font(.system(.body, design: .monospaced)) + Button { + appStore.showAddDevice() + } label: { + Label("Add Time Capsule", systemImage: "plus.circle") + } + } else { + ForEach(appStore.deviceRegistry.profiles) { profile in + Button { + appStore.select(profile) + } label: { + HStack { + VStack(alignment: .leading) { + Text(profile.title) + .font(.body.weight(.medium)) + Text(profile.host) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(profile.payloadFamily ?? "Unchecked") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + Divider() + } } } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } +} + +private struct AddDeviceView: View { + @ObservedObject var store: AddDeviceFlowStore - private func clearActive() { - switch selection { - case .connect: - connectionStore.clear() - case .deploy: - deployStore.clear() - case .doctor: - doctorStore.clear() - case .maintenance: - maintenanceStore.clear() + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text("Add Time Capsule") + .font(.title2.weight(.semibold)) + Spacer() + Picker("Connection Method", selection: Binding( + get: { store.entryMode }, + set: { store.setEntryMode($0) } + )) { + ForEach(AddDeviceEntryMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(width: 360) + } + + HStack { + if store.entryMode == .discover { + Text(store.currentStage?.description ?? "Browse for AirPort Bonjour services") + .foregroundStyle(.secondary) + Button { + store.runDiscover() + } label: { + Label(L10n.string("button.discover"), systemImage: "network") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + } + Label(store.state.title, systemImage: statusIcon) + .foregroundStyle(statusColor) + } + .frame(minHeight: 28, alignment: .center) + + if store.entryMode == .discover && !store.devices.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Discovered Devices") + .font(.headline) + ForEach(store.devices) { device in + Button { + store.select(device) + } label: { + DeviceCandidateRow(device: device, selected: store.selectedDeviceID == device.id) + } + .buttonStyle(.plain) + } + } + } + + HStack { + TextField("Host or IP", text: Binding( + get: { store.hostFieldText }, + set: { store.manualHost = $0 } + )) + .disabled(!store.isHostFieldEditable) + SecureField("Time Capsule password", text: $store.password) + } + + HStack { + Button { + store.runConfigure() + } label: { + Label("Save Device", systemImage: "checkmark.circle") + } + .disabled(!store.canConfigure) + + Button { + store.reset() + } label: { + Label("Reset", systemImage: "arrow.counterclockwise") + } + .disabled(store.isRunning) + } + + if let profile = store.savedProfile { + Label("Saved \(profile.title)", systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + + if let error = store.error { + ErrorBlock(error: error) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var statusIcon: String { + switch store.state { + case .idle, .manualEntry, .passwordEntry: + return "circle" + case .discovering, .configuring, .savingProfile: + return "hourglass" + case .discoveryReady, .saved: + return "checkmark.circle" + case .discoveryEmpty: + return "magnifyingglass" + case .authFailed, .unsupported, .failed: + return "exclamationmark.triangle" + } + } + + private var statusColor: Color { + switch store.state { + case .discoveryReady, .saved: + return .green + case .authFailed, .unsupported, .failed: + return .red default: - backend.clear() + return .secondary } } +} - private var visibleEvents: [BackendEvent] { - backend.events.filter { !["capabilities", "validate-install"].contains($0.operation) } +private struct DeviceCandidateRow: View { + let device: DiscoveredDevice + let selected: Bool + + var body: some View { + HStack { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading) { + Text(device.name) + Text([device.host, device.hostname].filter { !$0.isEmpty }.joined(separator: " ")) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(device.model ?? device.syap ?? "") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) } +} + +private struct DeviceDashboardView: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var appStore: AppStore + @Binding var replacementPassword: String + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Picker("", selection: $dashboardStore.selectedTab) { + ForEach(DeviceDashboardTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + + Divider() + + ScrollView { + Group { + switch dashboardStore.selectedTab { + case .overview: + OverviewTab(profile: profile, dashboardStore: dashboardStore, appStore: appStore, replacementPassword: $replacementPassword) + case .install: + InstallTab(profile: profile, dashboardStore: dashboardStore) + case .checkup: + CheckupTab(profile: profile, dashboardStore: dashboardStore) + case .maintenance: + MaintenanceTab(profile: profile, dashboardStore: dashboardStore) + case .advanced: + AdvancedTab(profile: profile, appStore: appStore) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } } -private enum Screen: String, CaseIterable, Identifiable { - case connect - case deploy - case doctor - case maintenance - case advanced +private struct OverviewTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + @ObservedObject var appStore: AppStore + @Binding var replacementPassword: String + + var body: some View { + let summary = dashboardStore.summary(for: profile) + VStack(alignment: .leading, spacing: 16) { + if let warning = summary.hostWarning { + WarningBanner(warning: warning) + } + + Text(profile.title) + .font(.title2.weight(.semibold)) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { Text("Host").foregroundStyle(.secondary); Text(profile.host) } + GridRow { Text("Model").foregroundStyle(.secondary); Text(profile.model ?? "Unknown") } + GridRow { Text("Generation").foregroundStyle(.secondary); Text(profile.deviceGeneration ?? "Unknown") } + GridRow { Text("Payload").foregroundStyle(.secondary); Text(profile.payloadFamily ?? "Unknown") } + GridRow { Text("Password").foregroundStyle(.secondary); Text(summary.passwordState.rawValue) } + GridRow { Text("Last Checkup").foregroundStyle(.secondary); Text(profile.lastCheckup?.summary ?? "Never") } + GridRow { Text("Last Install").foregroundStyle(.secondary); Text(profile.lastDeploy?.summary ?? "Never") } + } - var id: String { rawValue } + HStack { + Button(primaryActionTitle(summary.primaryAction)) { + runPrimary(summary.primaryAction) + } + .buttonStyle(.borderedProminent) - var title: String { - switch self { - case .connect: return L10n.string("screen.connect") - case .deploy: return L10n.string("screen.deploy") - case .doctor: return L10n.string("screen.doctor") - case .maintenance: return L10n.string("screen.maintenance") - case .advanced: return L10n.string("screen.advanced") + Button { + dashboardStore.runCheckup(profile: profile) + } label: { + Label("Run Checkup", systemImage: "stethoscope") + } + } + + HStack { + SecureField("Replacement password", text: $replacementPassword) + Button { + try? appStore.savePassword(replacementPassword, for: profile) + replacementPassword = "" + } label: { + Label("Save Password", systemImage: "key") + } + .disabled(replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if let passwordError = dashboardStore.passwordError { + Text(passwordError) + .foregroundStyle(.red) + } + } + } + + private func primaryActionTitle(_ action: DashboardPrimaryAction) -> String { + switch action { + case .addDevice: + return "Add Time Capsule" + case .replacePassword: + return "Replace Password" + case .runCheckup: + return "Run Checkup" + case .installSMB: + return "Install SMB" + case .viewCheckup: + return "View Checkup" + case .openSMB: + return "Open SMB Address" + } + } + + private func runPrimary(_ action: DashboardPrimaryAction) { + switch action { + case .replacePassword: + replacementPassword = "" + case .runCheckup: + dashboardStore.runCheckup(profile: profile) + case .viewCheckup: + dashboardStore.selectedTab = .checkup + case .openSMB: + openSMBAddress() + case .installSMB: + dashboardStore.runInstallPlan(profile: profile) + case .addDevice: + appStore.showAddDevice() + } + } + + private func openSMBAddress() { + let host = profile.host + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"^.*@"#, with: "", options: .regularExpression) + guard !host.isEmpty, let url = URL(string: "smb://\(host)") else { + return + } + NSWorkspace.shared.open(url) + } +} + +private struct InstallTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + + var body: some View { + let store = dashboardStore.deployStore + VStack(alignment: .leading, spacing: 12) { + Text("Install / Update") + .font(.title2.weight(.semibold)) + HStack { + Toggle(L10n.string("toggle.enable_nbns"), isOn: $dashboardStore.deployStore.nbnsEnabled) + Toggle(L10n.string("toggle.no_reboot"), isOn: $dashboardStore.deployStore.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $dashboardStore.deployStore.noWait) + Toggle(L10n.string("toggle.force_debug_logging"), isOn: $dashboardStore.deployStore.debugLogging) + TextField(L10n.string("field.mount_wait"), text: $dashboardStore.deployStore.mountWait) + .frame(width: 150) + } + HStack { + Button { + dashboardStore.runInstallPlan(profile: profile) + } label: { + Label("Plan Install", systemImage: "doc.text.magnifyingglass") + } + .disabled(store.isRunning || store.mountWaitValue == nil) + Button { + dashboardStore.runInstall(profile: profile) + } label: { + Label("Install SMB", systemImage: "square.and.arrow.up") + } + .disabled(!store.canDeploy) + Label(store.state.title, systemImage: "circle") + } + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let plan = store.plan { + SummaryGrid(rows: [ + ("Host", plan.host), + ("Payload", plan.payloadFamily ?? "unknown"), + ("Reboot", plan.requiresReboot ? "required" : "not required"), + ("Actions", "\(plan.uploads.count) uploads") + ]) + } + if let result = store.result { + SummaryGrid(rows: [ + ("Verified", result.verified == true ? "yes" : "no"), + ("Reboot Requested", result.rebootRequested == true ? "yes" : "no"), + ("Message", result.message ?? "Install completed.") + ]) + } + if let error = store.error { + ErrorBlock(error: error) + } + } + } +} + +private struct CheckupTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + + var body: some View { + let store = dashboardStore.doctorStore + VStack(alignment: .leading, spacing: 12) { + Text("Checkup") + .font(.title2.weight(.semibold)) + HStack { + TextField(L10n.string("field.bonjour_timeout"), text: $dashboardStore.doctorStore.bonjourTimeout) + .frame(width: 180) + Button { + dashboardStore.runCheckup(profile: profile) + } label: { + Label("Run Checkup", systemImage: "stethoscope") + } + .disabled(store.isRunning || store.bonjourTimeoutValue == nil) + Label(store.state.title, systemImage: "circle") + } + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let summary = store.summary { + SummaryGrid(rows: [ + ("PASS", "\(summary.passCount)"), + ("WARN", "\(summary.warnCount)"), + ("FAIL", "\(summary.failCount)"), + ("INFO", "\(summary.infoCount)") + ]) + ForEach(summary.groups) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.domain).font(.headline) + ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in + HStack { + Text(check.status) + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(check.message) + .font(.caption) + } + } + } + } + } + if let error = store.error { + ErrorBlock(error: error) + } + } + } +} + +private struct MaintenanceTab: View { + let profile: DeviceProfile + @ObservedObject var dashboardStore: DashboardStore + + var body: some View { + let store = dashboardStore.maintenanceStore + VStack(alignment: .leading, spacing: 12) { + Text("Maintenance") + .font(.title2.weight(.semibold)) + Picker("Maintenance", selection: $dashboardStore.maintenanceStore.selectedWorkflow) { + Text("NetBSD4 Activation").tag(MaintenanceWorkflow.activate) + Text("Uninstall").tag(MaintenanceWorkflow.uninstall) + Text("Disk Repair").tag(MaintenanceWorkflow.fsck) + Text("File Metadata Repair").tag(MaintenanceWorkflow.repairXattrs) + } + .pickerStyle(.segmented) + + HStack { + TextField(L10n.string("field.mount_wait"), text: $dashboardStore.maintenanceStore.mountWait) + .frame(width: 150) + Toggle(L10n.string("toggle.no_reboot"), isOn: $dashboardStore.maintenanceStore.noReboot) + Toggle(L10n.string("toggle.no_wait"), isOn: $dashboardStore.maintenanceStore.noWait) + } + + maintenanceControls(store: store) + + if let stage = store.currentStage { + StageLine(stage: stage) + } + if let error = store.error { + ErrorBlock(error: error) + } + } + } + + @ViewBuilder + private func maintenanceControls(store: MaintenanceStore) -> some View { + switch store.selectedWorkflow { + case .activate: + HStack { + Button("Plan Start SMB") { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.planActivation(password: password, profile: profile) + } + } + Button("Start SMB") { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.runActivation(password: password, profile: profile) + } + } + .disabled(!store.canRunActivation) + Label(store.activateState.title, systemImage: "circle") + } + case .uninstall: + HStack { + Button("Plan Uninstall") { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.planUninstall(password: password, profile: profile) + } + } + Button("Uninstall") { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.runUninstall(password: password, profile: profile) + } + } + .disabled(!store.canRunUninstall) + Label(store.uninstallState.title, systemImage: "circle") + } + case .fsck: + VStack(alignment: .leading, spacing: 8) { + HStack { + Button("Find Volumes") { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.refreshFsckTargets(password: password, profile: profile) + } + } + Button("Plan Disk Repair") { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.planFsck(password: password, profile: profile) + } + } + .disabled(!store.canPlanFsck) + Button("Run Disk Repair") { + if let password = dashboardStore.maintenancePassword(for: profile) { + store.runFsck(password: password, profile: profile) + } + } + .disabled(!store.canRunFsck) + Label(store.fsckState.title, systemImage: "circle") + } + ForEach(store.fsckTargets) { target in + Button { + store.selectedFsckTargetID = target.id + } label: { + HStack { + Image(systemName: store.selectedFsckTargetID == target.id ? "checkmark.circle.fill" : "circle") + Text(target.name ?? target.device) + Text(target.mountpoint).foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + } + case .repairXattrs: + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.string("field.repair_xattrs_path"), text: $dashboardStore.maintenanceStore.repairPath) + HStack { + Button("Scan Metadata") { + store.scanRepairXattrs() + } + Button("Repair Metadata") { + store.runRepairXattrs() + } + .disabled(!store.canRepairXattrs) + Label(store.repairState.title, systemImage: "circle") + } + if let scan = store.repairScan { + Text("\(scan.repairableCount) repairable item(s)") + .foregroundStyle(.secondary) + } + } } } +} + +private struct AdvancedTab: View { + let profile: DeviceProfile + @ObservedObject var appStore: AppStore - var icon: String { - switch self { - case .connect: return "network" - case .deploy: return "square.and.arrow.up" - case .doctor: return "stethoscope" - case .maintenance: return "wrench.and.screwdriver" - case .advanced: return "exclamationmark.triangle" + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Advanced") + .font(.title2.weight(.semibold)) + SummaryGrid(rows: [ + ("Profile ID", profile.id), + ("Config", profile.configPath), + ("Helper", appStore.backend.helperPath.isEmpty ? "Auto" : appStore.backend.helperPath) + ]) + EventList(events: appStore.backend.events) } } } +private struct WarningBanner: View { + let warning: HostCompatibilityWarning + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + VStack(alignment: .leading) { + Text(warning.title) + .font(.body.weight(.medium)) + Text(warning.message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(Color.yellow.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +private 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) + } +} + +private 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) + } + } + } +} + +private struct ErrorBlock: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + if let recovery = error.recovery, !recovery.actions.isEmpty { + ForEach(recovery.actions, id: \.self) { action in + Text(action) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .foregroundStyle(.red) + } +} + private struct AppReadinessBannerView: View { @ObservedObject var store: AppReadinessStore let showDiagnostics: () -> Void @@ -335,21 +1041,6 @@ private struct AppDiagnosticsView: View { } } -private struct CommandPanel: View { - let title: String - @ViewBuilder var content: Content - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(title) - .font(.title2.weight(.semibold)) - content - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } -} - private struct EventList: View { let events: [BackendEvent] diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift new file mode 100644 index 00000000..1afa916b --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift @@ -0,0 +1,182 @@ +import Combine +import Foundation + +enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { + case overview + case install + case checkup + case maintenance + case advanced + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: + return "Overview" + case .install: + return "Install / Update" + case .checkup: + return "Checkup" + case .maintenance: + return "Maintenance" + case .advanced: + return "Advanced" + } + } +} + +@MainActor +final class DashboardStore: ObservableObject { + @Published var selectedTab: DeviceDashboardTab = .overview + @Published private(set) var passwordError: String? + + let appStore: AppStore + var deployStore: DeployWorkflowStore + var doctorStore: DoctorStore + var maintenanceStore: MaintenanceStore + + private var activeCheckupOperation: ActiveOperation? + private var activeDeployOperation: ActiveOperation? + private var cancellables: Set = [] + + init(appStore: AppStore) { + self.appStore = appStore + self.deployStore = DeployWorkflowStore(coordinator: appStore.operationCoordinator) + self.doctorStore = DoctorStore(coordinator: appStore.operationCoordinator) + self.maintenanceStore = MaintenanceStore(coordinator: appStore.operationCoordinator) + forwardChildChanges() + observeSnapshots() + } + + func summary(for profile: DeviceProfile) -> DeviceDashboardSummary { + appStore.dashboardSummary(for: profile) + } + + func runCheckup(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + passwordError = "Password is required." + return + } + passwordError = nil + selectedTab = .checkup + if case .started(let operation) = doctorStore.runDoctor(password: password, profile: profile) { + activeCheckupOperation = operation + } + } + + func runInstallPlan(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + passwordError = "Password is required." + return + } + passwordError = nil + selectedTab = .install + deployStore.nbnsEnabled = profile.settings.nbnsEnabled + deployStore.debugLogging = profile.settings.debugLogging + deployStore.mountWait = String(profile.settings.mountWaitSeconds) + _ = deployStore.runPlan(password: password, profile: profile) + } + + func runInstall(profile: DeviceProfile) { + guard let password = appStore.password(for: profile) else { + passwordError = "Password is required." + return + } + passwordError = nil + selectedTab = .install + if case .started(let operation) = deployStore.runDeploy(password: password, profile: profile) { + activeDeployOperation = operation + } + } + + func maintenancePassword(for profile: DeviceProfile) -> String? { + guard let password = appStore.password(for: profile) else { + passwordError = "Password is required." + return nil + } + passwordError = nil + selectedTab = .maintenance + return password + } + + 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?.updateDeploySnapshot(state: state) + } + } + .store(in: &cancellables) + } + + private func forwardChildChanges() { + deployStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + doctorStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + maintenanceStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + private func updateCheckupSnapshot(state: DoctorWorkflowState) { + guard [.passed, .warning, .failed, .runFailed].contains(state) else { + return + } + defer { + activeCheckupOperation = nil + } + guard [.passed, .warning, .failed].contains(state), + let profileID = activeCheckupOperation?.profileID, + let summary = doctorStore.summary else { + return + } + appStore.deviceRegistry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(), + state: state, + passCount: summary.passCount, + warnCount: summary.warnCount, + failCount: summary.failCount, + summary: "PASS \(summary.passCount), WARN \(summary.warnCount), FAIL \(summary.failCount)" + ), for: profileID) + } + + private func updateDeploySnapshot(state: DeployWorkflowState) { + guard [.deployed, .deployFailed].contains(state) else { + return + } + defer { + activeDeployOperation = nil + } + guard state == .deployed, + let profileID = activeDeployOperation?.profileID, + let profile = appStore.deviceRegistry.profile(id: profileID), + let result = deployStore.result else { + return + } + appStore.deviceRegistry.updateDeploy(DeviceDeploySnapshot( + deployedAt: Date(), + state: state, + payloadFamily: deployStore.plan?.payloadFamily ?? profile.payloadFamily, + rebootRequested: result.rebootRequested, + verified: result.verified, + summary: result.message ?? "Install completed." + ), for: profile.id) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift index 0ca6f177..ff406262 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift @@ -9,7 +9,7 @@ struct DeployOptions: Equatable { let mountWait: Int } -enum DeployWorkflowState: String, CaseIterable, Equatable { +enum DeployWorkflowState: String, CaseIterable, Equatable, Codable { case idle case planning case planReady @@ -70,7 +70,9 @@ final class DeployWorkflowStore: ObservableObject { @Published private(set) var plannedOptions: DeployOptions? let backend: BackendClient + private let coordinator: OperationCoordinator? + private var activeOperation: ActiveOperation? private var lastProcessedEventCount = 0 private var cancellables: Set = [] @@ -80,6 +82,17 @@ final class DeployWorkflowStore: ObservableObject { init(backend: BackendClient) { self.backend = backend + self.coordinator = nil + observeBackend(backend) + } + + init(coordinator: OperationCoordinator) { + self.backend = coordinator.backend + self.coordinator = coordinator + observeBackend(coordinator.backend) + } + + private func observeBackend(_ backend: BackendClient) { backend.$events .sink { [weak self] events in Task { @MainActor in @@ -109,20 +122,18 @@ final class DeployWorkflowStore: ObservableObject { !backend.isRunning && state == .planReady && plan != nil && currentOptions == plannedOptions } - func runPlan(password: String) { + @discardableResult + func runPlan(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { guard let options = currentOptions else { failLocally(state: .planFailed, message: "Mount wait must be a non-negative integer.") - return + return .rejected("Mount wait must be a non-negative integer.") + } + guard !backend.isRunning else { + rejectRun(state: .planFailed, message: "Another operation is already running.") + return .rejected("Another operation is already running.") } backend.clear() - lastProcessedEventCount = 0 - state = .planning - plan = nil - result = nil - error = nil - currentStage = nil - plannedOptions = options - backend.run( + let start = run( operation: "deploy", params: OperationParams.deployPlan( noReboot: options.noReboot, @@ -131,11 +142,26 @@ final class DeployWorkflowStore: ObservableObject { debugLogging: options.debugLogging, mountWait: Double(options.mountWait), password: password - ) + ), + profile: profile ) + guard case .started(let operation) = start else { + rejectRun(state: .planFailed, message: start.rejectionMessage ?? "Operation could not start.") + return start + } + lastProcessedEventCount = 0 + activeOperation = operation + state = .planning + plan = nil + result = nil + error = nil + currentStage = nil + plannedOptions = options + return start } - func runDeploy(password: String) { + @discardableResult + func runDeploy(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { guard let options = plannedOptions, plan != nil, currentOptions == options else { state = .planStale error = BackendErrorViewModel( @@ -143,18 +169,17 @@ final class DeployWorkflowStore: ObservableObject { code: "plan_stale", message: "Review and regenerate the deploy plan before deploying." ) - return + return .rejected("Review and regenerate the deploy plan before deploying.") } guard state == .planReady else { - return + return .rejected("Deploy plan is not ready.") + } + guard !backend.isRunning else { + rejectRun(state: .deployFailed, message: "Another operation is already running.") + return .rejected("Another operation is already running.") } backend.clear() - lastProcessedEventCount = 0 - state = .deploying - result = nil - error = nil - currentStage = nil - backend.run( + let start = run( operation: "deploy", params: OperationParams.deployRun( noReboot: options.noReboot, @@ -163,8 +188,20 @@ final class DeployWorkflowStore: ObservableObject { debugLogging: options.debugLogging, mountWait: Double(options.mountWait), password: password - ) + ), + profile: profile ) + guard case .started(let operation) = start else { + rejectRun(state: .deployFailed, message: start.rejectionMessage ?? "Operation could not start.") + return start + } + lastProcessedEventCount = 0 + activeOperation = operation + state = .deploying + result = nil + error = nil + currentStage = nil + return start } func clear() { @@ -176,6 +213,7 @@ final class DeployWorkflowStore: ObservableObject { error = nil currentStage = nil plannedOptions = nil + activeOperation = nil } func cancel() { @@ -219,6 +257,9 @@ final class DeployWorkflowStore: ObservableObject { guard event.operation == "deploy" else { return } + guard activeOperation?.operation == event.operation else { + return + } if let stage = OperationStageState(event: event) { currentStage = stage @@ -257,6 +298,7 @@ final class DeployWorkflowStore: ObservableObject { result = nil error = nil state = .planReady + activeOperation = nil } catch { failContract(state: .planFailed, error: error) } @@ -267,6 +309,7 @@ final class DeployWorkflowStore: ObservableObject { result = try event.decodePayload(DeployResultPayload.self) error = nil state = .deployed + activeOperation = nil } catch { failContract(state: .deployFailed, error: error) } @@ -280,6 +323,7 @@ final class DeployWorkflowStore: ObservableObject { } error = BackendErrorViewModel(event: event) state = state == .planning ? .planFailed : .deployFailed + activeOperation = nil } private func applyFailureResult(_ event: BackendEvent) { @@ -289,6 +333,7 @@ final class DeployWorkflowStore: ObservableObject { message: event.payloadSummaryText ?? event.summary ) state = state == .planning ? .planFailed : .deployFailed + activeOperation = nil } private func failContract(state: DeployWorkflowState, error: Error) { @@ -298,6 +343,7 @@ final class DeployWorkflowStore: ObservableObject { message: error.localizedDescription ) self.state = state + activeOperation = nil } private func failLocally(state: DeployWorkflowState, message: String) { @@ -308,6 +354,18 @@ final class DeployWorkflowStore: ObservableObject { ) currentStage = nil self.state = state + activeOperation = nil + } + + private func rejectRun(state: DeployWorkflowState, message: String) { + error = BackendErrorViewModel( + operation: "deploy", + code: "operation_rejected", + message: message + ) + currentStage = nil + self.state = state + activeOperation = nil } private func nonNegativeInteger(_ text: String) -> Int? { @@ -317,4 +375,18 @@ final class DeployWorkflowStore: ObservableObject { } return value } + + private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { + if let coordinator { + return coordinator.run(operation: operation, params: params, profile: profile) + } else { + guard !backend.isRunning else { + return .rejected("Another operation is already running.") + } + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run(operation: operation, params: params, context: profile?.runtimeContext) + return .started(activeOperation) + } + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift new file mode 100644 index 00000000..062f80b0 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift @@ -0,0 +1,181 @@ +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 +} + +struct DeviceProfileSettings: Codable, Equatable { + var nbnsEnabled: Bool + var debugLogging: Bool + var mountWaitSeconds: Int + + static let `default` = DeviceProfileSettings( + nbnsEnabled: true, + debugLogging: false, + mountWaitSeconds: 30 + ) +} + +struct DeviceCheckupSnapshot: Codable, Equatable { + var checkedAt: Date + var state: DoctorWorkflowState + var passCount: Int + var warnCount: Int + var failCount: Int + var summary: String +} + +struct DeviceDeploySnapshot: Codable, Equatable { + var deployedAt: Date + var state: DeployWorkflowState + var payloadFamily: String? + var rebootRequested: Bool? + var verified: Bool? + var summary: String +} + +struct DeviceProfile: Codable, Equatable, Identifiable { + typealias ID = String + + var id: ID + var displayName: String + var host: String + var bonjourName: String? + var bonjourFullname: String? + var hostname: String? + var addresses: [String] + var syap: String? + var model: String? + var osName: String? + var osRelease: String? + var arch: String? + var elfEndianness: String? + var payloadFamily: String? + var deviceGeneration: String? + var configPath: String + var keychainAccount: String + var createdAt: Date + var updatedAt: Date + var lastCheckup: DeviceCheckupSnapshot? + var lastDeploy: DeviceDeploySnapshot? + var settings: DeviceProfileSettings + var passwordState: DevicePasswordState + + var title: String { + let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedName.isEmpty { + return trimmedName + } + if let bonjourName = bonjourName?.trimmingCharacters(in: .whitespacesAndNewlines), !bonjourName.isEmpty { + return bonjourName + } + if let model = model?.trimmingCharacters(in: .whitespacesAndNewlines), !model.isEmpty { + return model + } + return normalizedHost.isEmpty ? "Time Capsule" : normalizedHost + } + + var normalizedHost: String { + Self.normalizedHost(host) + } + + var runtimeContext: DeviceRuntimeContext { + DeviceRuntimeContext(profileID: id, configURL: URL(fileURLWithPath: configPath)) + } + + static func configURL(for id: ID, applicationSupportURL: URL) -> URL { + applicationSupportURL + .appendingPathComponent("Devices", isDirectory: true) + .appendingPathComponent(id, isDirectory: true) + .appendingPathComponent(".env") + } + + static func normalizedHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let withoutUser = trimmed.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false).last.map(String.init) ?? trimmed + return withoutUser + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + } + + static func matches(_ left: DeviceProfile, _ right: DeviceProfile) -> Bool { + if let leftFullname = normalizedOptional(left.bonjourFullname), + let rightFullname = normalizedOptional(right.bonjourFullname), + leftFullname == rightFullname { + return true + } + let leftHost = left.normalizedHost + let rightHost = right.normalizedHost + return !leftHost.isEmpty && leftHost == rightHost + } + + static func make( + id: ID = UUID().uuidString.lowercased(), + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + applicationSupportURL: URL, + existing: DeviceProfile? = nil, + date: Date = Date() + ) -> DeviceProfile { + let resolvedID = existing?.id ?? id + let compatibility = configuredDevice.compatibility + return DeviceProfile( + id: resolvedID, + displayName: existing?.displayName ?? discoveredDevice?.name ?? configuredDevice.model ?? "Time Capsule", + host: configuredDevice.host, + bonjourName: discoveredDevice?.name ?? existing?.bonjourName, + bonjourFullname: discoveredDevice?.fullname ?? existing?.bonjourFullname, + hostname: discoveredDevice?.hostname ?? existing?.hostname, + addresses: discoveredDevice?.addresses ?? existing?.addresses ?? [], + syap: configuredDevice.syap ?? existing?.syap, + model: configuredDevice.model ?? existing?.model, + osName: compatibility?.osName ?? existing?.osName, + osRelease: compatibility?.osRelease ?? existing?.osRelease, + arch: compatibility?.arch ?? existing?.arch, + elfEndianness: compatibility?.elfEndianness ?? existing?.elfEndianness, + payloadFamily: compatibility?.payloadFamily ?? existing?.payloadFamily, + deviceGeneration: compatibility?.deviceGeneration ?? existing?.deviceGeneration, + configPath: Self.configURL(for: resolvedID, applicationSupportURL: applicationSupportURL).path, + keychainAccount: resolvedID, + createdAt: existing?.createdAt ?? date, + updatedAt: date, + lastCheckup: existing?.lastCheckup, + lastDeploy: existing?.lastDeploy, + settings: existing?.settings ?? .default, + passwordState: existing?.passwordState ?? .unknown + ) + } + + private static func normalizedOptional(_ value: String?) -> String? { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !normalized.isEmpty else { + return nil + } + return normalized + } +} + +extension DiscoveredDevice { + var fullname: String? { + guard case .object(let object) = rawRecord, + case .string(let value)? = object["fullname"] else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift new file mode 100644 index 00000000..d05d1175 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift @@ -0,0 +1,216 @@ +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 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 .io(let message): + return message + } + } +} + +@MainActor +final class DeviceRegistryStore: ObservableObject { + @Published private(set) var state: DeviceRegistryState = .idle + @Published private(set) var profiles: [DeviceProfile] = [] + @Published private(set) var error: DeviceRegistryError? + + let applicationSupportURL: URL + let registryURL: URL + let devicesDirectoryURL: URL + + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let now: () -> Date + + convenience init() { + let appSupport = BundleLayout.applicationSupportDirectory() ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/TimeCapsuleSMB", isDirectory: true) + self.init(applicationSupportURL: appSupport) + } + + init( + applicationSupportURL: URL, + fileManager: FileManager = .default, + now: @escaping () -> Date = Date.init + ) { + self.applicationSupportURL = applicationSupportURL + self.registryURL = applicationSupportURL.appendingPathComponent("devices.json") + self.devicesDirectoryURL = applicationSupportURL.appendingPathComponent("Devices", isDirectory: true) + self.fileManager = fileManager + self.now = now + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + } + + var isEmpty: Bool { + profiles.isEmpty + } + + func load() { + state = .loading + error = nil + do { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + guard fileManager.fileExists(atPath: registryURL.path) else { + profiles = [] + state = .empty + return + } + let data = try Data(contentsOf: registryURL) + profiles = try decoder.decode([DeviceProfile].self, from: data) + .sorted { $0.updatedAt > $1.updatedAt } + state = profiles.isEmpty ? .empty : .loaded + } catch let decoding as DecodingError { + profiles = [] + error = .corruptRegistry(String(describing: decoding)) + state = .failed + } catch { + profiles = [] + self.error = .io(error.localizedDescription) + state = .failed + } + } + + @discardableResult + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + ) throws -> DeviceProfile { + let existing = matchingProfile(host: configuredDevice.host, bonjourFullname: discoveredDevice?.fullname) + var profile = DeviceProfile.make( + id: preferredID, + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + applicationSupportURL: applicationSupportURL, + existing: existing, + date: now() + ) + profile.passwordState = passwordState + return try save(profile) + } + + @discardableResult + func save(_ profile: DeviceProfile) throws -> DeviceProfile { + state = .saving + error = nil + do { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: URL(fileURLWithPath: profile.configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var updated = profiles.filter { !DeviceProfile.matches($0, profile) && $0.id != profile.id } + updated.append(profile) + profiles = updated.sorted { $0.updatedAt > $1.updatedAt } + try persist() + state = profiles.isEmpty ? .empty : .loaded + return profile + } catch { + self.error = .io(error.localizedDescription) + state = .failed + throw error + } + } + + func delete(_ profile: DeviceProfile) throws { + state = .saving + error = nil + do { + profiles.removeAll { $0.id == profile.id } + let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() + if fileManager.fileExists(atPath: configDirectory.path) { + try fileManager.removeItem(at: configDirectory) + } + try persist() + state = profiles.isEmpty ? .empty : .loaded + } catch { + self.error = .io(error.localizedDescription) + state = .failed + throw error + } + } + + func updatePasswordState(_ state: DevicePasswordState, for profileID: DeviceProfile.ID) { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return + } + guard profiles[index].passwordState != state else { + return + } + profiles[index].passwordState = state + profiles[index].updatedAt = now() + try? persist() + } + + func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return + } + profiles[index].lastCheckup = snapshot + profiles[index].updatedAt = now() + try? persist() + } + + func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) { + guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { + return + } + profiles[index].lastDeploy = snapshot + profiles[index].updatedAt = now() + try? persist() + } + + func profile(id: DeviceProfile.ID?) -> DeviceProfile? { + guard let id else { + return nil + } + return profiles.first { $0.id == id } + } + + func matchingProfile(host: String, bonjourFullname: String?) -> DeviceProfile? { + let normalizedFullname = bonjourFullname?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let normalizedFullname, !normalizedFullname.isEmpty, + let profile = profiles.first(where: { $0.bonjourFullname?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalizedFullname }) { + return profile + } + let normalizedHost = DeviceProfile.normalizedHost(host) + guard !normalizedHost.isEmpty else { + return nil + } + return profiles.first { $0.normalizedHost == normalizedHost } + } + + private func persist() throws { + try fileManager.createDirectory(at: applicationSupportURL, withIntermediateDirectories: true) + let data = try encoder.encode(profiles) + try data.write(to: registryURL, options: [.atomic]) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift index 22800849..fc9ad9e2 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift @@ -8,7 +8,7 @@ struct DoctorOptions: Equatable { let skipSMB: Bool } -enum DoctorWorkflowState: String, CaseIterable, Equatable { +enum DoctorWorkflowState: String, CaseIterable, Equatable, Codable { case idle case running case passed @@ -99,7 +99,9 @@ final class DoctorStore: ObservableObject { @Published private(set) var currentStage: OperationStageState? let backend: BackendClient + private let coordinator: OperationCoordinator? + private var activeOperation: ActiveOperation? private var lastProcessedEventCount = 0 private var cancellables: Set = [] @@ -109,6 +111,17 @@ final class DoctorStore: ObservableObject { init(backend: BackendClient) { self.backend = backend + self.coordinator = nil + observeBackend(backend) + } + + init(coordinator: OperationCoordinator) { + self.backend = coordinator.backend + self.coordinator = coordinator + observeBackend(coordinator.backend) + } + + private func observeBackend(_ backend: BackendClient) { backend.$events .sink { [weak self] events in Task { @MainActor in @@ -134,19 +147,18 @@ final class DoctorStore: ObservableObject { nonNegativeDouble(bonjourTimeout) } - func runDoctor(password: String) { + @discardableResult + func runDoctor(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { guard let timeout = bonjourTimeoutValue else { failLocally(message: "Bonjour timeout must be a non-negative number.") - return + return .rejected("Bonjour timeout must be a non-negative number.") + } + guard !backend.isRunning else { + rejectRun("Another operation is already running.") + return .rejected("Another operation is already running.") } backend.clear() - lastProcessedEventCount = 0 - state = .running - payload = nil - summary = nil - error = nil - currentStage = nil - backend.run( + let start = run( operation: "doctor", params: OperationParams.doctor( bonjourTimeout: timeout, @@ -154,8 +166,21 @@ final class DoctorStore: ObservableObject { skipSSH: skipSSH, skipBonjour: skipBonjour, skipSMB: skipSMB - ) + ), + profile: profile ) + guard case .started(let operation) = start else { + rejectRun(start.rejectionMessage ?? "Operation could not start.") + return start + } + lastProcessedEventCount = 0 + activeOperation = operation + state = .running + payload = nil + summary = nil + error = nil + currentStage = nil + return start } func clear() { @@ -166,6 +191,7 @@ final class DoctorStore: ObservableObject { summary = nil error = nil currentStage = nil + activeOperation = nil } func cancel() { @@ -189,6 +215,9 @@ final class DoctorStore: ObservableObject { guard event.operation == "doctor" else { return } + guard activeOperation?.operation == event.operation else { + return + } if let stage = OperationStageState(event: event) { currentStage = stage @@ -198,6 +227,7 @@ final class DoctorStore: ObservableObject { if event.type == "error" { error = BackendErrorViewModel(event: event) state = .runFailed + activeOperation = nil return } @@ -220,6 +250,7 @@ final class DoctorStore: ObservableObject { } else { state = .passed } + activeOperation = nil } catch { self.error = BackendErrorViewModel( operation: "doctor", @@ -227,6 +258,7 @@ final class DoctorStore: ObservableObject { message: error.localizedDescription ) state = .runFailed + activeOperation = nil } } @@ -238,6 +270,18 @@ final class DoctorStore: ObservableObject { ) currentStage = nil state = .runFailed + activeOperation = nil + } + + private func rejectRun(_ message: String) { + error = BackendErrorViewModel( + operation: "doctor", + code: "operation_rejected", + message: message + ) + currentStage = nil + state = .runFailed + activeOperation = nil } private func nonNegativeDouble(_ text: String) -> Double? { @@ -247,4 +291,18 @@ final class DoctorStore: ObservableObject { } return value } + + private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { + if let coordinator { + return coordinator.run(operation: operation, params: params, profile: profile) + } else { + guard !backend.isRunning else { + return .rejected("Another operation is already running.") + } + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run(operation: operation, params: params, context: context) + return .started(activeOperation) + } + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift index 08ac68d5..d5472328 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperLocator.swift @@ -62,11 +62,14 @@ public struct HelperLocator { throw HelperLocatorError.notFound(attempts) } - public func helperEnvironment(for resolution: HelperResolution) -> [String: String] { + 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 output["TCAPSULE_CONFIG"] == nil { + 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 { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 0740dc9a..76934b41 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -12,6 +12,7 @@ public protocol HelperRunning: Sendable { helperPath: String?, operation: String, params: [String: JSONValue], + context: DeviceRuntimeContext?, onEvent: @escaping @Sendable (BackendEvent) async -> Void ) async -> HelperRunResult } @@ -31,6 +32,7 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { helperPath: String?, operation: String, params: [String: JSONValue], + context: DeviceRuntimeContext? = nil, onEvent: @escaping @Sendable (BackendEvent) async -> Void ) async -> HelperRunResult { let terminalTracker = TerminalEventTracker() @@ -50,7 +52,7 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { let process = Process() process.executableURL = resolution.executableURL process.arguments = ["api"] - process.environment = locator.helperEnvironment(for: resolution) + process.environment = locator.helperEnvironment(for: resolution, context: context) let input = Pipe() let output = Pipe() @@ -75,7 +77,11 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { } do { - let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(params)] + var requestParams = params + if let context, requestParams["config"] == nil { + requestParams["config"] = .string(context.configURL.path) + } + let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(requestParams)] let requestData = try JSONEncoder().encode(JSONValue.object(request)) try input.fileHandleForWriting.write(contentsOf: requestData) try input.fileHandleForWriting.close() diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift new file mode 100644 index 00000000..de27a56e --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift @@ -0,0 +1,28 @@ +import Foundation + +struct HostCompatibilityWarning: Equatable { + let title: String + let message: String +} + +enum HostCompatibilityPolicy { + static func warning(for version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion) -> HostCompatibilityWarning? { + guard version.majorVersion == 15 || version.majorVersion == 26 else { + return nil + } + if version.majorVersion == 15 && version.minorVersion == 7 && [5, 6, 7].contains(version.patchVersion) { + return timeMachineWarning(version: version) + } + if version.majorVersion == 26 && version.minorVersion == 4 { + return timeMachineWarning(version: version) + } + return nil + } + + private static func timeMachineWarning(version: OperatingSystemVersion) -> HostCompatibilityWarning { + HostCompatibilityWarning( + title: "macOS Time Machine Warning", + message: "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion) has known Time Machine network backup issues. SMB may work, but backup reliability can be affected by the host OS." + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift index 5564d6c3..c268088b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift @@ -139,11 +139,13 @@ final class MaintenanceStore: ObservableObject { @Published private(set) var error: BackendErrorViewModel? let backend: BackendClient + private let coordinator: OperationCoordinator? private var plannedUninstallOptions: MaintenanceOptions? private var plannedFsckOptions: MaintenanceOptions? private var plannedFsckTargetID: FsckTargetViewModel.ID? private var scannedRepairPath: String? + private var activeOperation: ActiveOperation? private var lastProcessedEventCount = 0 private var cancellables: Set = [] @@ -153,6 +155,17 @@ final class MaintenanceStore: ObservableObject { init(backend: BackendClient) { self.backend = backend + self.coordinator = nil + observeBackend(backend) + } + + init(coordinator: OperationCoordinator) { + self.backend = coordinator.backend + self.coordinator = coordinator + observeBackend(coordinator.backend) + } + + private func observeBackend(_ backend: BackendClient) { backend.$events .sink { [weak self] events in Task { @MainActor in @@ -212,50 +225,83 @@ final class MaintenanceStore: ObservableObject { && scannedRepairPath == trimmedRepairPath } - func planActivation(password: String) { - resetRunState() + @discardableResult + func planActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + let start = startRun( + operation: "activate", + params: OperationParams.activatePlan(password: password), + profile: profile, + workflow: .activate + ) + guard case .started = start else { + return start + } selectedWorkflow = .activate activateState = .planning activationPlan = nil activationResult = nil - backend.run(operation: "activate", params: OperationParams.activatePlan(password: password)) + return start } - func runActivation(password: String) { + @discardableResult + func runActivation(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .activate, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } guard canRunActivation else { failLocally(workflow: .activate, message: "Plan NetBSD4 activation before running it.") - return + return .rejected("Plan NetBSD4 activation before running it.") + } + let start = startRun( + operation: "activate", + params: OperationParams.activateRun(password: password), + profile: profile, + workflow: .activate + ) + guard case .started = start else { + return start } - resetRunState() selectedWorkflow = .activate activateState = .running activationResult = nil - backend.run(operation: "activate", params: OperationParams.activateRun(password: password)) + return start } - func planUninstall(password: String) { + @discardableResult + func planUninstall(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { guard let options = currentOptions else { failLocally(workflow: .uninstall, message: "Mount wait must be a non-negative integer.") - return + return .rejected("Mount wait must be a non-negative integer.") } - resetRunState() - selectedWorkflow = .uninstall - uninstallState = .planning - uninstallPlan = nil - uninstallResult = nil - plannedUninstallOptions = options - backend.run( + let start = startRun( operation: "uninstall", params: OperationParams.uninstallPlan( noReboot: options.noReboot, noWait: options.noWait, mountWait: Double(options.mountWait), password: password - ) + ), + profile: profile, + workflow: .uninstall ) + guard case .started = start else { + return start + } + selectedWorkflow = .uninstall + uninstallState = .planning + uninstallPlan = nil + uninstallResult = nil + plannedUninstallOptions = options + return start } - func runUninstall(password: String) { + @discardableResult + func runUninstall(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .uninstall, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } guard let options = plannedUninstallOptions, currentOptions == options, uninstallPlan != nil else { uninstallState = .planStale error = BackendErrorViewModel( @@ -263,58 +309,66 @@ final class MaintenanceStore: ObservableObject { code: "plan_stale", message: "Review and regenerate the uninstall plan before running it." ) - return + return .rejected("Review and regenerate the uninstall plan before running it.") } guard uninstallState == .planReady else { - return + return .rejected("Uninstall plan is not ready.") } - resetRunState() - selectedWorkflow = .uninstall - uninstallState = .running - uninstallResult = nil - backend.run( + let start = startRun( operation: "uninstall", params: OperationParams.uninstallRun( noReboot: options.noReboot, noWait: options.noWait, mountWait: Double(options.mountWait), password: password - ) + ), + profile: profile, + workflow: .uninstall ) + guard case .started = start else { + return start + } + selectedWorkflow = .uninstall + uninstallState = .running + uninstallResult = nil + return start } - func refreshFsckTargets(password: String) { + @discardableResult + func refreshFsckTargets(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { guard let mountWaitValue else { failLocally(workflow: .fsck, message: "Mount wait must be a non-negative integer.") - return + return .rejected("Mount wait must be a non-negative integer.") + } + let start = startRun( + operation: "fsck", + params: OperationParams.fsckList(mountWait: Double(mountWaitValue), password: password), + profile: profile, + workflow: .fsck + ) + guard case .started = start else { + return start } - resetRunState() selectedWorkflow = .fsck fsckState = .loading fsckTargets = [] selectedFsckTargetID = nil fsckPlan = nil fsckResult = nil - backend.run(operation: "fsck", params: OperationParams.fsckList(mountWait: Double(mountWaitValue), password: password)) + return start } - func planFsck(password: String) { + @discardableResult + func planFsck(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { guard let options = currentOptions else { failLocally(workflow: .fsck, message: "Mount wait must be a non-negative integer.") - return + return .rejected("Mount wait must be a non-negative integer.") } guard let target = selectedFsckTarget else { failLocally(workflow: .fsck, message: "Select a mounted HFS volume before planning fsck.") - return + return .rejected("Select a mounted HFS volume before planning fsck.") } - resetRunState() - selectedWorkflow = .fsck - fsckState = .planning - fsckPlan = nil - fsckResult = nil - plannedFsckOptions = options - plannedFsckTargetID = target.id - backend.run( + let start = startRun( operation: "fsck", params: OperationParams.fsckPlan( volume: target.volumeParam, @@ -322,11 +376,28 @@ final class MaintenanceStore: ObservableObject { noWait: options.noWait, mountWait: Double(options.mountWait), password: password - ) + ), + profile: profile, + workflow: .fsck ) + guard case .started = start else { + return start + } + selectedWorkflow = .fsck + fsckState = .planning + fsckPlan = nil + fsckResult = nil + plannedFsckOptions = options + plannedFsckTargetID = target.id + return start } - func runFsck(password: String) { + @discardableResult + func runFsck(password: String, profile: DeviceProfile? = nil) -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .fsck, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } guard let options = plannedFsckOptions, let target = selectedFsckTarget, selectedFsckTargetID == plannedFsckTargetID, @@ -338,16 +409,12 @@ final class MaintenanceStore: ObservableObject { code: "plan_stale", message: "Review and regenerate the fsck plan before running it." ) - return + return .rejected("Review and regenerate the fsck plan before running it.") } guard fsckState == .planReady else { - return + return .rejected("fsck plan is not ready.") } - resetRunState() - selectedWorkflow = .fsck - fsckState = .running - fsckResult = nil - backend.run( + let start = startRun( operation: "fsck", params: OperationParams.fsckRun( volume: target.volumeParam, @@ -355,25 +422,49 @@ final class MaintenanceStore: ObservableObject { noWait: options.noWait, mountWait: Double(options.mountWait), password: password - ) + ), + profile: profile, + workflow: .fsck ) + guard case .started = start else { + return start + } + selectedWorkflow = .fsck + fsckState = .running + fsckResult = nil + return start } - func scanRepairXattrs() { + @discardableResult + func scanRepairXattrs() -> OperationStartResult { guard !trimmedRepairPath.isEmpty else { failLocally(workflow: .repairXattrs, message: "Choose a mounted SMB share path before scanning.") - return + return .rejected("Choose a mounted SMB share path before scanning.") + } + let path = trimmedRepairPath + let start = startRun( + operation: "repair-xattrs", + params: OperationParams.repairXattrsScan(path: path), + profile: nil, + workflow: .repairXattrs + ) + guard case .started = start else { + return start } - resetRunState() selectedWorkflow = .repairXattrs repairState = .scanning repairScan = nil repairResult = nil - scannedRepairPath = trimmedRepairPath - backend.run(operation: "repair-xattrs", params: OperationParams.repairXattrsScan(path: trimmedRepairPath)) + scannedRepairPath = path + return start } - func runRepairXattrs() { + @discardableResult + func runRepairXattrs() -> OperationStartResult { + guard !backend.isRunning else { + rejectRun(workflow: .repairXattrs, message: "Another operation is already running.") + return .rejected("Another operation is already running.") + } guard canRepairXattrs else { repairState = .scanStale error = BackendErrorViewModel( @@ -381,13 +472,21 @@ final class MaintenanceStore: ObservableObject { code: "scan_stale", message: "Run a fresh xattr scan before repairing." ) - return + return .rejected("Run a fresh xattr scan before repairing.") + } + let start = startRun( + operation: "repair-xattrs", + params: OperationParams.repairXattrsRun(path: trimmedRepairPath), + profile: nil, + workflow: .repairXattrs + ) + guard case .started = start else { + return start } - resetRunState() selectedWorkflow = .repairXattrs repairState = .repairing repairResult = nil - backend.run(operation: "repair-xattrs", params: OperationParams.repairXattrsRun(path: trimmedRepairPath)) + return start } func clear() { @@ -413,6 +512,7 @@ final class MaintenanceStore: ObservableObject { plannedFsckOptions = nil plannedFsckTargetID = nil scannedRepairPath = nil + activeOperation = nil } func cancel() { @@ -435,6 +535,7 @@ final class MaintenanceStore: ObservableObject { lastProcessedEventCount = 0 error = nil currentStage = nil + activeOperation = nil } private func process(_ events: [BackendEvent]) { @@ -454,6 +555,9 @@ final class MaintenanceStore: ObservableObject { guard ["activate", "uninstall", "fsck", "repair-xattrs"].contains(event.operation) else { return } + guard activeOperation?.operation == event.operation else { + return + } if let stage = OperationStageState(event: event) { currentStage = stage @@ -502,6 +606,7 @@ final class MaintenanceStore: ObservableObject { do { activationPlan = try event.decodePayload(ActivationPlanPayload.self) activateState = .planReady + activeOperation = nil } catch { failContract(workflow: .activate, error: error) } @@ -511,6 +616,7 @@ final class MaintenanceStore: ObservableObject { activationResult = try event.decodePayload(ActivationResultPayload.self) activateState = .succeeded error = nil + activeOperation = nil } catch { failContract(workflow: .activate, error: error) } @@ -521,6 +627,7 @@ final class MaintenanceStore: ObservableObject { do { uninstallPlan = try event.decodePayload(UninstallPlanPayload.self) uninstallState = .planReady + activeOperation = nil } catch { failContract(workflow: .uninstall, error: error) } @@ -530,6 +637,7 @@ final class MaintenanceStore: ObservableObject { uninstallResult = try event.decodePayload(MaintenanceResultPayload.self) uninstallState = .succeeded error = nil + activeOperation = nil } catch { failContract(workflow: .uninstall, error: error) } @@ -544,6 +652,7 @@ final class MaintenanceStore: ObservableObject { selectedFsckTargetID = fsckTargets.count == 1 ? fsckTargets[0].id : nil fsckState = .listReady error = nil + activeOperation = nil } catch { failContract(workflow: .fsck, error: error) } @@ -552,6 +661,7 @@ final class MaintenanceStore: ObservableObject { fsckPlan = try event.decodePayload(FsckPlanPayload.self) fsckState = .planReady error = nil + activeOperation = nil } catch { failContract(workflow: .fsck, error: error) } @@ -560,6 +670,7 @@ final class MaintenanceStore: ObservableObject { fsckResult = try event.decodePayload(FsckResultPayload.self) fsckState = .succeeded error = nil + activeOperation = nil } catch { failContract(workflow: .fsck, error: error) } @@ -572,9 +683,11 @@ final class MaintenanceStore: ObservableObject { if repairState == .scanning { repairScan = payload repairState = .scanReady + activeOperation = nil } else { repairResult = payload repairState = .repaired + activeOperation = nil } error = nil } catch { @@ -619,6 +732,7 @@ final class MaintenanceStore: ObservableObject { message: error.localizedDescription ) setState(.failed, for: workflow) + activeOperation = nil } private func failLocally(workflow: MaintenanceWorkflow, message: String) { @@ -630,6 +744,19 @@ final class MaintenanceStore: ObservableObject { selectedWorkflow = workflow currentStage = nil setState(.failed, for: workflow) + activeOperation = nil + } + + private func rejectRun(workflow: MaintenanceWorkflow, message: String) { + error = BackendErrorViewModel( + operation: operationName(for: workflow), + code: "operation_rejected", + message: message + ) + selectedWorkflow = workflow + currentStage = nil + setState(.failed, for: workflow) + activeOperation = nil } private func failState(for operation: String) { @@ -645,6 +772,7 @@ final class MaintenanceStore: ObservableObject { default: break } + activeOperation = nil } private func setState(_ state: MaintenanceOperationState, for workflow: MaintenanceWorkflow) { @@ -700,4 +828,40 @@ final class MaintenanceStore: ObservableObject { } return value } + + private func startRun( + operation: String, + params: [String: JSONValue], + profile: DeviceProfile?, + workflow: MaintenanceWorkflow + ) -> OperationStartResult { + guard !backend.isRunning else { + let message = "Another operation is already running." + rejectRun(workflow: workflow, message: message) + return .rejected(message) + } + resetRunState() + let start = run(operation: operation, params: params, profile: profile) + switch start { + case .started(let operation): + activeOperation = operation + case .rejected(let message): + rejectRun(workflow: workflow, message: message) + } + return start + } + + private func run(operation: String, params: [String: JSONValue], profile: DeviceProfile?) -> OperationStartResult { + if let coordinator { + return coordinator.run(operation: operation, params: params, profile: profile) + } else { + guard !backend.isRunning else { + return .rejected("Another operation is already running.") + } + let context = profile?.runtimeContext + let activeOperation = ActiveOperation(operation: operation, profileID: profile?.id, context: context) + backend.run(operation: operation, params: params, context: context) + return .started(activeOperation) + } + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift new file mode 100644 index 00000000..bc632642 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift @@ -0,0 +1,124 @@ +import Combine +import Foundation + +struct ActiveOperation: Equatable, Identifiable { + let id: UUID + let operation: String + let profileID: DeviceProfile.ID? + let context: DeviceRuntimeContext? + + init( + id: UUID = UUID(), + operation: String, + profileID: DeviceProfile.ID?, + context: DeviceRuntimeContext? + ) { + self.id = id + self.operation = operation + self.profileID = profileID + self.context = context + } +} + +enum OperationStartResult: Equatable { + case started(ActiveOperation) + case rejected(String) + + var operation: ActiveOperation? { + guard case .started(let operation) = self else { + return nil + } + return operation + } + + var rejectionMessage: String? { + guard case .rejected(let message) = self else { + return nil + } + return message + } +} + +@MainActor +final class OperationCoordinator: ObservableObject { + @Published private(set) var activeOperation: ActiveOperation? + @Published private(set) var activeDeviceID: DeviceProfile.ID? + @Published private(set) var rejectedOperationMessage: String? + + let backend: BackendClient + + private var cancellables: Set = [] + + convenience init() { + self.init(backend: BackendClient()) + } + + init(backend: BackendClient) { + self.backend = backend + backend.$isRunning + .sink { [weak self] isRunning in + guard !isRunning else { return } + self?.activeOperation = nil + self?.activeDeviceID = nil + } + .store(in: &cancellables) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + profile: DeviceProfile?, + password: String? = nil + ) -> OperationStartResult { + run( + operation: operation, + params: params, + context: profile?.runtimeContext, + activeDeviceID: profile?.id, + password: password + ) + } + + @discardableResult + func run( + operation: String, + params: [String: JSONValue] = [:], + context: DeviceRuntimeContext?, + activeDeviceID: DeviceProfile.ID?, + password: String? = nil + ) -> OperationStartResult { + guard !backend.isRunning else { + let message = "Another operation is already running." + rejectedOperationMessage = message + return .rejected(message) + } + var updatedParams = params + if let password, + !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + updatedParams["credentials"] == nil { + updatedParams["credentials"] = .object(["password": .string(password)]) + } + let activeOperation = ActiveOperation( + operation: operation, + profileID: activeDeviceID, + context: context + ) + rejectedOperationMessage = nil + self.activeOperation = activeOperation + self.activeDeviceID = activeDeviceID + backend.run(operation: operation, params: updatedParams, context: context) + return .started(activeOperation) + } + + func cancel() { + backend.cancel() + } + + func clear() { + backend.clear() + rejectedOperationMessage = nil + activeOperation = nil + activeDeviceID = nil + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift index 1815c62e..a8b39080 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationParams.swift @@ -1,6 +1,14 @@ import Foundation enum OperationParams { + private static func rootSSHTarget(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("@") else { + return trimmed + } + return "root@\(trimmed)" + } + private static func withCredentials(_ params: [String: JSONValue], password: String) -> [String: JSONValue] { let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -22,12 +30,13 @@ enum OperationParams { debugLogging: Bool ) -> [String: JSONValue] { var params: [String: JSONValue] = [ - "password": .string(password) + "password": .string(password), + "persist_password": .bool(false) ] if let selectedRecord { params["selected_record"] = selectedRecord } else { - params["host"] = .string(host) + params["host"] = .string(rootSSHTarget(host)) } if debugLogging { params["debug_logging"] = .bool(true) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift new file mode 100644 index 00000000..5ad0994b --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift @@ -0,0 +1,166 @@ +import Foundation +import Security + +enum PasswordStoreError: Error, Equatable, LocalizedError { + case missing + case unavailable(String) + + var errorDescription: String? { + switch self { + case .missing: + return "Password is missing." + case .unavailable(let message): + return message + } + } +} + +protocol PasswordStore: AnyObject { + func password(for account: String) throws -> String + func save(_ password: String, for account: String) throws + func deletePassword(for account: String) throws + func state(for account: String) -> DevicePasswordState +} + +final class KeychainPasswordStore: PasswordStore { + static let service = "TimeCapsuleSMB.DevicePassword" + + private let service: String + + init(service: String = KeychainPasswordStore.service) { + self.service = service + } + + 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 = SecItemCopyMatching(query as CFDictionary, &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("Keychain returned an unreadable password.") + } + return password + } + + func save(_ password: String, for account: String) throws { + let data = Data(password.utf8) + var query = baseQuery(account: account) + let attributes = [kSecValueData as String: data] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecSuccess { + return + } + if status != errSecItemNotFound { + throw PasswordStoreError.unavailable(message(for: status)) + } + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let addStatus = SecItemAdd(query as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw PasswordStoreError.unavailable(message(for: addStatus)) + } + } + + func deletePassword(for account: String) throws { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { + return + } + throw PasswordStoreError.unavailable(message(for: status)) + } + + func state(for account: String) -> DevicePasswordState { + do { + _ = try password(for: account) + return .available + } catch PasswordStoreError.missing { + return .missing + } catch { + return .keychainUnavailable + } + } + + private func baseQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } + + private func message(for status: OSStatus) -> String { + if let message = SecCopyErrorMessageString(status, nil) as String? { + return message + } + return "Keychain error \(status)." + } +} + +final class InMemoryPasswordStore: PasswordStore { + enum Failure: Error { + case read + case save + case delete + } + + var readFailure: Failure? + var saveFailure: Failure? + var deleteFailure: Failure? + + private var passwords: [String: String] + private var invalidAccounts: Set + + init(passwords: [String: String] = [:], invalidAccounts: Set = []) { + self.passwords = passwords + self.invalidAccounts = invalidAccounts + } + + func password(for account: String) throws -> String { + if readFailure != nil { + throw PasswordStoreError.unavailable("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 state(for account: String) -> DevicePasswordState { + if readFailure != nil { + return .keychainUnavailable + } + if invalidAccounts.contains(account) { + return .invalid + } + return passwords[account] == nil ? .missing : .available + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift index 497530f2..6bc01193 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PendingConfirmation.swift @@ -7,10 +7,12 @@ struct PendingConfirmation: Identifiable { let actionTitle: String let operation: String let params: [String: JSONValue] + let context: DeviceRuntimeContext? init?( confirmationEvent event: BackendEvent, - originalParams: [String: JSONValue] + originalParams: [String: JSONValue], + context: DeviceRuntimeContext? = nil ) { guard event.type == "error", @@ -28,6 +30,7 @@ struct PendingConfirmation: Identifiable { var confirmedParams = originalParams confirmedParams["confirmation_id"] = .string(confirmationId) self.params = confirmedParams + self.context = context } private static func detailString(_ details: [String: JSONValue], _ key: String) -> String? { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift index b3620ecd..1f96ce47 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBExecutable/main.swift @@ -1,8 +1,16 @@ +import AppKit import SwiftUI import TimeCapsuleSMBApp @main struct TimeCapsuleSMBExecutable: App { + init() { + NSApplication.shared.setActivationPolicy(.regular) + DispatchQueue.main.async { + NSApplication.shared.activate(ignoringOtherApps: true) + } + } + var body: some Scene { WindowGroup { ContentView() diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift new file mode 100644 index 00000000..b0c82bc7 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -0,0 +1,390 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class AddDeviceFlowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(AddDeviceFlowState.allCases, [ + .idle, + .discovering, + .discoveryEmpty, + .discoveryReady, + .manualEntry, + .passwordEntry, + .configuring, + .savingProfile, + .saved, + .authFailed, + .unsupported, + .failed + ]) + } + + func testEntryModeInventoryIsExplicit() { + XCTAssertEqual(AddDeviceEntryMode.allCases, [.discover, .manual]) + } + + func testDiscoverEmptyReadyAndFailureStates() async throws { + let empty = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [])) + ]) + ]) + empty.store.runDiscover() + try await waitUntilStoreState { empty.store.state == .discoveryEmpty } + XCTAssertEqual(empty.store.devices, []) + + let ready = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [ + testDeviceRecord(name: "A", hostname: "a.local.", ipv4: ["10.0.0.2"], fullname: "A._airport._tcp.local."), + testDeviceRecord(name: "B", hostname: "b.local.", ipv4: ["10.0.0.3"], fullname: "B._airport._tcp.local.") + ])) + ]) + ]) + ready.store.runDiscover() + try await waitUntilStoreState { ready.store.state == .discoveryReady } + XCTAssertEqual(ready.store.devices.count, 2) + XCTAssertNil(ready.store.selectedDeviceID) + + let failed = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "discover", code: "bonjour_failed", message: "mDNS failed") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + failed.store.runDiscover() + try await waitUntilStoreState { failed.store.state == .failed } + XCTAssertEqual(failed.store.error?.code, "bonjour_failed") + } + + func testDiscoverUsesBackendDeviceContractInsteadOfRawBonjourRecords() async throws { + let records = [ + testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["169.254.44.9", "10.0.0.2"], + fullname: "Office Capsule._airport._tcp.local." + ), + testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._smb._tcp.local.", + serviceType: "_smb._tcp.local.", + services: ["_smb._tcp.local."] + ), + testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._adisk._tcp.local.", + serviceType: "_adisk._tcp.local.", + services: ["_adisk._tcp.local."] + ), + testDeviceRecord( + name: "Lab Capsule", + hostname: "lab.local.", + ipv4: ["10.0.0.3"], + fullname: "Lab Capsule._airport._tcp.local." + ), + testDeviceRecord( + name: "Lab Capsule", + hostname: "lab.local.", + ipv4: ["10.0.0.3"], + fullname: "Lab Capsule._smb._tcp.local.", + serviceType: "_smb._tcp.local.", + services: ["_smb._tcp.local."] + ), + testDeviceRecord( + name: "Printer", + hostname: "printer.local.", + ipv4: ["10.0.0.20"], + syap: "", + model: "", + fullname: "Printer._ipp._tcp.local.", + serviceType: "_ipp._tcp.local.", + services: ["_ipp._tcp.local."] + ) + ] + let devices = [ + testDiscoveredDevice( + id: "bonjour:lab-capsule._airport._tcp.local", + name: "Lab Capsule", + host: "10.0.0.3", + hostname: "lab.local.", + fullname: "Lab Capsule._airport._tcp.local.", + selectedRecord: records[3] + ), + testDiscoveredDevice( + id: "bonjour:office-capsule._airport._tcp.local", + name: "Office Capsule", + host: "10.0.0.2", + hostname: "office.local.", + addresses: ["169.254.44.9", "10.0.0.2"], + ipv4: ["169.254.44.9", "10.0.0.2"], + preferredIPv4: "10.0.0.2", + fullname: "Office Capsule._airport._tcp.local.", + selectedRecord: records[0] + ) + ] + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: records, devices: devices)) + ]) + ]) + + fixture.store.runDiscover() + + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + XCTAssertEqual(fixture.store.devices.map(\.name), ["Lab Capsule", "Office Capsule"]) + XCTAssertEqual(fixture.store.devices.map(\.host), ["10.0.0.3", "10.0.0.2"]) + XCTAssertEqual(fixture.store.devices[1].addresses, ["169.254.44.9", "10.0.0.2"]) + } + + func testModeChoiceSeparatesDiscoverAndManualFlows() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]) + ]) + + XCTAssertEqual(fixture.store.entryMode, .discover) + XCTAssertFalse(fixture.store.isHostFieldEditable) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + XCTAssertEqual(fixture.store.selectedDevice?.host, "10.0.0.2") + XCTAssertEqual(fixture.store.hostFieldText, "10.0.0.2") + XCTAssertFalse(fixture.store.isHostFieldEditable) + + fixture.store.setEntryMode(.manual) + + XCTAssertEqual(fixture.store.entryMode, .manual) + XCTAssertTrue(fixture.store.isHostFieldEditable) + XCTAssertEqual(fixture.store.devices, []) + XCTAssertNil(fixture.store.selectedDeviceID) + } + + func testResetClearsPasswordAndSetupInputs() throws { + let fixture = try makeStore(responses: []) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.reset() + + XCTAssertEqual(fixture.store.state, .idle) + XCTAssertEqual(fixture.store.entryMode, .discover) + XCTAssertEqual(fixture.store.manualHost, "") + XCTAssertEqual(fixture.store.password, "") + XCTAssertEqual(fixture.store.devices, []) + XCTAssertNil(fixture.store.selectedDeviceID) + } + + func testManualHostConfigureSuccessSavesProfileAndPassword() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "root@10.0.0.2")) + ]) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + let profile = try XCTUnwrap(fixture.store.savedProfile) + XCTAssertEqual(fixture.registry.profiles.count, 1) + XCTAssertEqual(profile.host, "root@10.0.0.2") + XCTAssertEqual(profile.passwordState, .available) + XCTAssertEqual(try fixture.passwordStore.password(for: profile.keychainAccount), "secret") + XCTAssertEqual(fixture.runner.calls.count, 1) + XCTAssertEqual(fixture.runner.calls[0].operation, "configure") + XCTAssertEqual(fixture.runner.calls[0].context?.profileID, profile.id) + XCTAssertEqual(fixture.runner.calls[0].params["config"], .string(profile.configPath)) + XCTAssertEqual(fixture.runner.calls[0].params["host"], .string("root@10.0.0.2")) + XCTAssertEqual(fixture.runner.calls[0].params["persist_password"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[0].params["password"], .string("secret")) + XCTAssertNil(fixture.runner.calls[0].params["debug_logging"]) + } + + func testConfigureRejectedWhileAnotherOperationRunsSavesNothing() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + _ = fixture.store.coordinator.run(operation: "doctor", profile: nil) + try await waitUntilStoreState { fixture.runner.calls.count == 1 } + XCTAssertTrue(fixture.store.isRunning) + fixture.store.runConfigure() + + XCTAssertEqual(fixture.store.state, .failed) + XCTAssertEqual(fixture.store.error?.code, "operation_rejected") + XCTAssertEqual(fixture.registry.profiles, []) + XCTAssertEqual(fixture.runner.calls.count, 1) + try await waitUntilStoreState { !fixture.store.isRunning } + } + + func testSelectedBonjourConfigureSuccessSavesProfileMetadata() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + hostname: "office.local.", + ipv4: ["10.0.0.5"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.5")) + ]) + ]) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + let device = try XCTUnwrap(fixture.store.devices.first) + fixture.store.select(device) + fixture.store.password = "secret" + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + let profile = try XCTUnwrap(fixture.store.savedProfile) + XCTAssertEqual(profile.bonjourFullname, "Office Capsule._airport._tcp.local.") + XCTAssertEqual(profile.hostname, "office.local.") + XCTAssertEqual(profile.addresses, ["10.0.0.5"]) + XCTAssertNotNil(fixture.runner.calls[1].params["selected_record"]) + XCTAssertNil(fixture.runner.calls[1].params["host"]) + } + + func testAuthFailureAndUnsupportedDeviceSaveNothing() async throws { + let auth = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "auth_failed", message: "bad password") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + auth.store.startManualEntry() + auth.store.manualHost = "10.0.0.2" + auth.store.password = "bad" + auth.store.runConfigure() + try await waitUntilStoreState { auth.store.state == .authFailed } + XCTAssertEqual(auth.registry.profiles, []) + XCTAssertNil(auth.store.savedProfile) + + let unsupported = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "configure", code: "unsupported_device", message: "unsupported") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + unsupported.store.startManualEntry() + unsupported.store.manualHost = "10.0.0.3" + unsupported.store.password = "pw" + unsupported.store.runConfigure() + try await waitUntilStoreState { unsupported.store.state == .unsupported } + XCTAssertEqual(unsupported.registry.profiles, []) + XCTAssertNil(unsupported.store.savedProfile) + } + + func testDuplicateHostUpdatesExistingProfileAfterConfigureSucceeds() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload( + host: "10.0.0.2", + model: "Updated Capsule" + )) + ]) + ]) + let existing = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Original Capsule"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "existing-device" + ) + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "new-secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + XCTAssertEqual(fixture.registry.profiles.count, 1) + XCTAssertEqual(fixture.store.savedProfile?.id, existing.id) + XCTAssertEqual(fixture.store.savedProfile?.model, "Updated Capsule") + XCTAssertEqual(fixture.runner.calls[0].context?.profileID, existing.id) + } + + func testKeychainSaveFailureLeavesProfilePasswordMissing() async throws { + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.2")) + ]) + ]) + fixture.passwordStore.saveFailure = .save + fixture.store.startManualEntry() + fixture.store.manualHost = "10.0.0.2" + fixture.store.password = "secret" + + fixture.store.runConfigure() + + try await waitUntilStoreState { fixture.store.state == .saved } + let profile = try XCTUnwrap(fixture.store.savedProfile) + XCTAssertEqual(profile.passwordState, .missing) + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + XCTAssertEqual(fixture.passwordStore.state(for: profile.keychainAccount), .missing) + } + + func testSelectingAlreadySavedDiscoveryRoutesToExistingProfile() async throws { + let record = testDeviceRecord( + name: "Office Capsule", + ipv4: ["10.0.0.2"], + fullname: "Office Capsule._airport._tcp.local." + ) + let fixture = try makeStore(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "discover", ok: true, payload: testDiscoverPayload(records: [record])) + ]) + ]) + let existing = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: try DiscoveredDevice(record: record.decode(BonjourResolvedServicePayload.self), index: 0), + passwordState: .available, + preferredID: "existing-device" + ) + + fixture.store.runDiscover() + try await waitUntilStoreState { fixture.store.state == .discoveryReady } + fixture.store.select(try XCTUnwrap(fixture.store.devices.first)) + + XCTAssertEqual(fixture.store.state, .saved) + XCTAssertEqual(fixture.store.savedProfile?.id, existing.id) + XCTAssertEqual(fixture.runner.calls.count, 1) + } + + private func makeStore(responses: [StoreTestRunner.Response]) throws -> ( + store: AddDeviceFlowStore, + runner: StoreTestRunner, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let runner = StoreTestRunner(responses: responses) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let passwordStore = InMemoryPasswordStore() + let store = AddDeviceFlowStore(coordinator: coordinator, registry: registry, passwordStore: passwordStore) + return (store, runner, registry, passwordStore) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift index 789e93a4..0de75197 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -27,7 +27,8 @@ final class BackendClientTests: XCTestCase { [RecordingHelperRunner.Call( helperPath: "/tmp/tcapsule", operation: "paths", - params: ["dry_run": .bool(true)] + params: ["dry_run": .bool(true)], + context: nil )] ) } @@ -110,6 +111,97 @@ final class BackendClientTests: XCTestCase { XCTAssertEqual(client.pendingConfirmation?.params["dry_run"], .bool(false)) } + func testProfileContextInjectsConfigAndPreservesExplicitConfig() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: "") + ) + let client = BackendClient(runner: runner) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + client.run(operation: "doctor", params: [:], context: context) + + try await waitUntil { !client.isRunning && runner.calls.count == 1 } + XCTAssertEqual(runner.calls[0].context, context) + XCTAssertEqual(runner.calls[0].params["config"], .string("/tmp/device-one/.env")) + + client.run( + operation: "doctor", + params: ["config": .string("/tmp/manual.env")], + context: context + ) + + try await waitUntil { !client.isRunning && runner.calls.count == 2 } + XCTAssertEqual(runner.calls[1].params["config"], .string("/tmp/manual.env")) + } + + func testConfirmationReplayPreservesDeviceContext() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deploy.", + details: .object([ + "confirmation_id": .string("confirm-1") + ]) + ) + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload()) + ]) + ]) + let client = BackendClient(runner: runner) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + client.run(operation: "deploy", params: ["dry_run": .bool(false)], context: context) + try await waitUntil { client.pendingConfirmation != nil && !client.isRunning } + XCTAssertEqual(client.pendingConfirmation?.context, context) + + client.confirmPending() + + try await waitUntil { !client.isRunning && runner.calls.count == 2 } + XCTAssertEqual(runner.calls[0].context, context) + XCTAssertEqual(runner.calls[1].context, context) + XCTAssertEqual(runner.calls[1].params["confirmation_id"], .string("confirm-1")) + XCTAssertEqual(runner.calls[1].params["config"], .string("/tmp/device-one/.env")) + } + + func testOperationCoordinatorRejectsSecondOperationWhileActive() async throws { + let runner = RecordingHelperRunner( + events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], + result: HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: 200_000_000 + ) + let client = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: client) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + guard case .started(let activeOperation) = coordinator.run(operation: "doctor", context: context, activeDeviceID: "device-one") else { + XCTFail("Expected first operation to start.") + return + } + guard case .rejected(let rejectionMessage) = coordinator.run(operation: "deploy", context: context, activeDeviceID: "device-one") else { + XCTFail("Expected second operation to be rejected.") + return + } + XCTAssertEqual(activeOperation.operation, "doctor") + XCTAssertEqual(activeOperation.profileID, "device-one") + XCTAssertEqual(rejectionMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.rejectedOperationMessage, "Another operation is already running.") + XCTAssertEqual(coordinator.activeOperation, activeOperation) + XCTAssertEqual(coordinator.activeDeviceID, "device-one") + + try await waitUntil { !client.isRunning } + XCTAssertNil(coordinator.activeOperation) + XCTAssertNil(coordinator.activeDeviceID) + } + private func waitUntil( timeoutNanoseconds: UInt64 = 2_000_000_000, _ condition: @escaping @MainActor () -> Bool @@ -130,6 +222,7 @@ private final class RecordingHelperRunner: HelperRunning, @unchecked Sendable { let helperPath: String? let operation: String let params: [String: JSONValue] + let context: DeviceRuntimeContext? } private let queue = DispatchQueue(label: "TimeCapsuleSMBAppTests.RecordingHelperRunner") @@ -152,10 +245,11 @@ private final class RecordingHelperRunner: HelperRunning, @unchecked Sendable { helperPath: String?, operation: String, params: [String: JSONValue], + context: DeviceRuntimeContext?, onEvent: @escaping @Sendable (BackendEvent) async -> Void ) async -> HelperRunResult { queue.sync { - storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params)) + storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params, context: context)) } if delayNanoseconds > 0 { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift index 83360e97..59f52ea6 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendPayloadTests.swift @@ -74,12 +74,41 @@ final class BackendPayloadTests: XCTestCase { "properties": {"syAP": "119", "model": "Time Capsule"}, "fullname": "TC._airport._tcp.local." }], - "counts": {"instances": 1, "resolved": 1}, - "summary": "discovered 1 resolved AirPort service(s)." + "devices": [{ + "id": "bonjour:tc._airport._tcp.local", + "name": "TC", + "host": "10.0.0.2", + "ssh_host": "root@10.0.0.2", + "hostname": "tc.local.", + "addresses": ["10.0.0.2"], + "ipv4": ["10.0.0.2"], + "ipv6": [], + "preferred_ipv4": "10.0.0.2", + "link_local_only": false, + "syap": "119", + "model": "Time Capsule", + "service_type": "_airport._tcp.local.", + "fullname": "TC._airport._tcp.local.", + "selected_record": { + "name": "TC", + "hostname": "tc.local.", + "service_type": "_airport._tcp.local.", + "port": 5009, + "ipv4": ["10.0.0.2"], + "ipv6": [], + "services": ["_airport._tcp.local."], + "properties": {"syAP": "119", "model": "Time Capsule"}, + "fullname": "TC._airport._tcp.local." + } + }], + "counts": {"instances": 1, "resolved": 1, "devices": 1}, + "summary": "discovered 1 Time Capsule device(s)." } """).decode(DiscoverPayload.self) XCTAssertEqual(discovery.resolved[0].name, "TC") + XCTAssertEqual(discovery.devices[0].host, "10.0.0.2") + XCTAssertEqual(discovery.devices[0].selectedRecord.stringValue(for: "fullname"), "TC._airport._tcp.local.") XCTAssertEqual(discovery.resolved[0].properties["syAP"], "119") XCTAssertEqual(discovery.resolved[0].jsonValue.stringValue(for: "name"), "TC") diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift index e750fe8a..a7806405 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConnectionWorkflowStoreTests.swift @@ -374,6 +374,7 @@ private final class WorkflowRecordingRunner: HelperRunning, @unchecked Sendable let helperPath: String? let operation: String let params: [String: JSONValue] + let context: DeviceRuntimeContext? } struct Response: Sendable { @@ -405,10 +406,11 @@ private final class WorkflowRecordingRunner: HelperRunning, @unchecked Sendable helperPath: String?, operation: String, params: [String: JSONValue], + context: DeviceRuntimeContext?, onEvent: @escaping @Sendable (BackendEvent) async -> Void ) async -> HelperRunResult { let response = queue.sync { - storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params)) + 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.")], diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift new file mode 100644 index 00000000..61f3063c --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -0,0 +1,234 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DashboardStoreTests: XCTestCase { + func testNoDeviceRegistryLeavesNoSelectedProfile() throws { + let fixture = try makeFixture(responses: []) + + XCTAssertEqual(fixture.registry.state, .empty) + XCTAssertNil(fixture.appStore.selectedProfile) + } + + func testPrimaryActionDerivesFromPasswordCheckupAndDeployState() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-one" + ) + + XCTAssertEqual(fixture.appStore.dashboardSummary(for: profile).primaryAction, .replacePassword) + + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: profile).primaryAction, .runCheckup) + + fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 100), + state: .passed, + passCount: 2, + warnCount: 0, + failCount: 0, + summary: "healthy" + ), for: profile.id) + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: checked).primaryAction, .installSMB) + + fixture.registry.updateDeploy(DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 110), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ), for: profile.id) + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: installed).primaryAction, .openSMB) + + fixture.registry.updateCheckup(DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 120), + state: .warning, + passCount: 1, + warnCount: 1, + failCount: 0, + summary: "warning" + ), for: profile.id) + let warning = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: warning).primaryAction, .viewCheckup) + } + + func testDashboardOperationsUpdateLastCheckupAndDeploySnapshots() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime"), + testDoctorCheck(status: "WARN", message: "bonjour missing", domain: "Bonjour") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) + ]) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + fixture.appStore.select(profile) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: profile) + + try await waitUntilStoreState { dashboard.doctorStore.state == .warning } + let checked = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(checked.lastCheckup?.state, .warning) + XCTAssertEqual(checked.lastCheckup?.warnCount, 1) + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(fixture.runner.calls[0].context?.profileID, profile.id) + + dashboard.runInstallPlan(profile: checked) + try await waitUntilStoreState { dashboard.deployStore.state == .planReady } + dashboard.runInstall(profile: checked) + + try await waitUntilStoreState { dashboard.deployStore.state == .deployed } + let installed = try XCTUnwrap(fixture.registry.profile(id: profile.id)) + XCTAssertEqual(installed.lastDeploy?.state, .deployed) + XCTAssertEqual(installed.lastDeploy?.payloadFamily, "netbsd6_samba4") + XCTAssertEqual(installed.lastDeploy?.verified, true) + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[2].params["dry_run"], .bool(false)) + XCTAssertEqual(fixture.runner.calls[2].context?.profileID, profile.id) + } + + func testCheckupSnapshotUsesStartedProfileWhenSelectionChanges() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ], delayNanoseconds: 100_000_000) + ]) + let first = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + try fixture.passwordStore.save("pw", for: first.keychainAccount) + fixture.appStore.select(first) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: first) + fixture.appStore.select(second) + + try await waitUntilStoreState { dashboard.doctorStore.state == .passed } + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastCheckup?.state, .passed) + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastCheckup) + } + + func testDeploySnapshotUsesStartedProfileWhenSelectionChanges() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload(payloadFamily: "netbsd6_samba4")) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployResultPayload(payloadFamily: "netbsd6_samba4")) + ], delayNanoseconds: 100_000_000) + ]) + let first = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + try fixture.passwordStore.save("pw", for: first.keychainAccount) + fixture.appStore.select(first) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runInstallPlan(profile: first) + try await waitUntilStoreState { dashboard.deployStore.state == .planReady } + dashboard.runInstall(profile: first) + fixture.appStore.select(second) + + try await waitUntilStoreState { dashboard.deployStore.state == .deployed } + XCTAssertEqual(fixture.registry.profile(id: first.id)?.lastDeploy?.state, .deployed) + XCTAssertNil(fixture.registry.profile(id: second.id)?.lastDeploy) + } + + func testPasswordLookupFailureMarksProfileMissing() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .unknown, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: profile) + + XCTAssertEqual(dashboard.passwordError, "Password is required.") + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) + } + + func testForgetProfileDeletesRegistryConfigDirectoryAndPassword() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() + XCTAssertTrue(FileManager.default.fileExists(atPath: configDirectory.path)) + fixture.appStore.select(profile) + + try fixture.appStore.forget(profile) + + XCTAssertEqual(fixture.registry.profiles, []) + XCTAssertNil(fixture.appStore.selectedProfile) + XCTAssertNil(fixture.appStore.selectedDeviceID) + XCTAssertFalse(FileManager.default.fileExists(atPath: configDirectory.path)) + XCTAssertEqual(fixture.passwordStore.state(for: profile.keychainAccount), .missing) + } + + private func makeFixture(responses: [StoreTestRunner.Response]) throws -> ( + appStore: AppStore, + registry: DeviceRegistryStore, + passwordStore: InMemoryPasswordStore, + runner: StoreTestRunner + ) { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let runner = StoreTestRunner(responses: responses) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let passwordStore = InMemoryPasswordStore() + let appStore = AppStore( + appReadinessStore: AppReadinessStore(backend: coordinator.backend), + deviceRegistry: registry, + operationCoordinator: coordinator, + passwordStore: passwordStore + ) + return (appStore, registry, passwordStore, runner) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift index b59be7dc..fec09354 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeployWorkflowStoreTests.swift @@ -60,6 +60,26 @@ final class DeployWorkflowStoreTests: XCTestCase { XCTAssertEqual(runner.calls[0].params["credentials"], .object(["password": .string("pw")])) } + func testRejectedPlanDoesNotEnterPlanning() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DeployWorkflowStore(coordinator: coordinator) + + _ = coordinator.run(operation: "doctor", profile: nil) + try await waitUntilStoreState { runner.calls.count == 1 } + let result = store.runPlan(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.state, .planFailed) + XCTAssertEqual(store.error?.code, "operation_rejected") + XCTAssertEqual(runner.calls.count, 1) + try await waitUntilStoreState { !store.isRunning } + } + func testMalformedPlanPayloadMovesToPlanFailed() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift new file mode 100644 index 00000000..2f278ecb --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceProfileTests: XCTestCase { + func testStableConfigPathFromProfileID() { + let appSupport = URL(fileURLWithPath: "/tmp/TimeCapsuleSMBTests", isDirectory: true) + + let configURL = DeviceProfile.configURL(for: "profile-1", applicationSupportURL: appSupport) + + XCTAssertEqual(configURL.path, "/tmp/TimeCapsuleSMBTests/Devices/profile-1/.env") + } + + func testDisplayNameFallbackOrder() { + var profile = makeProfile(displayName: " ", host: "10.0.0.2", bonjourName: "Office Capsule", model: "Model") + XCTAssertEqual(profile.title, "Office Capsule") + + profile.bonjourName = " " + XCTAssertEqual(profile.title, "Model") + + profile.model = nil + XCTAssertEqual(profile.title, "10.0.0.2") + + profile.host = " " + XCTAssertEqual(profile.title, "Time Capsule") + } + + func testDuplicateMatchingUsesBonjourFullnameAndNormalizedHostOnly() { + let first = makeProfile( + id: "one", + host: " TCAPSULE.LOCAL. ", + bonjourFullname: "Office Capsule._airport._tcp.local.", + syap: "119", + model: "Time Capsule" + ) + let sameFullname = makeProfile( + id: "two", + host: "10.0.0.9", + bonjourFullname: " office capsule._AIRPORT._tcp.local. " + ) + let sameHost = makeProfile(id: "three", host: "tcapsule.local.") + let sameHostWithRootUser = makeProfile(id: "five", host: "root@tcapsule.local") + let weakMetadataOnly = makeProfile(id: "four", host: "10.0.0.10", syap: "119", model: "Time Capsule") + + XCTAssertTrue(DeviceProfile.matches(first, sameFullname)) + XCTAssertTrue(DeviceProfile.matches(first, sameHost)) + XCTAssertTrue(DeviceProfile.matches(first, sameHostWithRootUser)) + XCTAssertFalse(DeviceProfile.matches(first, weakMetadataOnly)) + } + + func testRuntimeContextUsesProfileConfigPath() { + let profile = makeProfile(id: "abc", host: "10.0.0.2", configPath: "/tmp/devices/abc/.env") + + XCTAssertEqual(profile.runtimeContext.profileID, "abc") + XCTAssertEqual(profile.runtimeContext.configURL.path, "/tmp/devices/abc/.env") + } + + private func makeProfile( + id: String = "profile", + displayName: String = "Office Capsule", + host: String = "10.0.0.2", + bonjourName: String? = nil, + bonjourFullname: String? = nil, + syap: String? = nil, + model: String? = nil, + configPath: String = "/tmp/profile/.env" + ) -> DeviceProfile { + DeviceProfile( + id: id, + displayName: displayName, + host: host, + bonjourName: bonjourName, + bonjourFullname: bonjourFullname, + hostname: nil, + addresses: [], + syap: syap, + model: model, + osName: nil, + osRelease: nil, + arch: nil, + elfEndianness: nil, + payloadFamily: nil, + deviceGeneration: nil, + configPath: configPath, + keychainAccount: id, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 20), + lastCheckup: nil, + lastDeploy: nil, + settings: .default, + passwordState: .unknown + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift new file mode 100644 index 00000000..cd48a847 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class DeviceRegistryStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(DeviceRegistryState.allCases, [.idle, .loading, .empty, .loaded, .saving, .failed]) + } + + func testMissingRegistryStartsEmpty() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + + store.load() + + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.devicesDirectoryURL.path)) + } + + func testCorruptRegistryEntersFailedStateWithoutDeletingFile() throws { + let temp = try TemporaryDirectory() + let registryURL = temp.url.appendingPathComponent("devices.json") + try "{ not json".write(to: registryURL, atomically: true, encoding: .utf8) + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + + store.load() + + XCTAssertEqual(store.state, .failed) + XCTAssertNotNil(store.error) + XCTAssertTrue(FileManager.default.fileExists(atPath: registryURL.path)) + XCTAssertEqual(try String(contentsOf: registryURL), "{ not json") + } + + func testCreateUpdateAndDeleteProfile() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + + var profile = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + XCTAssertEqual(store.state, .loaded) + XCTAssertEqual(store.profiles.count, 1) + XCTAssertEqual(profile.configPath, temp.url.appendingPathComponent("Devices/device-one/.env").path) + XCTAssertTrue(FileManager.default.fileExists(atPath: URL(fileURLWithPath: profile.configPath).deletingLastPathComponent().path)) + + profile.displayName = "Renamed Capsule" + profile.settings.debugLogging = true + let updated = try store.save(profile) + XCTAssertEqual(updated.displayName, "Renamed Capsule") + XCTAssertEqual(store.profiles.first?.settings.debugLogging, true) + + try store.delete(updated) + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + XCTAssertFalse(FileManager.default.fileExists(atPath: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent().path)) + } + + func testDuplicateSaveUpdatesByHostAndBonjourFullnameButNotWeakMetadata() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + + let first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "tcapsule.local.", model: "Time Capsule"), + discoveredDevice: try discovered(record: testDeviceRecord(fullname: "Office._airport._tcp.local.")), + passwordState: .available, + preferredID: "device-one" + ) + let hostDuplicate = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: " TCAPSULE.LOCAL. ", model: "Updated Model"), + discoveredDevice: nil, + passwordState: .missing, + preferredID: "device-two" + ) + XCTAssertEqual(hostDuplicate.id, first.id) + XCTAssertEqual(store.profiles.count, 1) + XCTAssertEqual(store.profiles.first?.model, "Updated Model") + + let fullnameDuplicate = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.9"), + discoveredDevice: try discovered(record: testDeviceRecord( + hostname: "other.local.", + ipv4: ["10.0.0.9"], + fullname: " office._AIRPORT._tcp.local. " + )), + passwordState: .available, + preferredID: "device-three" + ) + XCTAssertEqual(fullnameDuplicate.id, first.id) + XCTAssertEqual(store.profiles.count, 1) + + _ = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.10", syap: "119", model: "Updated Model"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-four" + ) + XCTAssertEqual(store.profiles.count, 2) + } + + 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/DoctorStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift index 2aa39521..863c375f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DoctorStoreTests.swift @@ -60,6 +60,26 @@ final class DoctorStoreTests: XCTestCase { XCTAssertEqual(runner.calls.first?.params["credentials"], .object(["password": .string("pw")])) } + func testRejectedRunDoesNotEnterRunning() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = DoctorStore(coordinator: coordinator) + + _ = coordinator.run(operation: "deploy", profile: nil) + try await waitUntilStoreState { runner.calls.count == 1 } + let result = store.runDoctor(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.state, .runFailed) + XCTAssertEqual(store.error?.code, "operation_rejected") + XCTAssertEqual(runner.calls.count, 1) + try await waitUntilStoreState { !store.isRunning } + } + func testWarningResultMovesToWarning() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift index d6e7a781..c34ed9be 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperLocatorTests.swift @@ -27,6 +27,26 @@ final class HelperLocatorTests: XCTestCase { XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1") } + func testLocatorUsesDeviceContextConfigWithoutChangingAppStateDirectory() throws { + let temp = try TemporaryDirectory() + let helper = temp.url.appendingPathComponent("tcapsule") + try "#!/bin/sh\nexit 0\n".write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + let context = DeviceRuntimeContext( + profileID: "device-one", + configURL: temp.url.appendingPathComponent("Devices/device-one/.env") + ) + let locator = HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default) + + let resolution = try locator.resolve(helperPath: helper.path) + let environment = locator.helperEnvironment(for: resolution, context: context) + + XCTAssertEqual(environment["TCAPSULE_CONFIG"], context.configURL.path) + XCTAssertNotNil(environment["TCAPSULE_STATE_DIR"]) + XCTAssertNotEqual(environment["TCAPSULE_STATE_DIR"], context.configURL.deletingLastPathComponent().path) + XCTAssertTrue(FileManager.default.fileExists(atPath: context.configURL.deletingLastPathComponent().path)) + } + func testLocatorDiscoversRepoHelperFromSourceRoot() throws { let temp = try TemporaryDirectory() let repo = temp.url.appendingPathComponent("Repo", isDirectory: true) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift new file mode 100644 index 00000000..5ff38d8a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HostCompatibilityPolicyTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class HostCompatibilityPolicyTests: XCTestCase { + func testWarnsForKnownProblemVersions() { + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 5))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 6))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 7))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 4, patchVersion: 0))) + XCTAssertNotNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 4, patchVersion: 12))) + } + + func testDoesNotWarnForAdjacentVersions() { + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 4))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 7, patchVersion: 8))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 15, minorVersion: 6, patchVersion: 7))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 3, patchVersion: 9))) + XCTAssertNil(HostCompatibilityPolicy.warning(for: OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 0))) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift index 7817e22c..88a429d1 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/MaintenanceStoreTests.swift @@ -51,6 +51,26 @@ final class MaintenanceStoreTests: XCTestCase { XCTAssertEqual(runner.calls[1].params["credentials"], .object(["password": .string("pw2")])) } + func testRejectedActivationPlanDoesNotEnterPlanning() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: .object(["ok": .bool(true)])) + ], delayNanoseconds: 100_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let store = MaintenanceStore(coordinator: coordinator) + + _ = coordinator.run(operation: "doctor", profile: nil) + try await waitUntilStoreState { runner.calls.count == 1 } + let result = store.planActivation(password: "pw") + + XCTAssertEqual(result.rejectionMessage, "Another operation is already running.") + XCTAssertEqual(store.activateState, .failed) + XCTAssertEqual(store.error?.code, "operation_rejected") + XCTAssertEqual(runner.calls.count, 1) + try await waitUntilStoreState { !store.isRunning } + } + func testActivationRequiresPlanAndHandlesConfirmationReplay() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift new file mode 100644 index 00000000..6b649bb3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift @@ -0,0 +1,55 @@ +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)") + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift index fe97352e..d20ab20f 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PendingConfirmationTests.swift @@ -63,6 +63,18 @@ final class PendingConfirmationTests: XCTestCase { XCTAssertEqual(params["debug_logging"], .bool(true)) } + func testConfigureParamsDefaultBareManualHostToRootUser() { + let params = OperationParams.configure( + host: " 10.0.0.2 ", + password: "pw", + debugLogging: false + ) + + XCTAssertEqual(params["host"], .string("root@10.0.0.2")) + XCTAssertEqual(params["password"], .string("pw")) + XCTAssertEqual(params["persist_password"], .bool(false)) + } + func testPendingConfirmationBuildsFromBackendEvent() throws { let event = BackendEvent( type: "error", diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift index e6358023..45cdd6df 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -7,18 +7,22 @@ final class StoreTestRunner: HelperRunning, @unchecked Sendable { let helperPath: String? let operation: String let params: [String: JSONValue] + let context: DeviceRuntimeContext? } struct Response: Sendable { let events: [BackendEvent] let result: HelperRunResult + let delayNanoseconds: UInt64 init( events: [BackendEvent], - result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: "") + result: HelperRunResult = HelperRunResult(exitCode: 0, sawTerminalEvent: true, stderr: ""), + delayNanoseconds: UInt64 = 0 ) { self.events = events self.result = result + self.delayNanoseconds = delayNanoseconds } } @@ -38,10 +42,11 @@ final class StoreTestRunner: HelperRunning, @unchecked Sendable { helperPath: String?, operation: String, params: [String: JSONValue], + context: DeviceRuntimeContext?, onEvent: @escaping @Sendable (BackendEvent) async -> Void ) async -> HelperRunResult { let response = queue.sync { - storedCalls.append(Call(helperPath: helperPath, operation: operation, params: params)) + 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.")], @@ -51,6 +56,13 @@ final class StoreTestRunner: HelperRunning, @unchecked Sendable { return storedResponses.removeFirst() } + if response.delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: response.delayNanoseconds) + } + if Task.isCancelled { + await onEvent(BackendEvent.error(operation: operation, code: "cancelled", message: L10n.string("helper.error.cancelled"))) + return HelperRunResult(exitCode: 130, sawTerminalEvent: true, stderr: "") + } for event in response.events { await onEvent(event) } @@ -74,7 +86,7 @@ func waitUntilStoreState( } func recoveryValue(title: String, actions: [String], suggestedOperation: String = "doctor") -> JSONValue { - .object([ + return .object([ "title": .string(title), "message": .string(title), "actions": .array(actions.map(JSONValue.string)), @@ -82,3 +94,231 @@ func recoveryValue(title: String, actions: [String], suggestedOperation: String "suggested_operation": .string(suggestedOperation) ]) } + +func testDeviceRecord( + name: String = "Office Capsule", + hostname: String = "office-capsule.local.", + ipv4: [String] = ["10.0.0.2"], + syap: String = "119", + model: String = "Time Capsule", + fullname: String = "Office Capsule._airport._tcp.local.", + serviceType: String = "_airport._tcp.local.", + services: [String] = ["_airport._tcp.local."] +) -> JSONValue { + .object([ + "name": .string(name), + "hostname": .string(hostname), + "service_type": .string(serviceType), + "port": .number(5009), + "ipv4": .array(ipv4.map(JSONValue.string)), + "ipv6": .array([]), + "services": .array(services.map(JSONValue.string)), + "properties": .object([ + "syAP": .string(syap), + "model": .string(model) + ]), + "fullname": .string(fullname) + ]) +} + +func testDiscoveredDevice( + id: String = "bonjour:office-capsule._airport._tcp.local", + name: String = "Office Capsule", + host: String = "10.0.0.2", + hostname: String = "office-capsule.local.", + addresses: [String]? = nil, + ipv4: [String]? = nil, + ipv6: [String] = [], + preferredIPv4: String? = "10.0.0.2", + linkLocalOnly: Bool = false, + syap: String? = "119", + model: String? = "Time Capsule", + fullname: String = "Office Capsule._airport._tcp.local.", + selectedRecord: JSONValue? = nil +) -> JSONValue { + let resolvedIPv4 = ipv4 ?? [host] + let resolvedAddresses = addresses ?? (resolvedIPv4 + ipv6) + let record = selectedRecord ?? testDeviceRecord( + name: name, + hostname: hostname, + ipv4: resolvedIPv4, + syap: syap ?? "", + model: model ?? "", + fullname: fullname + ) + return .object([ + "id": .string(id), + "name": .string(name), + "host": .string(host), + "ssh_host": preferredIPv4 == nil ? .null : .string("root@\(host)"), + "hostname": .string(hostname), + "addresses": .array(resolvedAddresses.map(JSONValue.string)), + "ipv4": .array(resolvedIPv4.map(JSONValue.string)), + "ipv6": .array(ipv6.map(JSONValue.string)), + "preferred_ipv4": preferredIPv4.map(JSONValue.string) ?? .null, + "link_local_only": .bool(linkLocalOnly), + "syap": syap.map(JSONValue.string) ?? .null, + "model": model.map(JSONValue.string) ?? .null, + "service_type": .string("_airport._tcp.local."), + "fullname": .string(fullname), + "selected_record": record + ]) +} + +func testDiscoverPayload(records: [JSONValue], devices: [JSONValue]? = nil) -> JSONValue { + let deviceValues: [JSONValue] + if let devices { + deviceValues = devices + } else { + deviceValues = records.map { record -> JSONValue in + let name = record.stringValue(for: "name") ?? "Office Capsule" + let hostname = record.stringValue(for: "hostname") ?? "office-capsule.local." + let fullname = record.stringValue(for: "fullname") ?? "\(name)._airport._tcp.local." + let host: String + if case .object(let object) = record, + case .array(let ipv4Values)? = object["ipv4"], + let first = ipv4Values.compactMap({ value -> String? in + guard case .string(let address) = value else { return nil } + return address.hasPrefix("169.254.") ? nil : address + }).first { + host = first + } else { + host = hostname + } + return testDiscoveredDevice( + id: "bonjour:\(fullname.lowercased())", + name: name, + host: host, + hostname: hostname, + fullname: fullname, + selectedRecord: record + ) + } + } + return .object([ + "schema_version": .number(1), + "instances": .array([]), + "resolved": .array(records), + "devices": .array(deviceValues), + "counts": .object([ + "instances": .number(0), + "resolved": .number(Double(records.count)), + "devices": .number(Double(deviceValues.count)) + ]), + "summary": .string("discovered \(deviceValues.count) Time Capsule device(s).") + ]) +} + +func testConfigurePayload( + host: String = "10.0.0.2", + configPath: String = "/tmp/profile/.env", + syap: String = "119", + model: String = "Time Capsule", + payloadFamily: String = "netbsd6_samba4" +) -> JSONValue { + .object([ + "schema_version": .number(1), + "config_path": .string(configPath), + "host": .string(host), + "configure_id": .string("cfg-1"), + "ssh_authenticated": .bool(true), + "device_syap": .string(syap), + "device_model": .string(model), + "compatibility": .object([ + "os_name": .string("NetBSD"), + "os_release": .string("6.0"), + "arch": .string("powerpc"), + "elf_endianness": .string("big"), + "payload_family": .string(payloadFamily), + "device_generation": .string("tc_gen4"), + "supported": .bool(true), + "syap_candidates": .array([.string(syap)]), + "model_candidates": .array([.string(model)]) + ]), + "device": .object([ + "host": .string(host), + "syap": .string(syap), + "model": .string(model) + ]), + "summary": .string("configuration saved and SSH authentication verified.") + ]) +} + +func testConfiguredDevice( + host: String = "10.0.0.2", + configPath: String = "/tmp/profile/.env", + syap: String = "119", + model: String = "Time Capsule", + payloadFamily: String = "netbsd6_samba4" +) throws -> ConfiguredDeviceState { + ConfiguredDeviceState(payload: try testConfigurePayload( + host: host, + configPath: configPath, + syap: syap, + model: model, + payloadFamily: payloadFamily + ).decode(ConfigurePayload.self)) +} + +func testDoctorPayload(fatal: Bool = false, checks: [JSONValue]) -> JSONValue { + let pass = checks.filter { $0.stringValue(for: "status") == "PASS" }.count + let warn = checks.filter { $0.stringValue(for: "status") == "WARN" }.count + let fail = checks.filter { $0.stringValue(for: "status") == "FAIL" }.count + let info = checks.filter { $0.stringValue(for: "status") == "INFO" }.count + return .object([ + "schema_version": .number(1), + "fatal": .bool(fatal), + "results": .array(checks), + "counts": .object([ + "PASS": .number(Double(pass)), + "WARN": .number(Double(warn)), + "FAIL": .number(Double(fail)), + "INFO": .number(Double(info)) + ]), + "error": fatal ? .string("doctor failed") : .null, + "summary": .string(fatal ? "doctor found one or more fatal problems." : "doctor checks passed.") + ]) +} + +func testDoctorCheck(status: String, message: String, domain: String) -> JSONValue { + .object([ + "status": .string(status), + "message": .string(message), + "details": .object(["domain": .string(domain)]) + ]) +} + +func testDeployPlanPayload(payloadFamily: String = "netbsd6_samba4") -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_root": .string("/Volumes/dk2"), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "payload_family": .string(payloadFamily), + "netbsd4": .bool(false), + "requires_reboot": .bool(true), + "reboot_required": .bool(true), + "uploads": .array([.object(["description": .string("smbd")])]), + "pre_upload_actions": .array([]), + "post_upload_actions": .array([]), + "activation_actions": .array([]), + "post_deploy_checks": .array([]), + "summary": .string("deployment dry-run plan generated.") + ]) +} + +func testDeployResultPayload(payloadFamily: String = "netbsd6_samba4", verified: Bool = true) -> JSONValue { + .object([ + "schema_version": .number(1), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "netbsd4": .bool(false), + "payload_family": .string(payloadFamily), + "requires_reboot": .bool(true), + "rebooted": .bool(true), + "reboot_requested": .bool(true), + "waited": .bool(true), + "verified": .bool(verified), + "message": .string("Install completed."), + "summary": .string("deployment completed.") + ]) +} diff --git a/src/timecapsulesmb/app/contracts.py b/src/timecapsulesmb/app/contracts.py index b1e322dd..a2a9781f 100644 --- a/src/timecapsulesmb/app/contracts.py +++ b/src/timecapsulesmb/app/contracts.py @@ -48,13 +48,15 @@ def _device_payload(*, host: str | None = None, syap: str | None = None, 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(resolved)} resolved AirPort service(s).", + "summary": f"discovered {len(devices)} Time Capsule device(s).", }) diff --git a/src/timecapsulesmb/app/ops/configure.py b/src/timecapsulesmb/app/ops/configure.py index 1d88b0dd..d7e999ca 100644 --- a/src/timecapsulesmb/app/ops/configure.py +++ b/src/timecapsulesmb/app/ops/configure.py @@ -31,6 +31,13 @@ from timecapsulesmb.transport.ssh import SshConnection +def configure_ssh_target(value: str) -> str: + host = value.strip() + if not host or "@" in host: + return host + return f"root@{host}" + + def configure_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "configure" sink.stage(operation, "load_existing_config") @@ -39,7 +46,7 @@ def configure_operation(params: dict[str, object], sink: EventSink) -> Operation existing = parse_env_file(env_path) configure_id = str(uuid.uuid4()) ssh_opts = string_param(params, "ssh_opts", existing.get("TC_SSH_OPTS", DEFAULTS["TC_SSH_OPTS"])) - host = string_param(params, "host") or selected_record_host(params) or existing.get("TC_HOST", "") + host = configure_ssh_target(string_param(params, "host") or selected_record_host(params) or existing.get("TC_HOST", "")) password = require_string_param(params, "password") if not host: raise AppOperationError("missing required parameter: host", code="validation_failed") diff --git a/src/timecapsulesmb/app/ops/readiness.py b/src/timecapsulesmb/app/ops/readiness.py index 7fc7d82a..caa7a728 100644 --- a/src/timecapsulesmb/app/ops/readiness.py +++ b/src/timecapsulesmb/app/ops/readiness.py @@ -15,6 +15,7 @@ discovery_record_to_jsonable, service_instance_to_jsonable, ) +from timecapsulesmb.discovery.devices import device_candidate_to_jsonable, device_candidates_from_records from timecapsulesmb.install_validation import ( install_checks_to_jsonable, install_ok, @@ -56,9 +57,11 @@ def selected_record_host(params: dict[str, object]) -> str: 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], } diff --git a/src/timecapsulesmb/cli/configure.py b/src/timecapsulesmb/cli/configure.py index 4de0ab56..e3f1b216 100644 --- a/src/timecapsulesmb/cli/configure.py +++ b/src/timecapsulesmb/cli/configure.py @@ -32,7 +32,7 @@ ssh_target_link_local_resolution_error, ) from timecapsulesmb.core.errors import missing_dependency_message, missing_required_python_module -from timecapsulesmb.core.net import extract_host, is_link_local_ipv4 +from timecapsulesmb.core.net import extract_host from timecapsulesmb.core.paths import resolve_app_paths from timecapsulesmb.identity import ensure_install_id from timecapsulesmb.device.compat import DeviceCompatibility, render_compatibility_message @@ -48,6 +48,7 @@ discover_resolved_records, discovered_record_root_host, ) +from timecapsulesmb.discovery.devices import DiscoveredDeviceCandidate, device_candidates_from_records from timecapsulesmb.telemetry import TelemetryClient from timecapsulesmb.transport.ssh import SshConnection from timecapsulesmb.integrations.acp import ACPAuthError, ACPError, enable_ssh @@ -79,16 +80,15 @@ def confirm(prompt_text: str, default_no: bool = False) -> bool: return confirm_prompt(prompt_text, default=not default_no, eof_default=False) -def list_devices(records) -> None: +def list_devices(candidates: list[DiscoveredDeviceCandidate]) -> None: print("Found devices:") - for i, record in enumerate(records, start=1): - root_host = discovered_record_root_host(record) - pref = root_host.removeprefix("root@") if root_host else record.hostname or "-" - ipv4 = ",".join(record.ipv4) if record.ipv4 else "-" - print(f" {i}. {record.name} | host: {pref} | IPv4: {ipv4}") + for i, candidate in enumerate(candidates, start=1): + pref = candidate.host or "-" + ipv4 = ",".join(candidate.ipv4) if candidate.ipv4 else "-" + print(f" {i}. {candidate.name} | host: {pref} | IPv4: {ipv4}") -def choose_device(records): +def choose_device(candidates: list[DiscoveredDeviceCandidate]) -> DiscoveredDeviceCandidate | None: while True: try: raw = input("Select a device by number (q to skip discovery): ").strip() @@ -101,39 +101,40 @@ def choose_device(records): print("Please enter a valid number.") continue idx = int(raw) - if not (1 <= idx <= len(records)): + if not (1 <= idx <= len(candidates)): print("Out of range.") continue - return records[idx - 1] + return candidates[idx - 1] def discover_default_record(existing: dict[str, str], *, timeout: float) -> Optional[BonjourResolvedService]: print("Attempting to discover Time Capsule/Airport Extreme devices on the local network via mDNS...", flush=True) records = discover_resolved_records(AIRPORT_SERVICE, timeout=timeout) - if not records: + candidates = device_candidates_from_records(records, airport_only=False) + if not candidates: print("No Time Capsule/Airport Extreme devices discovered. Falling back to manual SSH target entry.\n", flush=True) return None - list_devices(records) - selected = choose_device(records) + list_devices(candidates) + selected = choose_device(candidates) if selected is None: existing_target = valid_existing_config_value(existing, "TC_HOST", "Device SSH target") or DEFAULTS["TC_HOST"] print(f"Discovery skipped. Falling back to {existing_target}.\n", flush=True) return None - chosen_host = discovered_record_root_host(selected) + chosen_host = selected.ssh_host selected_host = ( chosen_host.removeprefix("root@") if chosen_host else selected.hostname or "manual SSH target required" ) print(f"Selected: {selected.name} ({selected_host})\n", flush=True) - if chosen_host is None and any(is_link_local_ipv4(ip) for ip in selected.ipv4): + if chosen_host is None and selected.link_local_only: print( "Selected device only advertised 169.254.x.x link-local IPv4. " "Enter the device's LAN IP or LAN-resolving hostname manually.\n", flush=True, ) - return selected + return selected.selected_record def exception_summary(exc: BaseException) -> str: diff --git a/src/timecapsulesmb/discovery/devices.py b/src/timecapsulesmb/discovery/devices.py new file mode 100644 index 00000000..967f802f --- /dev/null +++ b/src/timecapsulesmb/discovery/devices.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass +from typing import Iterable + +from timecapsulesmb.core.net import is_link_local_ipv4 +from timecapsulesmb.discovery.bonjour import ( + AIRPORT_SERVICE, + BonjourResolvedService, + discovered_record_root_host, + discovery_record_to_jsonable, + record_has_service, +) + + +@dataclass(frozen=True) +class DiscoveredDeviceCandidate: + id: str + name: str + host: str + ssh_host: str | None + hostname: str + addresses: tuple[str, ...] + ipv4: tuple[str, ...] + ipv6: tuple[str, ...] + preferred_ipv4: str | None + link_local_only: bool + syap: str | None + model: str | None + service_type: str + fullname: str + selected_record: BonjourResolvedService + + +def device_candidates_from_records( + records: Iterable[BonjourResolvedService], + *, + airport_only: bool = True, +) -> list[DiscoveredDeviceCandidate]: + materialized = list(records) + source_records = [record for record in materialized if record_has_service(record, AIRPORT_SERVICE)] + if not airport_only and not source_records: + source_records = materialized + candidates = [ + _candidate_from_record(record, index) + for index, record in enumerate(source_records) + ] + by_key: dict[str, DiscoveredDeviceCandidate] = {} + for candidate in candidates: + key = _dedupe_key(candidate) + existing = by_key.get(key) + if existing is None or _candidate_score(candidate) > _candidate_score(existing): + by_key[key] = candidate + return sorted(by_key.values(), key=lambda candidate: (candidate.name.casefold(), candidate.host.casefold(), candidate.id)) + + +def device_candidate_to_jsonable(candidate: DiscoveredDeviceCandidate) -> dict[str, object]: + return { + "id": candidate.id, + "name": candidate.name, + "host": candidate.host, + "ssh_host": candidate.ssh_host, + "hostname": candidate.hostname, + "addresses": list(candidate.addresses), + "ipv4": list(candidate.ipv4), + "ipv6": list(candidate.ipv6), + "preferred_ipv4": candidate.preferred_ipv4, + "link_local_only": candidate.link_local_only, + "syap": candidate.syap, + "model": candidate.model, + "service_type": candidate.service_type, + "fullname": candidate.fullname, + "selected_record": discovery_record_to_jsonable(candidate.selected_record), + } + + +def _candidate_from_record(record: BonjourResolvedService, index: int) -> DiscoveredDeviceCandidate: + preferred_ipv4 = _first_non_link_local_ipv4(record.ipv4) + ssh_host = discovered_record_root_host(record) + host = _host_from_ssh_host(ssh_host) or record.hostname or _first_value(record.ipv6) or "" + name = record.name or record.hostname or host or "AirPort Device" + fullname = record.fullname or "" + return DiscoveredDeviceCandidate( + id=_candidate_id(record, host=host, index=index), + name=name, + host=host, + ssh_host=ssh_host, + hostname=record.hostname or "", + addresses=tuple([*record.ipv4, *record.ipv6]), + ipv4=tuple(record.ipv4), + ipv6=tuple(record.ipv6), + preferred_ipv4=preferred_ipv4, + link_local_only=bool(record.ipv4) and preferred_ipv4 is None, + syap=_non_empty(record.properties.get("syAP") or record.properties.get("syap")), + model=_non_empty(record.properties.get("model") or record.properties.get("am")), + service_type=record.service_type or "", + fullname=fullname, + selected_record=record, + ) + + +def _candidate_score(candidate: DiscoveredDeviceCandidate) -> tuple[int, int, int, int]: + return ( + 1 if candidate.preferred_ipv4 else 0, + 1 if candidate.ssh_host else 0, + 1 if candidate.syap else 0, + len(candidate.addresses), + ) + + +def _candidate_id(record: BonjourResolvedService, *, host: str, index: int) -> str: + for prefix, value in ( + ("bonjour", record.fullname), + ("hostname", record.hostname), + ("host", host), + ("name", record.name), + ): + normalized = _normalize(value) + if normalized: + return f"{prefix}:{normalized}" + return f"discovered:{index}" + + +def _dedupe_key(candidate: DiscoveredDeviceCandidate) -> str: + for prefix, value in ( + ("bonjour", candidate.fullname), + ("hostname", candidate.hostname), + ("host", candidate.host), + ("name", candidate.name), + ): + normalized = _normalize(value) + if normalized: + return f"{prefix}:{normalized}" + return candidate.id + + +def _first_non_link_local_ipv4(values: Iterable[str]) -> str | None: + for value in values: + if not value or is_link_local_ipv4(value): + continue + try: + if ipaddress.ip_address(value).version == 4: + return value + except ValueError: + continue + return None + + +def _host_from_ssh_host(value: str | None) -> str: + if not value: + return "" + return value.removeprefix("root@") + + +def _first_value(values: Iterable[str]) -> str: + for value in values: + if value: + return value + return "" + + +def _normalize(value: str | None) -> str: + return (value or "").strip().rstrip(".").casefold() + + +def _non_empty(value: str | None) -> str | None: + stripped = (value or "").strip() + return stripped or None diff --git a/tests/test_app_api.py b/tests/test_app_api.py index 3ae79c96..ed8769f6 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -346,8 +346,9 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: hostname="tc.local.", service_type="_airport._tcp.local.", port=5009, - ipv4=("10.0.0.2",), + ipv4=("169.254.44.9", "10.0.0.2"), properties={"syAP": "119"}, + fullname="TC._airport._tcp.local.", ) ], ) @@ -358,10 +359,49 @@ def test_discover_operation_returns_snapshot_payload(self) -> None: 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"], ["10.0.0.2"]) + 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}) - self.assertEqual(result["payload"]["summary"], "discovered 1 resolved AirPort service(s).") + self.assertEqual(result["payload"]["counts"], {"instances": 1, "resolved": 1, "devices": 1}) + self.assertEqual(result["payload"]["summary"], "discovered 1 Time Capsule device(s).") + + def test_discover_operation_exposes_deduped_devices_separately_from_raw_services(self) -> None: + collector = CollectingSink() + raw_records = [ + BonjourResolvedService( + name=name, + hostname=f"{name.lower()}.local.", + service_type=service_type, + port=5009, + ipv4=ipv4, + properties={"syAP": syap}, + fullname=f"{name}.{service_type}", + ) + for name, ipv4, syap in ( + ("James", ("169.254.155.207", "192.168.1.217"), "119"), + ("Office", ("10.0.0.9",), "116"), + ) + for service_type in ( + "_adisk._tcp.local.", + "_airport._tcp.local.", + "_device-info._tcp.local.", + "_smb._tcp.local.", + ) + ] + snapshot = BonjourDiscoverySnapshot(instances=[], resolved=raw_records) + + with mock.patch("timecapsulesmb.app.ops.readiness.discover_snapshot", return_value=snapshot): + rc = service.run_api_request({"operation": "discover", "params": {"timeout": 0.1}}, collector.sink) + + self.assertEqual(rc, 0) + payload = collector.events_of_type("result")[0]["payload"] + self.assertEqual(payload["counts"], {"instances": 0, "resolved": 8, "devices": 2}) + self.assertEqual([device["name"] for device in payload["devices"]], ["James", "Office"]) + self.assertEqual(payload["devices"][0]["host"], "192.168.1.217") + self.assertEqual(payload["devices"][0]["selected_record"]["service_type"], "_airport._tcp.local.") def test_discover_rejects_invalid_timeout_values(self) -> None: for timeout in ("bad", "nan", -1, True): @@ -417,6 +457,36 @@ def test_configure_writes_env_without_persisting_or_leaking_password_by_default( serialized_events = json.dumps(collector.events) self.assertNotIn("goodpw", serialized_events) + def test_configure_defaults_bare_host_to_root_user(self) -> None: + collector = CollectingSink() + captured_connections: list[SshConnection] = [] + + def capture_probe(connection: SshConnection) -> ProbedDeviceState: + captured_connections.append(connection) + return probed_state() + + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / ".env" + with mock.patch("timecapsulesmb.app.ops.configure.probe_connection_state", side_effect=capture_probe): + rc = service.run_api_request( + { + "operation": "configure", + "params": { + "config": str(config_path), + "host": " 10.0.0.2 ", + "password": "goodpw", + }, + }, + collector.sink, + ) + + values = parse_env_file(config_path) + + self.assertEqual(rc, 0) + self.assertEqual(captured_connections[0].host, "root@10.0.0.2") + self.assertEqual(values["TC_HOST"], "root@10.0.0.2") + self.assertEqual(collector.events_of_type("result")[0]["payload"]["host"], "root@10.0.0.2") + def test_configure_can_persist_password_for_env_compatibility_when_requested(self) -> None: collector = CollectingSink() with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_discovery_devices.py b/tests/test_discovery_devices.py new file mode 100644 index 00000000..1724a903 --- /dev/null +++ b/tests/test_discovery_devices.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import unittest + +from timecapsulesmb.discovery.bonjour import BonjourResolvedService +from timecapsulesmb.discovery.devices import device_candidate_to_jsonable, device_candidates_from_records + + +class DiscoveryDeviceCandidateTests(unittest.TestCase): + def test_builds_selectable_devices_from_airport_records_and_prefers_lan_ipv4(self) -> None: + records = [ + self.record("James", "_adisk._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("James", "_airport._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("James", "_device-info._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("James", "_smb._tcp.local.", ["169.254.155.207", "192.168.1.217"]), + self.record("Office", "_adisk._tcp.local.", ["10.0.0.9"]), + self.record("Office", "_airport._tcp.local.", ["10.0.0.9"]), + self.record("Office", "_device-info._tcp.local.", ["10.0.0.9"]), + self.record("Office", "_smb._tcp.local.", ["10.0.0.9"]), + ] + + devices = device_candidates_from_records(records) + + self.assertEqual([device.name for device in devices], ["James", "Office"]) + self.assertEqual(devices[0].host, "192.168.1.217") + self.assertEqual(devices[0].ssh_host, "root@192.168.1.217") + self.assertEqual(devices[0].preferred_ipv4, "192.168.1.217") + self.assertFalse(devices[0].link_local_only) + self.assertEqual(devices[0].selected_record.service_type, "_airport._tcp.local.") + + def test_ignores_non_airport_records_even_when_they_have_time_capsule_metadata(self) -> None: + records = [ + self.record("SMB Only", "_smb._tcp.local.", ["10.0.0.2"], syap="119"), + self.record("Device Info", "_device-info._tcp.local.", ["10.0.0.2"], syap="119"), + ] + + self.assertEqual(device_candidates_from_records(records), []) + + def test_cli_can_build_candidates_from_already_filtered_mock_records(self) -> None: + records = [ + self.record("SMB Only", "_smb._tcp.local.", ["10.0.0.2"], syap="", model=""), + ] + + devices = device_candidates_from_records(records, airport_only=False) + + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].host, "10.0.0.2") + self.assertEqual(devices[0].selected_record.service_type, "_smb._tcp.local.") + + def test_dedupes_repeated_airport_records_and_keeps_best_address_candidate(self) -> None: + records = [ + self.record("Office", "_airport._tcp.local.", ["169.254.44.9"], hostname="office.local."), + self.record("Office", "_airport._tcp.local.", ["169.254.44.9", "10.0.0.2"], hostname="office.local."), + ] + + devices = device_candidates_from_records(records) + + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].host, "10.0.0.2") + self.assertEqual(devices[0].addresses, ("169.254.44.9", "10.0.0.2")) + + def test_link_local_only_candidate_is_explicit_and_does_not_produce_ssh_host(self) -> None: + devices = device_candidates_from_records([ + self.record("Office", "_airport._tcp.local.", ["169.254.44.9"], hostname="office.local.") + ]) + + device = devices[0] + self.assertEqual(device.host, "office.local.") + self.assertIsNone(device.ssh_host) + self.assertIsNone(device.preferred_ipv4) + self.assertTrue(device.link_local_only) + + def test_json_payload_keeps_raw_selected_record_for_configure(self) -> None: + record = self.record("Office", "_airport._tcp.local.", ["10.0.0.2"], syap="119", model="TimeCapsule8,119") + device = device_candidates_from_records([record])[0] + + payload = device_candidate_to_jsonable(device) + + self.assertEqual(payload["host"], "10.0.0.2") + self.assertEqual(payload["ssh_host"], "root@10.0.0.2") + self.assertEqual(payload["syap"], "119") + self.assertEqual(payload["model"], "TimeCapsule8,119") + self.assertEqual(payload["selected_record"]["fullname"], "Office._airport._tcp.local.") + self.assertEqual(payload["selected_record"]["ipv4"], ["10.0.0.2"]) + + def record( + self, + name: str, + service_type: str, + ipv4: list[str], + *, + hostname: str | None = None, + syap: str = "119", + model: str = "TimeCapsule8,119", + ) -> BonjourResolvedService: + return BonjourResolvedService( + name=name, + hostname=hostname or f"{name.lower()}.local.", + service_type=service_type, + port=5009, + ipv4=ipv4, + properties={"syAP": syap, "model": model}, + fullname=f"{name}.{service_type}", + ) From fabd699ac83084c152af9d02fa2e485fc94a017c Mon Sep 17 00:00:00 2001 From: James Chang Date: Wed, 20 May 2026 23:57:28 -0700 Subject: [PATCH 18/20] Build GUI dashboard state and recovery policies Add activity snapshots, operation timelines, dashboard presentation models, device status policies, and flash workflow scaffolding for the saved-device dashboard. Split registry behavior so configure can merge rediscovered devices while profile edits update by ID and reject duplicate hosts or Bonjour fullnames. Route recovery actions, password invalidation, and backend-only readiness activity through app stores with focused Swift coverage. Tests: swift test; .venv/bin/pytest --- .../TimeCapsuleSMBApp/ActivityStore.swift | 105 +++++++++ .../TimeCapsuleSMBApp/ActivityView.swift | 42 ++++ .../AddDeviceFlowStore.swift | 2 +- .../Sources/TimeCapsuleSMBApp/AppStore.swift | 69 ++++-- .../TimeCapsuleSMBApp/BackendClient.swift | 4 + .../TimeCapsuleSMBApp/ConnectView.swift | 23 +- .../TimeCapsuleSMBApp/ContentView.swift | 209 +++++++++--------- .../DashboardPresentation.swift | 114 ++++++++++ .../TimeCapsuleSMBApp/DashboardStore.swift | 102 +++++++++ .../DeployWorkflowStore.swift | 7 + .../DeviceProfileTraits.swift | 32 +++ .../DeviceRegistryStore.swift | 75 ++++++- .../DeviceStatusPolicy.swift | 170 ++++++++++++++ .../TimeCapsuleSMBApp/DoctorStore.swift | 6 + .../TimeCapsuleSMBApp/ErrorRecoveryView.swift | 81 +++++++ .../TimeCapsuleSMBApp/FlashBootHookView.swift | 39 ++++ .../FlashWorkflowStore.swift | 122 ++++++++++ .../TimeCapsuleSMBApp/MaintenanceStore.swift | 6 + .../TimeCapsuleSMBApp/MaintenanceView.swift | 16 -- .../TimeCapsuleSMBApp/OperationTimeline.swift | 146 ++++++++++++ .../RecoveryActionMapper.swift | 109 +++++++++ .../TimeCapsuleSMBApp/SharedViews.swift | 56 +++++ .../TimeCapsuleSMBApp/SidebarView.swift | 39 ++++ .../ActivityStoreTests.swift | 69 ++++++ .../DashboardPresentationTests.swift | 55 +++++ .../DashboardStoreTests.swift | 158 +++++++++++++ .../DeviceProfileTests.swift | 32 ++- .../DeviceRegistryStoreTests.swift | 119 +++++++++- .../DeviceStatusPolicyTests.swift | 157 +++++++++++++ .../FlashWorkflowStoreTests.swift | 64 ++++++ .../OperationTimelineBuilderTests.swift | 45 ++++ .../RecoveryActionMapperTests.swift | 33 +++ 32 files changed, 2146 insertions(+), 160 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift new file mode 100644 index 00000000..940db09d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift @@ -0,0 +1,105 @@ +import Combine +import Foundation + +enum ActivityScope: Equatable { + case app + case device(DeviceProfile.ID) + case unknown +} + +struct ActivitySnapshot: Equatable { + let isRunning: Bool + let scope: ActivityScope + let operationTitle: String + let latestMessage: String? + let timeline: [OperationTimelineItem] +} + +@MainActor +final class ActivityStore: ObservableObject { + @Published private(set) var snapshot = ActivitySnapshot( + isRunning: false, + scope: .unknown, + operationTitle: "No active operation", + latestMessage: nil, + timeline: [] + ) + + private let coordinator: OperationCoordinator + private var cancellables: Set = [] + + init(coordinator: OperationCoordinator) { + self.coordinator = coordinator + coordinator.$activeOperation + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.$activeDeviceID + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.backend.$events + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.backend.$isRunning + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + coordinator.backend.$activeOperationName + .sink { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + .store(in: &cancellables) + refresh() + } + + func refresh() { + let events = coordinator.backend.events + let timeline = OperationTimelineBuilder.timeline(from: events) + let latestMessage = timeline.last?.detail ?? events.last?.summary + let operation = coordinator.activeOperation?.operation + ?? coordinator.backend.activeOperationName + ?? latestOperation(from: events) + let scope: ActivityScope + if let activeDeviceID = coordinator.activeDeviceID { + scope = .device(activeDeviceID) + } else if isAppOperation(operation) { + scope = .app + } else { + scope = .unknown + } + snapshot = ActivitySnapshot( + isRunning: coordinator.backend.isRunning, + scope: scope, + operationTitle: operation.map(OperationTimelineBuilder.operationTitle) ?? (timeline.isEmpty ? "No active operation" : "Last operation"), + latestMessage: latestMessage, + timeline: timeline + ) + } + + private func latestOperation(from events: [BackendEvent]) -> String? { + events.last?.operation + } + + private func isAppOperation(_ operation: String?) -> Bool { + guard let operation else { + return false + } + return ["capabilities", "validate-install", "paths"].contains(operation) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift new file mode 100644 index 00000000..2f5f8f00 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ActivityCompactView: View { + @ObservedObject var activityStore: ActivityStore + @ObservedObject var registry: DeviceRegistryStore + + var body: some View { + let snapshot = activityStore.snapshot + HStack(spacing: 10) { + Image(systemName: snapshot.isRunning ? "hourglass" : "checkmark.circle") + .foregroundStyle(snapshot.isRunning ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(title(snapshot)) + .font(.caption.weight(.medium)) + if let latest = snapshot.latestMessage, !latest.isEmpty { + Text(latest) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer() + if let last = snapshot.timeline.last { + Text(last.title) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.06)) + } + + private func title(_ snapshot: ActivitySnapshot) -> String { + if case .device(let activeDeviceID) = snapshot.scope, + let profile = registry.profile(id: activeDeviceID) { + return "\(snapshot.operationTitle) - \(profile.title)" + } + return snapshot.operationTitle + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift index af6f540c..529c3854 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift @@ -385,7 +385,7 @@ final class AddDeviceFlowStore: ObservableObject { try passwordStore.save(password, for: profile.keychainAccount) var saved = profile saved.passwordState = .available - saved = try registry.save(saved) + saved = try registry.updateProfile(saved) savedProfile = saved } catch { registry.updatePasswordState(.missing, for: profile.id) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift index c17d5f27..e8dd93db 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppStore.swift @@ -13,6 +13,7 @@ enum DashboardPrimaryAction: String, Equatable { struct DeviceDashboardSummary: Equatable { let profile: DeviceProfile let passwordState: DevicePasswordState + let displayStatus: DeviceDisplayStatus let primaryAction: DashboardPrimaryAction let hostWarning: HostCompatibilityWarning? } @@ -26,6 +27,7 @@ final class AppStore: ObservableObject { let deviceRegistry: DeviceRegistryStore let operationCoordinator: OperationCoordinator let passwordStore: PasswordStore + let activityStore: ActivityStore private var cancellables: Set = [] @@ -35,7 +37,8 @@ final class AppStore: ObservableObject { appReadinessStore: AppReadinessStore(backend: coordinator.backend), deviceRegistry: DeviceRegistryStore(), operationCoordinator: coordinator, - passwordStore: KeychainPasswordStore() + passwordStore: KeychainPasswordStore(), + activityStore: ActivityStore(coordinator: coordinator) ) } @@ -43,12 +46,14 @@ final class AppStore: ObservableObject { appReadinessStore: AppReadinessStore, deviceRegistry: DeviceRegistryStore, operationCoordinator: OperationCoordinator, - passwordStore: PasswordStore + passwordStore: PasswordStore, + activityStore: ActivityStore? = nil ) { self.appReadinessStore = appReadinessStore self.deviceRegistry = deviceRegistry self.operationCoordinator = operationCoordinator self.passwordStore = passwordStore + self.activityStore = activityStore ?? ActivityStore(coordinator: operationCoordinator) appReadinessStore.objectWillChange .sink { [weak self] _ in @@ -65,6 +70,11 @@ final class AppStore: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + self.activityStore.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) deviceRegistry.$profiles .sink { [weak self] profiles in Task { @MainActor in @@ -99,28 +109,30 @@ final class AppStore: ObservableObject { } func dashboardSummary(for profile: DeviceProfile) -> DeviceDashboardSummary { - let passwordState = passwordStore.state(for: profile.keychainAccount) - let primaryAction: DashboardPrimaryAction - if passwordState != .available { - primaryAction = .replacePassword - } else if profile.lastCheckup == nil { - primaryAction = .runCheckup - } else if profile.lastDeploy == nil { - primaryAction = .installSMB - } else if profile.lastCheckup?.failCount ?? 0 > 0 || profile.lastCheckup?.warnCount ?? 0 > 0 { - primaryAction = .viewCheckup - } else { - primaryAction = .openSMB - } + let passwordState = effectivePasswordState(for: profile) + let displayStatus = DeviceStatusPolicy.status( + for: profile, + passwordState: passwordState, + activeOperation: operationCoordinator.activeOperation + ) + let primaryAction = DashboardPrimaryActionPolicy.primaryAction( + for: profile, + passwordState: passwordState, + activeOperation: operationCoordinator.activeOperation + ) return DeviceDashboardSummary( profile: profile, passwordState: passwordState, + displayStatus: displayStatus, primaryAction: primaryAction, hostWarning: HostCompatibilityPolicy.warning() ) } func password(for profile: DeviceProfile) -> String? { + if profile.passwordState == .invalid { + return nil + } do { return try passwordStore.password(for: profile.keychainAccount) } catch PasswordStoreError.missing { @@ -137,6 +149,24 @@ final class AppStore: ObservableObject { deviceRegistry.updatePasswordState(.available, for: profile.id) } + func updateSettings(_ settings: DeviceProfileSettings, for profile: DeviceProfile) throws { + var updated = profile + updated.settings = settings + try deviceRegistry.updateProfile(updated) + } + + func rename(_ profile: DeviceProfile, displayName: String) throws { + var updated = profile + updated.displayName = displayName + try deviceRegistry.updateProfile(updated) + } + + func updateHost(_ profile: DeviceProfile, host: String) throws { + var updated = profile + updated.host = host + try deviceRegistry.updateProfile(updated) + } + func forget(_ profile: DeviceProfile) throws { try passwordStore.deletePassword(for: profile.keychainAccount) try deviceRegistry.delete(profile) @@ -148,8 +178,15 @@ final class AppStore: ObservableObject { func refreshPasswordStates() { for profile in deviceRegistry.profiles { - deviceRegistry.updatePasswordState(passwordStore.state(for: profile.keychainAccount), for: profile.id) + deviceRegistry.updatePasswordState(effectivePasswordState(for: profile), for: profile.id) + } + } + + private func effectivePasswordState(for profile: DeviceProfile) -> DevicePasswordState { + if profile.passwordState == .invalid { + return .invalid } + return passwordStore.state(for: profile.keychainAccount) } private func syncSelection(profiles: [DeviceProfile]) { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index c452de77..9dca361a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -10,6 +10,7 @@ final class BackendClient: ObservableObject { @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? @@ -33,6 +34,7 @@ final class BackendClient: ObservableObject { currentStage = nil currentRisk = nil currentCancellable = nil + activeOperationName = nil } var canCancel: Bool { @@ -51,6 +53,7 @@ final class BackendClient: ObservableObject { currentStage = nil currentRisk = nil currentCancellable = nil + activeOperationName = operation activeCall = BackendCall(operation: operation, params: runParams, context: context) let helperPath = self.helperPath.trimmingCharacters(in: .whitespacesAndNewlines) let runner = self.runner @@ -107,6 +110,7 @@ final class BackendClient: ObservableObject { isRunning = false runTask = nil activeCall = nil + activeOperationName = nil } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift index a9c99c64..a0e01a95 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConnectView.swift @@ -70,7 +70,7 @@ struct ConnectView: View { } if let error = store.error { - ErrorRecoveryView(error: error) + ErrorBlock(error: error) } } .padding() @@ -186,24 +186,3 @@ private struct ConfiguredDeviceView: View { .font(.caption) } } - -private struct ErrorRecoveryView: View { - let error: BackendErrorViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(error.recovery?.title ?? error.code) - .font(.body.weight(.medium)) - Text(error.message) - .font(.caption) - if let recovery = error.recovery, !recovery.actions.isEmpty { - ForEach(recovery.actions, id: \.self) { action in - Text(action) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .foregroundStyle(.red) - } -} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index 256fcbbf..d2bedf19 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -36,6 +36,11 @@ public struct ContentView: View { diagnosticsPresented = true } detail + Divider() + ActivityCompactView( + activityStore: appStore.activityStore, + registry: appStore.deviceRegistry + ) } } .toolbar { @@ -197,7 +202,10 @@ public struct ContentView: View { Section("Devices") { ForEach(appStore.deviceRegistry.profiles) { profile in - Label(profile.title, systemImage: "externaldrive") + DeviceSidebarRow( + profile: profile, + summary: appStore.dashboardSummary(for: profile) + ) .tag("device:\(profile.id)") } } @@ -220,7 +228,10 @@ public struct ContentView: View { profile: profile, dashboardStore: dashboardStore, appStore: appStore, - replacementPassword: $replacementPassword + replacementPassword: $replacementPassword, + showDiagnostics: { + diagnosticsPresented = true + } ) } else { DeviceListOverviewView(appStore: appStore) @@ -245,6 +256,7 @@ private struct DeviceListOverviewView: View { } } else { ForEach(appStore.deviceRegistry.profiles) { profile in + let summary = appStore.dashboardSummary(for: profile) Button { appStore.select(profile) } label: { @@ -257,7 +269,7 @@ private struct DeviceListOverviewView: View { .foregroundStyle(.secondary) } Spacer() - Text(profile.payloadFamily ?? "Unchecked") + Label(summary.displayStatus.title, systemImage: summary.displayStatus.systemImage) .font(.caption) .foregroundStyle(.secondary) } @@ -417,6 +429,7 @@ private struct DeviceDashboardView: View { @ObservedObject var dashboardStore: DashboardStore @ObservedObject var appStore: AppStore @Binding var replacementPassword: String + let showDiagnostics: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -436,11 +449,11 @@ private struct DeviceDashboardView: View { case .overview: OverviewTab(profile: profile, dashboardStore: dashboardStore, appStore: appStore, replacementPassword: $replacementPassword) case .install: - InstallTab(profile: profile, dashboardStore: dashboardStore) + InstallTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) case .checkup: - CheckupTab(profile: profile, dashboardStore: dashboardStore) + CheckupTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) case .maintenance: - MaintenanceTab(profile: profile, dashboardStore: dashboardStore) + MaintenanceTab(profile: profile, dashboardStore: dashboardStore, showDiagnostics: showDiagnostics) case .advanced: AdvancedTab(profile: profile, appStore: appStore) } @@ -469,6 +482,7 @@ private struct OverviewTab: View { .font(.title2.weight(.semibold)) Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { Text("Status").foregroundStyle(.secondary); Text(summary.displayStatus.title) } GridRow { Text("Host").foregroundStyle(.secondary); Text(profile.host) } GridRow { Text("Model").foregroundStyle(.secondary); Text(profile.model ?? "Unknown") } GridRow { Text("Generation").foregroundStyle(.secondary); Text(profile.deviceGeneration ?? "Unknown") } @@ -557,6 +571,7 @@ private struct OverviewTab: View { private struct InstallTab: View { let profile: DeviceProfile @ObservedObject var dashboardStore: DashboardStore + let showDiagnostics: () -> Void var body: some View { let store = dashboardStore.deployStore @@ -590,12 +605,23 @@ private struct InstallTab: View { StageLine(stage: stage) } if let plan = store.plan { - SummaryGrid(rows: [ - ("Host", plan.host), - ("Payload", plan.payloadFamily ?? "unknown"), - ("Reboot", plan.requiresReboot ? "required" : "not required"), - ("Actions", "\(plan.uploads.count) uploads") - ]) + let presentation = DeployPlanPresentation( + plan: plan, + profile: profile, + hostWarning: HostCompatibilityPolicy.warning() + ) + Text(presentation.title) + .font(.headline) + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + ForEach(presentation.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.yellow) + } + DisclosureGroup("Advanced Plan Details") { + SummaryGrid(rows: presentation.advancedRows.map { ($0.label, $0.value) }) + .padding(.top, 6) + } } if let result = store.result { SummaryGrid(rows: [ @@ -605,15 +631,26 @@ private struct InstallTab: View { ]) } if let error = store.error { - ErrorBlock(error: error) + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } } } } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + } } private struct CheckupTab: View { let profile: DeviceProfile @ObservedObject var dashboardStore: DashboardStore + let showDiagnostics: () -> Void var body: some View { let store = dashboardStore.doctorStore @@ -635,13 +672,11 @@ private struct CheckupTab: View { StageLine(stage: stage) } if let summary = store.summary { - SummaryGrid(rows: [ - ("PASS", "\(summary.passCount)"), - ("WARN", "\(summary.warnCount)"), - ("FAIL", "\(summary.failCount)"), - ("INFO", "\(summary.infoCount)") - ]) - ForEach(summary.groups) { group in + let presentation = CheckupPresentation(summary: summary, state: store.state) + Text(presentation.headline) + .font(.headline) + SummaryGrid(rows: presentation.summaryRows.map { ($0.label, $0.value) }) + ForEach(presentation.groups) { group in VStack(alignment: .leading, spacing: 4) { Text(group.domain).font(.headline) ForEach(Array(group.checks.enumerated()), id: \.offset) { _, check in @@ -657,18 +692,30 @@ private struct CheckupTab: View { } } if let error = store.error { - ErrorBlock(error: error) + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } } } } + + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + } } private struct MaintenanceTab: View { let profile: DeviceProfile @ObservedObject var dashboardStore: DashboardStore + let showDiagnostics: () -> Void var body: some View { let store = dashboardStore.maintenanceStore + let presentation = MaintenanceWorkflowPresentation.presentation(for: store.selectedWorkflow) VStack(alignment: .leading, spacing: 12) { Text("Maintenance") .font(.title2.weight(.semibold)) @@ -680,6 +727,17 @@ private struct MaintenanceTab: View { } .pickerStyle(.segmented) + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.headline) + Text(presentation.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + Label(presentation.risk, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { TextField(L10n.string("field.mount_wait"), text: $dashboardStore.maintenanceStore.mountWait) .frame(width: 150) @@ -688,16 +746,27 @@ private struct MaintenanceTab: View { } maintenanceControls(store: store) + FlashBootHookSection(profile: profile) if let stage = store.currentStage { StageLine(stage: stage) } if let error = store.error { - ErrorBlock(error: error) + ErrorRecoveryView(error: error) { action in + handleRecovery(action: action, error: error) + } } } } + private func handleRecovery(action: RecoveryAction, error: BackendErrorViewModel) { + if action.kind == .diagnostics { + showDiagnostics() + return + } + _ = dashboardStore.handleRecoveryAction(action, error: error, profile: profile) + } + @ViewBuilder private func maintenanceControls(store: MaintenanceStore) -> some View { switch store.selectedWorkflow { @@ -768,7 +837,14 @@ private struct MaintenanceTab: View { } case .repairXattrs: VStack(alignment: .leading, spacing: 8) { - TextField(L10n.string("field.repair_xattrs_path"), text: $dashboardStore.maintenanceStore.repairPath) + HStack { + TextField(L10n.string("field.repair_xattrs_path"), text: $dashboardStore.maintenanceStore.repairPath) + Button { + chooseRepairPath(store: store) + } label: { + Label("Choose Folder", systemImage: "folder") + } + } HStack { Button("Scan Metadata") { store.scanRepairXattrs() @@ -786,6 +862,17 @@ private struct MaintenanceTab: View { } } } + + private func chooseRepairPath(store: MaintenanceStore) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + if panel.runModal() == .OK, let url = panel.url { + store.repairPath = url.path + } + } } private struct AdvancedTab: View { @@ -806,82 +893,6 @@ private struct AdvancedTab: View { } } -private struct WarningBanner: View { - let warning: HostCompatibilityWarning - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.yellow) - VStack(alignment: .leading) { - Text(warning.title) - .font(.body.weight(.medium)) - Text(warning.message) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(10) - .background(Color.yellow.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } -} - -private 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) - } -} - -private 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) - } - } - } -} - -private struct ErrorBlock: View { - let error: BackendErrorViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(error.recovery?.title ?? error.code) - .font(.body.weight(.medium)) - Text(error.message) - .font(.caption) - if let recovery = error.recovery, !recovery.actions.isEmpty { - ForEach(recovery.actions, id: \.self) { action in - Text(action) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .foregroundStyle(.red) - } -} - private struct AppReadinessBannerView: View { @ObservedObject var store: AppReadinessStore let showDiagnostics: () -> Void diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift new file mode 100644 index 00000000..da941e35 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift @@ -0,0 +1,114 @@ +import Foundation + +struct PresentationRow: Equatable, Identifiable { + var id: String { + "\(label):\(value)" + } + + let label: String + let value: String +} + +struct DeployPlanPresentation: Equatable { + let title: String + let summaryRows: [PresentationRow] + let advancedRows: [PresentationRow] + let warnings: [String] + + init(plan: DeployPlanPayload, profile: DeviceProfile, hostWarning: HostCompatibilityWarning? = nil) { + self.title = plan.netbsd4 ? "Install SMB and Start Runtime" : "Install SMB" + self.summaryRows = [ + PresentationRow(label: "Target", value: profile.title), + PresentationRow(label: "Host", value: plan.host), + PresentationRow(label: "Payload", value: plan.payloadFamily ?? profile.payloadFamily ?? "Unknown"), + PresentationRow(label: "Disk Location", value: plan.volumeRoot ?? plan.payloadDir), + PresentationRow(label: "Reboot", value: plan.requiresReboot ? "Required" : "Not required"), + PresentationRow(label: "Expected Changes", value: "\(plan.uploads.count) file upload(s), \(plan.postUploadActions.count) install action(s)") + ] + self.advancedRows = [ + PresentationRow(label: "Payload Directory", value: plan.payloadDir), + PresentationRow(label: "Pre-upload Actions", value: "\(plan.preUploadActions.count)"), + PresentationRow(label: "Post-upload Actions", value: "\(plan.postUploadActions.count)"), + PresentationRow(label: "Activation Actions", value: "\(plan.activationActions.count)"), + PresentationRow(label: "Post-install Checks", value: plan.postDeployChecks.map(\.description).joined(separator: ", ")) + ] + var warnings: [String] = [] + if plan.netbsd4 { + warnings.append("This NetBSD4 device may need Start SMB after future reboots unless the boot hook is patched.") + } + if let hostWarning { + warnings.append(hostWarning.message) + } + self.warnings = warnings + } +} + +struct CheckupPresentation: Equatable { + let headline: String + let summaryRows: [PresentationRow] + let groups: [DoctorCheckGroup] + + init(summary: DoctorSummary, state: DoctorWorkflowState) { + switch state { + case .passed: + self.headline = "SMB looks healthy." + case .warning: + self.headline = "Checkup found warnings." + case .failed: + self.headline = "Checkup found failures." + case .runFailed: + self.headline = "Checkup could not finish." + case .idle: + self.headline = "Run a checkup to verify this Time Capsule." + case .running: + self.headline = "Running checkup..." + } + self.summaryRows = [ + PresentationRow(label: "Pass", value: "\(summary.passCount)"), + PresentationRow(label: "Warning", value: "\(summary.warnCount)"), + PresentationRow(label: "Fail", value: "\(summary.failCount)"), + PresentationRow(label: "Info", value: "\(summary.infoCount)") + ] + self.groups = summary.groups + } +} + +struct MaintenanceWorkflowPresentation: Equatable { + let title: String + let subtitle: String + let primaryAction: String + let risk: String + + static func presentation(for workflow: MaintenanceWorkflow) -> MaintenanceWorkflowPresentation { + switch workflow { + case .activate: + return MaintenanceWorkflowPresentation( + title: "NetBSD4 Activation", + subtitle: "Start the deployed SMB runtime on a NetBSD4 Time Capsule.", + primaryAction: "Start SMB", + risk: "Remote write" + ) + case .uninstall: + return MaintenanceWorkflowPresentation( + title: "Uninstall", + subtitle: "Remove managed SMB files from the selected Time Capsule.", + primaryAction: "Uninstall", + risk: "Destructive" + ) + case .fsck: + return MaintenanceWorkflowPresentation( + title: "Disk Repair", + subtitle: "Unmount a selected HFS volume and run fsck_hfs on the device.", + primaryAction: "Run Disk Repair", + risk: "Destructive" + ) + case .repairXattrs: + return MaintenanceWorkflowPresentation( + title: "File Metadata Repair", + subtitle: "Scan and repair macOS metadata on a mounted SMB share.", + primaryAction: "Repair Metadata", + risk: "Local destructive" + ) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift index 1afa916b..249d5556 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift @@ -1,5 +1,8 @@ import Combine import Foundation +#if canImport(AppKit) +import AppKit +#endif enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { case overview @@ -100,6 +103,40 @@ final class DashboardStore: ObservableObject { 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 .diskRepair: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .fsck + return true + case .metadataRepair: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .repairXattrs + return true + case .replacePassword: + selectedTab = .overview + return true + case .openFinder: + openSMBAddress(for: profile) + return true + case .diagnostics, .copyDiagnostics, .generic: + return false + } + } + private func observeSnapshots() { doctorStore.$state .sink { [weak self] state in @@ -108,6 +145,14 @@ final class DashboardStore: ObservableObject { } } .store(in: &cancellables) + doctorStore.$passwordInvalidProfileID + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor in + self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + } + } + .store(in: &cancellables) deployStore.$state .sink { [weak self] state in Task { @MainActor in @@ -115,6 +160,63 @@ final class DashboardStore: ObservableObject { } } .store(in: &cancellables) + deployStore.$passwordInvalidProfileID + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor in + self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + } + } + .store(in: &cancellables) + maintenanceStore.$passwordInvalidProfileID + .sink { [weak self] profileID in + guard let profileID else { return } + Task { @MainActor in + self?.appStore.deviceRegistry.updatePasswordState(.invalid, for: profileID) + } + } + .store(in: &cancellables) + } + + private func retry(error: BackendErrorViewModel, profile: DeviceProfile) -> Bool { + switch error.operation { + case "doctor": + runCheckup(profile: profile) + return true + case "deploy": + runInstallPlan(profile: profile) + return true + case "activate": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .activate + return true + case "uninstall": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .uninstall + return true + case "fsck": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .fsck + return true + case "repair-xattrs": + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .repairXattrs + return true + default: + return false + } + } + + private func openSMBAddress(for profile: DeviceProfile) { + let host = profile.host + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"^.*@"#, with: "", options: .regularExpression) + guard !host.isEmpty, let url = URL(string: "smb://\(host)") else { + return + } + #if canImport(AppKit) + NSWorkspace.shared.open(url) + #endif } private func forwardChildChanges() { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift index ff406262..9a9afd33 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeployWorkflowStore.swift @@ -68,6 +68,7 @@ final class DeployWorkflowStore: ObservableObject { @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? @@ -157,6 +158,7 @@ final class DeployWorkflowStore: ObservableObject { error = nil currentStage = nil plannedOptions = options + passwordInvalidProfileID = nil return start } @@ -201,6 +203,7 @@ final class DeployWorkflowStore: ObservableObject { result = nil error = nil currentStage = nil + passwordInvalidProfileID = nil return start } @@ -213,6 +216,7 @@ final class DeployWorkflowStore: ObservableObject { error = nil currentStage = nil plannedOptions = nil + passwordInvalidProfileID = nil activeOperation = nil } @@ -321,6 +325,9 @@ final class DeployWorkflowStore: ObservableObject { state = .awaitingConfirmation return } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation?.profileID + } error = BackendErrorViewModel(event: event) state = state == .planning ? .planFailed : .deployFailed activeOperation = nil diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift new file mode 100644 index 00000000..b67194fb --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfileTraits.swift @@ -0,0 +1,32 @@ +import Foundation + +struct DeviceProfileTraits: Equatable { + let isNetBSD4: Bool + let isNetBSD6: Bool + let isSupported: Bool + let supportsFlashBootHook: Bool + let needsActivationAfterReboot: Bool +} + +extension DeviceProfile { + var traits: DeviceProfileTraits { + let isNetBSD4 = payloadFamily?.localizedCaseInsensitiveContains("netbsd4") == true + || osRelease?.hasPrefix("4.") == true + let isNetBSD6 = payloadFamily?.localizedCaseInsensitiveContains("netbsd6") == true + || osRelease?.hasPrefix("6.") == true + let unsupportedValues = [ + payloadFamily, + deviceGeneration + ] + let isSupported = !unsupportedValues.contains { value in + value?.localizedCaseInsensitiveContains("unsupported") == true + } + return DeviceProfileTraits( + isNetBSD4: isNetBSD4, + isNetBSD6: isNetBSD6, + isSupported: isSupported, + supportsFlashBootHook: isNetBSD4, + needsActivationAfterReboot: isNetBSD4 + ) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift index d05d1175..99588b2c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift @@ -12,6 +12,8 @@ enum DeviceRegistryState: String, CaseIterable, Equatable { 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? { @@ -20,6 +22,10 @@ enum DeviceRegistryError: Error, Equatable, LocalizedError { 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 } @@ -114,11 +120,11 @@ final class DeviceRegistryStore: ObservableObject { date: now() ) profile.passwordState = passwordState - return try save(profile) + return try saveMergingDuplicates(profile) } @discardableResult - func save(_ profile: DeviceProfile) throws -> DeviceProfile { + private func saveMergingDuplicates(_ profile: DeviceProfile) throws -> DeviceProfile { state = .saving error = nil do { @@ -140,6 +146,39 @@ final class DeviceRegistryStore: ObservableObject { } } + @discardableResult + func updateProfile(_ profile: DeviceProfile) throws -> DeviceProfile { + guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { + let error = DeviceRegistryError.profileNotFound(profile.id) + self.error = error + throw error + } + if let conflict = duplicateConflict(for: profile, excluding: profile.id) { + self.error = conflict + throw conflict + } + state = .saving + error = nil + var updated = profile + updated.updatedAt = now() + do { + try fileManager.createDirectory(at: devicesDirectoryURL, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + profiles[index] = updated + profiles = profiles.sorted { $0.updatedAt > $1.updatedAt } + try persist() + state = profiles.isEmpty ? .empty : .loaded + return updated + } catch { + self.error = .io(error.localizedDescription) + state = .failed + throw error + } + } + func delete(_ profile: DeviceProfile) throws { state = .saving error = nil @@ -208,6 +247,38 @@ final class DeviceRegistryStore: ObservableObject { return profiles.first { $0.normalizedHost == normalizedHost } } + private func duplicateConflict(for profile: DeviceProfile, excluding profileID: DeviceProfile.ID) -> DeviceRegistryError? { + if let normalizedFullname = normalizedBonjourFullname(profile.bonjourFullname), + let conflicting = profiles.first(where: { + $0.id != profileID && normalizedBonjourFullname($0.bonjourFullname) == normalizedFullname + }) { + return .duplicateProfile( + field: "Bonjour fullname", + value: normalizedFullname, + conflictingProfileID: conflicting.id + ) + } + + let normalizedHost = profile.normalizedHost + if !normalizedHost.isEmpty, + let conflicting = profiles.first(where: { $0.id != profileID && $0.normalizedHost == normalizedHost }) { + return .duplicateProfile( + field: "host", + value: normalizedHost, + conflictingProfileID: conflicting.id + ) + } + return nil + } + + private func normalizedBonjourFullname(_ value: String?) -> String? { + guard let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !normalized.isEmpty else { + return nil + } + return normalized + } + private func persist() throws { try fileManager.createDirectory(at: applicationSupportURL, withIntermediateDirectories: true) let data = try encoder.encode(profiles) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift new file mode 100644 index 00000000..e90bb284 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift @@ -0,0 +1,170 @@ +import Foundation + +enum DeviceDisplayStatus: String, CaseIterable, Equatable, Identifiable { + case unchecked + case passwordNeeded + case passwordInvalid + case keychainUnavailable + case checking + case installing + case maintaining + case readyToInstall + case healthy + case warning + case failed + case activationNeeded + case removed + case offline + case unsupported + + var id: String { rawValue } + + var title: String { + switch self { + case .unchecked: + return "Unchecked" + case .passwordNeeded: + return "Password Needed" + case .passwordInvalid: + return "Password Invalid" + case .keychainUnavailable: + return "Keychain Unavailable" + case .checking: + return "Checking" + case .installing: + return "Installing" + case .maintaining: + return "Maintenance" + case .readyToInstall: + return "Ready to Install" + case .healthy: + return "Healthy" + case .warning: + return "Warning" + case .failed: + return "Failed" + case .activationNeeded: + return "Activation Needed" + case .removed: + return "Removed" + case .offline: + return "Offline" + case .unsupported: + return "Unsupported" + } + } + + var systemImage: String { + switch self { + case .unchecked: + return "circle" + case .passwordNeeded, .passwordInvalid, .keychainUnavailable: + return "key" + case .checking: + return "stethoscope" + case .installing: + return "square.and.arrow.up" + case .maintaining: + return "wrench.and.screwdriver" + case .readyToInstall: + return "arrow.down.circle" + case .healthy: + return "checkmark.circle" + case .warning, .activationNeeded: + return "exclamationmark.triangle" + case .failed, .offline, .unsupported: + return "xmark.octagon" + case .removed: + return "trash" + } + } +} + +enum DeviceStatusPolicy { + static func status( + for profile: DeviceProfile, + passwordState: DevicePasswordState, + activeOperation: ActiveOperation? + ) -> DeviceDisplayStatus { + if let activeOperation, activeOperation.profileID == profile.id { + switch activeOperation.operation { + case "doctor": + return .checking + case "deploy": + return .installing + case "activate", "uninstall", "fsck", "repair-xattrs", "flash": + return .maintaining + default: + break + } + } + + switch passwordState { + case .missing, .unknown: + return .passwordNeeded + case .invalid: + return .passwordInvalid + case .keychainUnavailable: + return .keychainUnavailable + case .available: + break + } + + if !profile.traits.isSupported { + return .unsupported + } + + guard let checkup = profile.lastCheckup else { + return .unchecked + } + + if checkup.failCount > 0 || checkup.state == .failed || checkup.state == .runFailed { + return .failed + } + if profile.traits.needsActivationAfterReboot, profile.lastDeploy != nil, checkup.warnCount > 0 { + return .activationNeeded + } + if checkup.warnCount > 0 || checkup.state == .warning { + return .warning + } + if profile.lastDeploy == nil { + return .readyToInstall + } + return .healthy + } + +} + +enum DashboardPrimaryActionPolicy { + static func primaryAction( + for profile: DeviceProfile, + passwordState: DevicePasswordState, + activeOperation: ActiveOperation? + ) -> DashboardPrimaryAction { + let status = DeviceStatusPolicy.status( + for: profile, + passwordState: passwordState, + activeOperation: activeOperation + ) + switch status { + case .passwordNeeded, .passwordInvalid, .keychainUnavailable: + return .replacePassword + case .unchecked: + return .runCheckup + case .readyToInstall: + return .installSMB + case .warning, .failed, .activationNeeded: + return .viewCheckup + case .healthy: + return .openSMB + case .checking: + return .viewCheckup + case .installing: + return .installSMB + case .maintaining: + return .viewCheckup + case .removed, .offline, .unsupported: + return .runCheckup + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift index fc9ad9e2..ecd9961f 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DoctorStore.swift @@ -97,6 +97,7 @@ final class DoctorStore: ObservableObject { @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? @@ -180,6 +181,7 @@ final class DoctorStore: ObservableObject { summary = nil error = nil currentStage = nil + passwordInvalidProfileID = nil return start } @@ -191,6 +193,7 @@ final class DoctorStore: ObservableObject { summary = nil error = nil currentStage = nil + passwordInvalidProfileID = nil activeOperation = nil } @@ -225,6 +228,9 @@ final class DoctorStore: ObservableObject { } if event.type == "error" { + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation?.profileID + } error = BackendErrorViewModel(event: event) state = .runFailed activeOperation = nil diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift new file mode 100644 index 00000000..8a6aa52d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift @@ -0,0 +1,81 @@ +import AppKit +import SwiftUI + +struct ErrorBlock: View { + let error: BackendErrorViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(error.recovery?.title ?? error.code) + .font(.body.weight(.medium)) + Text(error.message) + .font(.caption) + } + .foregroundStyle(.red) + } +} + +struct ErrorRecoveryView: View { + let error: BackendErrorViewModel + let onAction: (RecoveryAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ErrorBlock(error: error) + let actions = RecoveryActionMapper.actions(for: error) + if !actions.isEmpty { + HStack { + ForEach(actions) { action in + Button { + if action.kind == .copyDiagnostics { + copyDiagnostics() + } else { + onAction(action) + } + } label: { + Label(action.title, systemImage: icon(for: action.kind)) + } + .disabled(!isActionable(action)) + } + } + } + } + } + + private func isActionable(_ action: RecoveryAction) -> Bool { + action.kind != .generic + } + + private func copyDiagnostics() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString("\(error.operation) \(error.code): \(error.message)", forType: .string) + } + + private func icon(for kind: RecoveryActionKind) -> String { + switch kind { + case .retry: + return "arrow.clockwise" + case .runCheckup: + return "stethoscope" + case .installSMB: + return "square.and.arrow.up" + case .startSMB: + return "play.circle" + case .diskRepair: + return "externaldrive.badge.exclamationmark" + case .metadataRepair: + return "tag" + case .openFinder: + return "folder" + case .replacePassword: + return "key" + case .copyDiagnostics: + return "doc.on.doc" + case .diagnostics: + return "wrench.and.screwdriver" + case .generic: + return "arrow.right.circle" + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift new file mode 100644 index 00000000..1fbb53e2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashBootHookView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct FlashBootHookSection: View { + let profile: DeviceProfile + @StateObject private var store = FlashWorkflowStore() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Persistent NetBSD4 Boot Hook") + .font(.headline) + Text(store.eligibilityMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Label(store.state.title, systemImage: "lock") + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { + Button("Back Up and Inspect") {} + .disabled(true) + Button("Patch Boot Hook") {} + .disabled(true) + Button("Restore Apple Firmware") {} + .disabled(true) + } + } + .onAppear { + store.refresh(profile: profile) + } + .onChange(of: profile.id) { _ in + store.refresh(profile: profile) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift new file mode 100644 index 00000000..d2269042 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/FlashWorkflowStore.swift @@ -0,0 +1,122 @@ +import Foundation + +enum FlashBuildPolicy: String, CaseIterable, Equatable { + case disabled + case readOnly + case writesEnabled +} + +enum FlashWorkflowState: String, CaseIterable, Equatable { + case unavailable + case disabledInThisBuild + case eligibleForReadOnlyAnalysis + case readingBanks + case savingBackup + case analyzingBanks + case planAvailable + case writeLocked + case awaitingStrongConfirmation + case writing + case readbackValidating + case writeValidated + case manualPowerCycleRequired + case restoreRebooting + case failed + + var title: String { + switch self { + case .unavailable: + return "Unavailable" + case .disabledInThisBuild: + return "Disabled in This Build" + case .eligibleForReadOnlyAnalysis: + return "Read-Only Analysis Available" + case .readingBanks: + return "Reading Firmware Banks" + case .savingBackup: + return "Saving Backup" + case .analyzingBanks: + return "Analyzing Firmware" + case .planAvailable: + return "Plan Available" + case .writeLocked: + return "Write Locked" + case .awaitingStrongConfirmation: + return "Awaiting Strong Confirmation" + case .writing: + return "Writing Firmware" + case .readbackValidating: + return "Validating Write" + case .writeValidated: + return "Write Validated" + case .manualPowerCycleRequired: + return "Manual Power Cycle Required" + case .restoreRebooting: + return "Rebooting After Restore" + case .failed: + return "Failed" + } + } +} + +struct FlashEligibility: Equatable { + let state: FlashWorkflowState + let message: String + let readOnlyAllowed: Bool + let writeAllowed: Bool +} + +enum FlashEligibilityPolicy { + static func eligibility(for profile: DeviceProfile, buildPolicy: FlashBuildPolicy = .disabled) -> FlashEligibility { + guard profile.traits.supportsFlashBootHook else { + return FlashEligibility( + state: .unavailable, + message: "Persistent boot hook tools are only for NetBSD4 Time Capsules.", + readOnlyAllowed: false, + writeAllowed: false + ) + } + + switch buildPolicy { + case .disabled: + return FlashEligibility( + state: .disabledInThisBuild, + message: "Firmware boot hook analysis is planned, but disabled in this build.", + readOnlyAllowed: false, + writeAllowed: false + ) + case .readOnly: + return FlashEligibility( + state: .eligibleForReadOnlyAnalysis, + message: "This device can use read-only firmware backup and inspection when the flash API is available.", + readOnlyAllowed: true, + writeAllowed: false + ) + case .writesEnabled: + return FlashEligibility( + state: .writeLocked, + message: "Write actions require backup review and strong confirmation before they can run.", + readOnlyAllowed: true, + writeAllowed: true + ) + } + } +} + +@MainActor +final class FlashWorkflowStore: ObservableObject { + @Published private(set) var state: FlashWorkflowState = .disabledInThisBuild + @Published private(set) var eligibilityMessage = "Firmware boot hook analysis is disabled in this build." + + let buildPolicy: FlashBuildPolicy + + init(buildPolicy: FlashBuildPolicy = .disabled) { + self.buildPolicy = buildPolicy + } + + func refresh(profile: DeviceProfile) { + let eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: buildPolicy) + state = eligibility.state + eligibilityMessage = eligibility.message + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift index c268088b..6dfd0b55 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceStore.swift @@ -137,6 +137,7 @@ final class MaintenanceStore: ObservableObject { @Published private(set) var repairResult: RepairXattrsPayload? @Published private(set) var currentStage: OperationStageState? @Published private(set) var error: BackendErrorViewModel? + @Published private(set) var passwordInvalidProfileID: DeviceProfile.ID? let backend: BackendClient private let coordinator: OperationCoordinator? @@ -508,6 +509,7 @@ final class MaintenanceStore: ObservableObject { repairResult = nil currentStage = nil error = nil + passwordInvalidProfileID = nil plannedUninstallOptions = nil plannedFsckOptions = nil plannedFsckTargetID = nil @@ -535,6 +537,7 @@ final class MaintenanceStore: ObservableObject { lastProcessedEventCount = 0 error = nil currentStage = nil + passwordInvalidProfileID = nil activeOperation = nil } @@ -712,6 +715,9 @@ final class MaintenanceStore: ObservableObject { } return } + if event.code == "auth_failed" { + passwordInvalidProfileID = activeOperation?.profileID + } error = BackendErrorViewModel(event: event) failState(for: event.operation) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift index 777d65b2..93fb23ae 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/MaintenanceView.swift @@ -260,22 +260,6 @@ private struct StatusLabel: View { } } -private 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) - } - } - } -} - private struct MaintenanceErrorView: View { let error: BackendErrorViewModel diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift new file mode 100644 index 00000000..e57d03f3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift @@ -0,0 +1,146 @@ +import Foundation + +struct OperationTimelineItem: Equatable, Identifiable { + enum State: String, Equatable { + case pending + case running + case succeeded + case warning + case failed + } + + let id: String + let operation: String + let title: String + let detail: String? + let state: State + let risk: String? + let cancellable: Bool? +} + +enum OperationTimelineBuilder { + static func timeline(from events: [BackendEvent]) -> [OperationTimelineItem] { + events.enumerated().compactMap { index, event in + switch event.type { + case "stage": + return OperationTimelineItem( + id: "\(index):\(event.operation):\(event.stage ?? "stage")", + operation: event.operation, + title: title(for: event.operation, stage: event.stage), + detail: event.description, + state: .running, + risk: event.risk, + cancellable: event.cancellable + ) + case "result": + return OperationTimelineItem( + id: "\(index):\(event.operation):result", + operation: event.operation, + title: event.ok == true ? "Done" : "Failed", + detail: event.payloadSummaryText ?? event.summary, + state: event.ok == true ? .succeeded : .failed, + risk: nil, + cancellable: nil + ) + case "error": + return OperationTimelineItem( + id: "\(index):\(event.operation):error", + operation: event.operation, + title: event.code == "confirmation_required" ? "Needs Confirmation" : "Needs Attention", + detail: event.message, + state: event.code == "confirmation_required" ? .warning : .failed, + risk: event.risk, + cancellable: event.cancellable + ) + default: + return nil + } + } + } + + static func operationTitle(_ operation: String) -> String { + switch operation { + case "discover": + return "Discovery" + case "configure": + return "Add Time Capsule" + case "deploy": + return "Install / Update" + case "doctor": + return "Checkup" + case "activate": + return "Start SMB" + case "fsck": + return "Disk Repair" + case "repair-xattrs": + return "File Metadata Repair" + case "uninstall": + return "Uninstall" + case "capabilities", "validate-install", "paths": + return "App Readiness" + case "flash": + return "Persistent NetBSD4 Boot Hook" + default: + return operation + } + } + + private static func title(for operation: String, stage: String?) -> String { + guard let stage else { + return operationTitle(operation) + } + switch (operation, stage) { + case ("discover", "bonjour_discovery"): + return "Finding Time Capsules" + case ("configure", "ssh_probe"), ("configure", "ssh_probe_after_acp"): + return "Checking SSH" + case ("configure", "acp_enable_ssh"): + return "Enabling SSH" + case ("configure", "wait_for_ssh_after_acp"): + return "Waiting for Device" + case ("configure", "write_env"): + return "Saving Device" + case ("deploy", "build_deployment_plan"): + return "Planning Install" + case ("deploy", "validate_artifacts"): + return "Checking Bundled Files" + case ("deploy", "read_mast"), ("deploy", "select_payload_home"): + return "Finding Disk" + case ("deploy", "upload_payload"): + return "Uploading" + case ("deploy", "flush_payload_upload"): + return "Syncing to Disk" + case ("deploy", "reboot"), ("deploy", "wait_for_reboot_down"), ("deploy", "wait_for_reboot_up"): + return "Rebooting" + case ("deploy", "netbsd4_activation"): + return "Starting SMB" + case ("deploy", "verify_runtime_activation"), ("deploy", "verify_runtime_reboot"): + return "Verifying SMB" + case ("doctor", "run_checks"): + return "Running Checkup" + case ("activate", "build_activation_plan"): + return "Planning Start SMB" + case ("activate", "run_activation"): + return "Starting SMB" + case ("uninstall", "build_uninstall_plan"): + return "Planning Uninstall" + case ("uninstall", "uninstall_payload"): + return "Removing Managed Files" + case ("fsck", "read_mast"), ("fsck", "select_fsck_volume"): + return "Finding Volumes" + case ("fsck", "run_fsck"): + return "Repairing Disk" + case ("repair-xattrs", "scan_findings"): + return "Scanning Metadata" + case ("repair-xattrs", "repair_findings"): + return "Repairing Metadata" + case ("validate-install", "validate_install"): + return "Validating App Bundle" + default: + return stage + .split(separator: "_") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift new file mode 100644 index 00000000..54dbf56a --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift @@ -0,0 +1,109 @@ +import Foundation + +enum RecoveryActionKind: String, Equatable { + case retry + case runCheckup + case installSMB + case startSMB + case diskRepair + case metadataRepair + case openFinder + case replacePassword + case copyDiagnostics + case 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(RecoveryAction(title: "Replace Password", kind: .replacePassword)) + } + + if let suggested = error.recovery?.suggestedOperation { + actions.append(action(forSuggestedOperation: suggested)) + } + + for title in error.recovery?.actions ?? [] { + actions.append(RecoveryAction(title: title, kind: inferKind(from: title))) + } + + if error.recovery?.retryable == true || error.code == "operation_failed" { + actions.append(RecoveryAction(title: "Retry", kind: .retry)) + } + actions.append(RecoveryAction(title: "Copy Diagnostics", kind: .copyDiagnostics)) + return deduplicated(actions) + } + + private static func action(forSuggestedOperation operation: String) -> RecoveryAction { + switch operation { + case "doctor": + return RecoveryAction(title: "Run Checkup", kind: .runCheckup) + case "deploy": + return RecoveryAction(title: "Install SMB", kind: .installSMB) + case "activate": + return RecoveryAction(title: "Start SMB", kind: .startSMB) + case "fsck": + return RecoveryAction(title: "Run Disk Repair", kind: .diskRepair) + case "repair-xattrs": + return RecoveryAction(title: "Repair File Metadata", kind: .metadataRepair) + case "validate-install": + return RecoveryAction(title: "Open Diagnostics", kind: .diagnostics) + default: + return RecoveryAction(title: operation, kind: .generic) + } + } + + private static func inferKind(from title: String) -> RecoveryActionKind { + let lower = title.lowercased() + if lower.contains("password") { + return .replacePassword + } + if lower.contains("checkup") || lower.contains("doctor") { + return .runCheckup + } + if lower.contains("deploy") || lower.contains("install") { + return .installSMB + } + if lower.contains("activate") || lower.contains("start smb") { + return .startSMB + } + if lower.contains("finder") || lower.contains("smb://") { + return .openFinder + } + if lower.contains("fsck") || lower.contains("disk") { + return .diskRepair + } + if lower.contains("xattr") || lower.contains("metadata") { + return .metadataRepair + } + if lower.contains("diagnostic") { + return .diagnostics + } + if lower.contains("retry") { + return .retry + } + return .generic + } + + 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/SharedViews.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift new file mode 100644 index 00000000..a3df09bf --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SharedViews.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct WarningBanner: View { + let warning: HostCompatibilityWarning + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + VStack(alignment: .leading) { + Text(warning.title) + .font(.body.weight(.medium)) + Text(warning.message) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(Color.yellow.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +struct SummaryGrid: View { + let rows: [(String, String)] + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + GridRow { + Text(row.0).foregroundStyle(.secondary) + Text(row.1) + .lineLimit(2) + .truncationMode(.middle) + } + } + } + .font(.caption) + } +} + +struct StageLine: View { + let stage: OperationStageState + + var body: some View { + HStack(spacing: 8) { + Text(stage.stage) + .font(.system(.caption, design: .monospaced)) + if let description = stage.description { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift new file mode 100644 index 00000000..8b84e91d --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/SidebarView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct DeviceSidebarRow: View { + let profile: DeviceProfile + let summary: DeviceDashboardSummary + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "externaldrive") + VStack(alignment: .leading, spacing: 2) { + Text(profile.title) + .lineLimit(1) + Text(profile.host) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Image(systemName: summary.displayStatus.systemImage) + .foregroundStyle(statusColor) + .help(summary.displayStatus.title) + } + } + + private var statusColor: Color { + switch summary.displayStatus { + case .healthy: + return .green + case .warning, .activationNeeded: + return .yellow + case .failed, .passwordInvalid, .keychainUnavailable, .offline, .unsupported: + return .red + case .installing, .checking, .maintaining, .readyToInstall: + return .accentColor + default: + return .secondary + } + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift new file mode 100644 index 00000000..567262ac --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ActivityStoreTests: XCTestCase { + func testActivitySnapshotTracksActiveOperationTimelineAndDevice() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "deploy", + stage: "upload_payload", + description: "Upload managed Samba payload files." + ), + BackendEvent( + type: "result", + operation: "deploy", + ok: true, + payload: .object(["summary": .string("deployment completed.")]) + ) + ], delayNanoseconds: 80_000_000) + ]) + let coordinator = OperationCoordinator(backend: BackendClient(runner: runner)) + let activity = ActivityStore(coordinator: coordinator) + let context = DeviceRuntimeContext(profileID: "device-one", configURL: URL(fileURLWithPath: "/tmp/device-one/.env")) + + _ = coordinator.run(operation: "deploy", context: context, activeDeviceID: "device-one") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "Install / Update") + XCTAssertEqual(activity.snapshot.scope, .device("device-one")) + + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.timeline.count == 2 } + XCTAssertEqual(activity.snapshot.timeline.map(\.title), ["Uploading", "Done"]) + XCTAssertEqual(activity.snapshot.latestMessage, "deployment completed.") + } + + func testActivitySnapshotTracksBackendOnlyReadinessOperationAsAppScoped() async throws { + let runner = StoreTestRunner(responses: [ + .init(events: [ + BackendEvent( + type: "stage", + operation: "capabilities", + stage: "start", + description: "Inspect helper capabilities." + ), + BackendEvent( + type: "result", + operation: "capabilities", + ok: true, + payload: .object(["schema_version": .number(1)]) + ) + ], delayNanoseconds: 80_000_000) + ]) + let backend = BackendClient(runner: runner) + let coordinator = OperationCoordinator(backend: backend) + let activity = ActivityStore(coordinator: coordinator) + + backend.run(operation: "capabilities") + + try await waitUntilStoreState { activity.snapshot.isRunning } + XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") + XCTAssertEqual(activity.snapshot.scope, .app) + + try await waitUntilStoreState { !activity.snapshot.isRunning && activity.snapshot.timeline.count == 2 } + XCTAssertEqual(activity.snapshot.scope, .app) + XCTAssertEqual(activity.snapshot.operationTitle, "App Readiness") + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift new file mode 100644 index 00000000..8803d42a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardPresentationTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class DashboardPresentationTests: XCTestCase { + func testDeployPlanPresentationSeparatesSummaryAdvancedAndWarnings() throws { + let plan = try netbsd4DeployPlan().decode(DeployPlanPayload.self) + let profile = DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(payloadFamily: "netbsd4_samba4"), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + let warning = HostCompatibilityWarning(title: "macOS Warning", message: "Time Machine warning.") + + let presentation = DeployPlanPresentation(plan: plan, profile: profile, hostWarning: warning) + + XCTAssertEqual(presentation.title, "Install SMB and Start Runtime") + XCTAssertTrue(presentation.summaryRows.contains(PresentationRow(label: "Payload", value: "netbsd4_samba4"))) + XCTAssertTrue(presentation.advancedRows.contains(PresentationRow(label: "Activation Actions", value: "1"))) + XCTAssertEqual(presentation.warnings.count, 2) + } + + func testCheckupPresentationHeadlineFollowsState() throws { + let payload = try testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "ssh ok", domain: "Device"), + testDoctorCheck(status: "WARN", message: "bonjour missing", domain: "Finder") + ]).decode(DoctorPayload.self) + let summary = DoctorSummary(payload: payload) + + let presentation = CheckupPresentation(summary: summary, state: .warning) + + XCTAssertEqual(presentation.headline, "Checkup found warnings.") + XCTAssertEqual(presentation.summaryRows.first, PresentationRow(label: "Pass", value: "1")) + XCTAssertEqual(presentation.groups.first?.domain, "Finder") + } + + private func netbsd4DeployPlan() -> JSONValue { + .object([ + "schema_version": .number(1), + "host": .string("root@10.0.0.2"), + "volume_root": .string("/Volumes/dk2"), + "payload_dir": .string("/Volumes/dk2/.samba4"), + "payload_family": .string("netbsd4_samba4"), + "netbsd4": .bool(true), + "requires_reboot": .bool(false), + "reboot_required": .bool(false), + "uploads": .array([.object(["description": .string("smbd")])]), + "pre_upload_actions": .array([]), + "post_upload_actions": .array([]), + "activation_actions": .array([.object(["description": .string("start smbd")])]), + "post_deploy_checks": .array([]), + "summary": .string("deployment dry-run plan generated.") + ]) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift index 61f3063c..3a0a9d18 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DashboardStoreTests.swift @@ -189,6 +189,164 @@ final class DashboardStoreTests: XCTestCase { XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) } + func testAuthFailureMarksSavedPasswordInvalid() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "error", operation: "doctor", code: "auth_failed", message: "Password rejected.") + ], result: HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: "")) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("bad-password", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + + dashboard.runCheckup(profile: profile) + + try await waitUntilStoreState { dashboard.doctorStore.state == .runFailed } + XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .invalid) + XCTAssertEqual(fixture.appStore.dashboardSummary(for: fixture.registry.profile(id: profile.id)!).primaryAction, .replacePassword) + } + + func testRecoveryActionsRouteToMaintenanceAndPasswordWorkflows() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let error = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Needs recovery.") + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Run Disk Repair", kind: .diskRepair), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.selectedTab, .maintenance) + XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .fsck) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Repair File Metadata", kind: .metadataRepair), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .repairXattrs) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Start SMB", kind: .startSMB), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.maintenanceStore.selectedWorkflow, .activate) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Replace Password", kind: .replacePassword), + error: error, + profile: profile + )) + XCTAssertEqual(dashboard.selectedTab, .overview) + } + + func testRecoveryRunCheckupAndInstallActionsStartBackendOperations() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]), + .init(events: [ + BackendEvent(type: "result", operation: "deploy", ok: true, payload: testDeployPlanPayload()) + ]) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let error = BackendErrorViewModel(operation: "deploy", code: "operation_failed", message: "Needs recovery.") + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Run Checkup", kind: .runCheckup), + error: error, + profile: profile + )) + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(fixture.runner.calls[0].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(dashboard.selectedTab, .checkup) + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Install SMB", kind: .installSMB), + error: error, + profile: profile + )) + try await waitUntilStoreState { fixture.runner.calls.count == 2 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[1].operation, "deploy") + XCTAssertEqual(fixture.runner.calls[1].params["dry_run"], .bool(true)) + XCTAssertEqual(fixture.runner.calls[1].params["credentials"], .object(["password": .string("pw")])) + XCTAssertEqual(dashboard.selectedTab, .install) + } + + func testRecoveryRetryUsesFailedOperation() async throws { + let fixture = try makeFixture(responses: [ + .init(events: [ + BackendEvent(type: "result", operation: "doctor", ok: true, payload: testDoctorPayload(checks: [ + testDoctorCheck(status: "PASS", message: "smbd is running", domain: "Runtime") + ])) + ]) + ]) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + try fixture.passwordStore.save("pw", for: profile.keychainAccount) + let dashboard = DashboardStore(appStore: fixture.appStore) + let doctorError = BackendErrorViewModel(operation: "doctor", code: "operation_failed", message: "Doctor failed.") + + XCTAssertTrue(dashboard.handleRecoveryAction( + RecoveryAction(title: "Retry", kind: .retry), + error: doctorError, + profile: profile + )) + + try await waitUntilStoreState { fixture.runner.calls.count == 1 && !fixture.appStore.backend.isRunning } + XCTAssertEqual(fixture.runner.calls[0].operation, "doctor") + XCTAssertEqual(dashboard.selectedTab, .checkup) + } + + func testNonActionableRecoveryKindsReturnFalse() throws { + let fixture = try makeFixture(responses: []) + let profile = try fixture.registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let dashboard = DashboardStore(appStore: fixture.appStore) + let error = BackendErrorViewModel(operation: "validate-install", code: "operation_failed", message: "Needs diagnostics.") + + XCTAssertFalse(dashboard.handleRecoveryAction( + RecoveryAction(title: "Open Diagnostics", kind: .diagnostics), + error: error, + profile: profile + )) + XCTAssertFalse(dashboard.handleRecoveryAction( + RecoveryAction(title: "Unknown", kind: .generic), + error: error, + profile: profile + )) + } + func testForgetProfileDeletesRegistryConfigDirectoryAndPassword() throws { let fixture = try makeFixture(responses: []) let profile = try fixture.registry.saveConfiguredDevice( diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift index 2f278ecb..d7806443 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceProfileTests.swift @@ -55,6 +55,29 @@ final class DeviceProfileTests: XCTestCase { XCTAssertEqual(profile.runtimeContext.configURL.path, "/tmp/devices/abc/.env") } + func testTraitsClassifyNetBSD4NetBSD6AndUnsupportedDevices() { + let netbsd4 = makeProfile(payloadFamily: "netbsd4_samba4") + XCTAssertTrue(netbsd4.traits.isNetBSD4) + XCTAssertFalse(netbsd4.traits.isNetBSD6) + XCTAssertTrue(netbsd4.traits.needsActivationAfterReboot) + XCTAssertTrue(netbsd4.traits.supportsFlashBootHook) + XCTAssertTrue(netbsd4.traits.isSupported) + + let netbsd4ByRelease = makeProfile(osRelease: "4.0") + XCTAssertTrue(netbsd4ByRelease.traits.isNetBSD4) + XCTAssertTrue(netbsd4ByRelease.traits.supportsFlashBootHook) + + let netbsd6 = makeProfile(osRelease: "6.0") + XCTAssertFalse(netbsd6.traits.isNetBSD4) + XCTAssertTrue(netbsd6.traits.isNetBSD6) + XCTAssertFalse(netbsd6.traits.needsActivationAfterReboot) + XCTAssertFalse(netbsd6.traits.supportsFlashBootHook) + XCTAssertTrue(netbsd6.traits.isSupported) + + let unsupported = makeProfile(payloadFamily: "unsupported", deviceGeneration: "unsupported") + XCTAssertFalse(unsupported.traits.isSupported) + } + private func makeProfile( id: String = "profile", displayName: String = "Office Capsule", @@ -63,6 +86,9 @@ final class DeviceProfileTests: XCTestCase { bonjourFullname: String? = nil, syap: String? = nil, model: String? = nil, + osRelease: String? = nil, + payloadFamily: String? = nil, + deviceGeneration: String? = nil, configPath: String = "/tmp/profile/.env" ) -> DeviceProfile { DeviceProfile( @@ -76,11 +102,11 @@ final class DeviceProfileTests: XCTestCase { syap: syap, model: model, osName: nil, - osRelease: nil, + osRelease: osRelease, arch: nil, elfEndianness: nil, - payloadFamily: nil, - deviceGeneration: nil, + payloadFamily: payloadFamily, + deviceGeneration: deviceGeneration, configPath: configPath, keychainAccount: id, createdAt: Date(timeIntervalSince1970: 10), diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift index cd48a847..b382eb04 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceRegistryStoreTests.swift @@ -50,7 +50,7 @@ final class DeviceRegistryStoreTests: XCTestCase { profile.displayName = "Renamed Capsule" profile.settings.debugLogging = true - let updated = try store.save(profile) + let updated = try store.updateProfile(profile) XCTAssertEqual(updated.displayName, "Renamed Capsule") XCTAssertEqual(store.profiles.first?.settings.debugLogging, true) @@ -103,6 +103,123 @@ final class DeviceRegistryStoreTests: XCTestCase { XCTAssertEqual(store.profiles.count, 2) } + func testUpdateProfileDoesNotMergeDuplicateHostIntoAnotherProfile() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + let first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + + var conflictingUpdate = second + conflictingUpdate.host = " root@10.0.0.2. " + + XCTAssertThrowsError(try store.updateProfile(conflictingUpdate)) { error in + XCTAssertEqual( + error as? DeviceRegistryError, + .duplicateProfile(field: "host", value: "10.0.0.2", conflictingProfileID: first.id) + ) + } + XCTAssertEqual(store.profiles.count, 2) + XCTAssertEqual(store.profile(id: first.id)?.host, "10.0.0.2") + XCTAssertEqual(store.profile(id: second.id)?.host, "10.0.0.3") + } + + func testUpdateProfileRejectsDuplicateBonjourFullname() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + let first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: try discovered(record: testDeviceRecord(fullname: "Office._airport._tcp.local.")), + passwordState: .available, + preferredID: "device-one" + ) + var second = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: try discovered(record: testDeviceRecord( + hostname: "den.local.", + ipv4: ["10.0.0.3"], + fullname: "Den._airport._tcp.local." + )), + passwordState: .available, + preferredID: "device-two" + ) + + second.bonjourFullname = " office._AIRPORT._tcp.local. " + + XCTAssertThrowsError(try store.updateProfile(second)) { error in + XCTAssertEqual( + error as? DeviceRegistryError, + .duplicateProfile( + field: "Bonjour fullname", + value: "office._airport._tcp.local.", + conflictingProfileID: first.id + ) + ) + } + XCTAssertEqual(store.profiles.count, 2) + XCTAssertEqual(store.profile(id: second.id)?.bonjourFullname, "Den._airport._tcp.local.") + } + + func testUpdateProfileMissingIDFailsWithoutCreatingProfile() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url) + store.load() + var profile = DeviceProfile.make( + id: "missing", + configuredDevice: try testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + applicationSupportURL: temp.url, + date: Date(timeIntervalSince1970: 10) + ) + profile.displayName = "Unsaved" + + XCTAssertThrowsError(try store.updateProfile(profile)) { error in + XCTAssertEqual(error as? DeviceRegistryError, .profileNotFound("missing")) + } + XCTAssertEqual(store.state, .empty) + XCTAssertEqual(store.profiles, []) + } + + func testUpdateProfilePreservesOtherProfilesForLocalEdits() throws { + let temp = try TemporaryDirectory() + let store = DeviceRegistryStore(applicationSupportURL: temp.url, now: { + Date(timeIntervalSince1970: 100) + }) + store.load() + var first = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let second = try store.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.3"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-two" + ) + + first.displayName = "Office" + first.settings.mountWaitSeconds = 45 + let updated = try store.updateProfile(first) + + XCTAssertEqual(updated.displayName, "Office") + XCTAssertEqual(updated.settings.mountWaitSeconds, 45) + XCTAssertEqual(store.profile(id: second.id), second) + XCTAssertEqual(store.profiles.count, 2) + } + private func discovered(record: JSONValue) throws -> DiscoveredDevice { let resolved = try record.decode(BonjourResolvedServicePayload.self) return DiscoveredDevice(record: resolved, index: 0) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift new file mode 100644 index 00000000..165596d3 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift @@ -0,0 +1,157 @@ +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 testPasswordStatesTakePriority() throws { + let profile = try makeProfile() + + XCTAssertEqual(status(profile, .missing), .passwordNeeded) + XCTAssertEqual(status(profile, .unknown), .passwordNeeded) + XCTAssertEqual(status(profile, .invalid), .passwordInvalid) + XCTAssertEqual(status(profile, .keychainUnavailable), .keychainUnavailable) + } + + func testActiveOperationOverridesStoredHealth() throws { + let profile = try makeProfile(lastCheckup: passedCheckup(), lastDeploy: deployed()) + + XCTAssertEqual(status(profile, .available, operation: "doctor"), .checking) + XCTAssertEqual(status(profile, .available, operation: "deploy"), .installing) + XCTAssertEqual(status(profile, .available, operation: "fsck"), .maintaining) + } + + func testHealthStatusFallsBackThroughCheckupAndDeploySnapshots() throws { + XCTAssertEqual(status(try makeProfile(), .available), .unchecked) + XCTAssertEqual(status(try makeProfile(lastCheckup: passedCheckup()), .available), .readyToInstall) + XCTAssertEqual(status(try makeProfile(lastCheckup: passedCheckup(), lastDeploy: deployed()), .available), .healthy) + XCTAssertEqual(status(try makeProfile(lastCheckup: warningCheckup(), lastDeploy: deployed()), .available), .warning) + XCTAssertEqual(status(try makeProfile(lastCheckup: failedCheckup(), lastDeploy: deployed()), .available), .failed) + } + + func testNetBSD4WarningAfterDeployMapsToActivationNeeded() throws { + let profile = try makeProfile( + payloadFamily: "netbsd4_samba4", + lastCheckup: warningCheckup(), + lastDeploy: deployed() + ) + + XCTAssertEqual(status(profile, .available), .activationNeeded) + } + + func testPrimaryActionPolicyUsesStatus() throws { + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(), + passwordState: .missing, + activeOperation: nil + ), .replacePassword) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(), + passwordState: .available, + activeOperation: nil + ), .runCheckup) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(lastCheckup: passedCheckup()), + passwordState: .available, + activeOperation: nil + ), .installSMB) + XCTAssertEqual(DashboardPrimaryActionPolicy.primaryAction( + for: try makeProfile(lastCheckup: passedCheckup(), lastDeploy: deployed()), + passwordState: .available, + activeOperation: nil + ), .openSMB) + } + + private func status( + _ profile: DeviceProfile, + _ passwordState: DevicePasswordState, + operation: String? = nil + ) -> DeviceDisplayStatus { + DeviceStatusPolicy.status( + for: profile, + passwordState: passwordState, + activeOperation: operation.map { + ActiveOperation(operation: $0, profileID: profile.id, context: profile.runtimeContext) + } + ) + } + + private func makeProfile( + payloadFamily: String = "netbsd6_samba4", + lastCheckup: DeviceCheckupSnapshot? = nil, + lastDeploy: DeviceDeploySnapshot? = nil + ) throws -> DeviceProfile { + var profile = DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(payloadFamily: payloadFamily), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true), + date: Date(timeIntervalSince1970: 1) + ) + profile.lastCheckup = lastCheckup + profile.lastDeploy = lastDeploy + return profile + } + + private func passedCheckup() -> DeviceCheckupSnapshot { + DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 10), + state: .passed, + passCount: 3, + warnCount: 0, + failCount: 0, + summary: "healthy" + ) + } + + private func warningCheckup() -> DeviceCheckupSnapshot { + DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 10), + state: .warning, + passCount: 2, + warnCount: 1, + failCount: 0, + summary: "warning" + ) + } + + private func failedCheckup() -> DeviceCheckupSnapshot { + DeviceCheckupSnapshot( + checkedAt: Date(timeIntervalSince1970: 10), + state: .failed, + passCount: 1, + warnCount: 0, + failCount: 1, + summary: "failed" + ) + } + + private func deployed() -> DeviceDeploySnapshot { + DeviceDeploySnapshot( + deployedAt: Date(timeIntervalSince1970: 11), + state: .deployed, + payloadFamily: "netbsd6_samba4", + rebootRequested: true, + verified: true, + summary: "installed" + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift new file mode 100644 index 00000000..e1303d6e --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/FlashWorkflowStoreTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class FlashWorkflowStoreTests: XCTestCase { + func testStateInventoryIsExplicit() { + XCTAssertEqual(FlashWorkflowState.allCases, [ + .unavailable, + .disabledInThisBuild, + .eligibleForReadOnlyAnalysis, + .readingBanks, + .savingBackup, + .analyzingBanks, + .planAvailable, + .writeLocked, + .awaitingStrongConfirmation, + .writing, + .readbackValidating, + .writeValidated, + .manualPowerCycleRequired, + .restoreRebooting, + .failed + ]) + } + + func testReleaseDefaultDisablesFlashEvenForNetBSD4() throws { + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + let store = FlashWorkflowStore() + + store.refresh(profile: profile) + + XCTAssertEqual(store.state, .disabledInThisBuild) + XCTAssertTrue(store.eligibilityMessage.contains("disabled")) + } + + func testReadOnlyPolicyAllowsAnalysisButNotWrites() throws { + let profile = try makeProfile(payloadFamily: "netbsd4_samba4") + + let eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: .readOnly) + + XCTAssertEqual(eligibility.state, .eligibleForReadOnlyAnalysis) + XCTAssertTrue(eligibility.readOnlyAllowed) + XCTAssertFalse(eligibility.writeAllowed) + } + + func testNonNetBSD4DeviceIsUnavailable() throws { + let profile = try makeProfile(payloadFamily: "netbsd6_samba4") + + let eligibility = FlashEligibilityPolicy.eligibility(for: profile, buildPolicy: .writesEnabled) + + XCTAssertEqual(eligibility.state, .unavailable) + XCTAssertFalse(eligibility.readOnlyAllowed) + XCTAssertFalse(eligibility.writeAllowed) + } + + private func makeProfile(payloadFamily: String) throws -> DeviceProfile { + DeviceProfile.make( + id: "device-one", + configuredDevice: try testConfiguredDevice(payloadFamily: payloadFamily), + discoveredDevice: nil, + applicationSupportURL: URL(fileURLWithPath: "/tmp/timecapsulesmb-tests", isDirectory: true) + ) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift new file mode 100644 index 00000000..76fbe63f --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/OperationTimelineBuilderTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class OperationTimelineBuilderTests: XCTestCase { + func testBuildsUserFacingTimelineFromStagesResultsAndErrors() { + let events = [ + BackendEvent( + type: "stage", + operation: "deploy", + stage: "upload_payload", + risk: "remote_write", + cancellable: false, + description: "Upload managed Samba payload files." + ), + BackendEvent( + type: "error", + operation: "deploy", + code: "confirmation_required", + message: "Confirm deployment." + ), + BackendEvent( + type: "result", + operation: "deploy", + ok: true, + payload: .object(["summary": .string("deployment completed.")]) + ) + ] + + let timeline = OperationTimelineBuilder.timeline(from: events) + + XCTAssertEqual(timeline.map(\.title), ["Uploading", "Needs Confirmation", "Done"]) + XCTAssertEqual(timeline[0].risk, "remote_write") + XCTAssertEqual(timeline[0].cancellable, false) + XCTAssertEqual(timeline[1].state, .warning) + XCTAssertEqual(timeline[2].detail, "deployment completed.") + } + + func testOperationTitlesAreUserFacing() { + XCTAssertEqual(OperationTimelineBuilder.operationTitle("deploy"), "Install / Update") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("doctor"), "Checkup") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("repair-xattrs"), "File Metadata Repair") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("paths"), "App Readiness") + XCTAssertEqual(OperationTimelineBuilder.operationTitle("flash"), "Persistent NetBSD4 Boot Hook") + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift new file mode 100644 index 00000000..c8e9af92 --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +final class RecoveryActionMapperTests: XCTestCase { + func testAuthFailureStartsWithReplacePassword() { + let error = BackendErrorViewModel(operation: "doctor", code: "auth_failed", message: "Password rejected.") + + let actions = RecoveryActionMapper.actions(for: error) + + XCTAssertEqual(actions.first, RecoveryAction(title: "Replace Password", kind: .replacePassword)) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Copy Diagnostics", kind: .copyDiagnostics))) + } + + func testSuggestedOperationMapsToUserFacingAction() throws { + let recovery = try recoveryValue( + title: "Disk issue", + actions: ["Wake the disk by opening it in Finder.", "Retry deploy."], + suggestedOperation: "fsck" + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "Disk did not mount.", + recovery: recovery + ) + + let actions = RecoveryActionMapper.actions(for: error) + + XCTAssertTrue(actions.contains(RecoveryAction(title: "Run Disk Repair", kind: .diskRepair))) + XCTAssertTrue(actions.contains(where: { $0.kind == .openFinder })) + XCTAssertTrue(actions.contains(where: { $0.kind == .installSMB })) + } +} From 80650a856ea603d8eec8deb59f968e788e6fa5a1 Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 00:44:37 -0700 Subject: [PATCH 19/20] Address GUI PR feedback --- .../TimeCapsuleSMBApp/ActivityStore.swift | 5 +- .../TimeCapsuleSMBApp/AppReadinessStore.swift | 31 ++- .../TimeCapsuleSMBApp/BackendClient.swift | 4 + .../TimeCapsuleSMBApp/BackendPayloads.swift | 13 ++ .../TimeCapsuleSMBApp/ContentView.swift | 176 +++++++-------- .../DashboardPresentation.swift | 83 +++---- .../TimeCapsuleSMBApp/DashboardStore.swift | 26 ++- .../TimeCapsuleSMBApp/DeviceProfile.swift | 15 ++ .../DeviceStatusPolicy.swift | 30 +-- .../TimeCapsuleSMBApp/ErrorRecoveryView.swift | 2 + .../HostCompatibilityPolicy.swift | 41 +++- .../TimeCapsuleSMBApp/OperationTimeline.swift | 72 +++--- .../RecoveryActionMapper.swift | 109 ++++----- .../Resources/en.lproj/Localizable.strings | 213 ++++++++++++++++++ .../ActivityStoreTests.swift | 2 + .../AppReadinessStoreTests.swift | 12 + .../BackendClientTests.swift | 75 ++++++ .../DeviceStatusPolicyTests.swift | 40 ++++ .../RecoveryActionMapperTests.swift | 27 ++- .../StoreTestSupport.swift | 8 +- src/timecapsulesmb/app/ops/maintenance.py | 45 ++-- src/timecapsulesmb/app/recovery.py | 25 ++ tests/test_app_api.py | 25 +- 23 files changed, 789 insertions(+), 290 deletions(-) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift index 940db09d..76ad7441 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ActivityStore.swift @@ -20,7 +20,7 @@ final class ActivityStore: ObservableObject { @Published private(set) var snapshot = ActivitySnapshot( isRunning: false, scope: .unknown, - operationTitle: "No active operation", + operationTitle: L10n.string("activity.no_active_operation"), latestMessage: nil, timeline: [] ) @@ -86,7 +86,8 @@ final class ActivityStore: ObservableObject { snapshot = ActivitySnapshot( isRunning: coordinator.backend.isRunning, scope: scope, - operationTitle: operation.map(OperationTimelineBuilder.operationTitle) ?? (timeline.isEmpty ? "No active operation" : "Last operation"), + operationTitle: operation.map(OperationTimelineBuilder.operationTitle) + ?? (timeline.isEmpty ? L10n.string("activity.no_active_operation") : L10n.string("activity.last_operation")), latestMessage: latestMessage, timeline: timeline ) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift index fece908d..761ea59c 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AppReadinessStore.swift @@ -9,6 +9,25 @@ enum AppReadinessStateKind: String, CaseIterable, Equatable { case ready case degraded case blocked + + var title: String { + switch self { + case .idle: + return L10n.string("app_readiness.state.idle") + case .resolvingBundle: + return L10n.string("app_readiness.state.resolving_bundle") + case .checkingCapabilities: + return L10n.string("app_readiness.state.checking_capabilities") + case .validatingInstall: + return L10n.string("app_readiness.state.validating_install") + case .ready: + return L10n.string("app_readiness.state.ready") + case .degraded: + return L10n.string("app_readiness.state.degraded") + case .blocked: + return L10n.string("app_readiness.state.blocked") + } + } } struct AppReadinessSummary: Equatable { @@ -134,7 +153,7 @@ final class AppReadinessStore: ObservableObject { code: .helperMissing, severity: .error, message: error.localizedDescription, - recovery: "Reinstall TimeCapsuleSMB or choose a valid helper in Diagnostics." + recovery: L10n.string("app_readiness.recovery.helper_missing") )) return } @@ -205,7 +224,7 @@ final class AppReadinessStore: ObservableObject { code: .operationFailed, severity: .error, message: payload.summary, - recovery: "Open Diagnostics and retry app readiness." + recovery: L10n.string("app_readiness.recovery.retry_diagnostics") )) return } @@ -225,7 +244,7 @@ final class AppReadinessStore: ObservableObject { code: .installValidationFailed, severity: .error, message: payload.summary, - recovery: "Reinstall TimeCapsuleSMB or open Diagnostics for the failed checks." + recovery: L10n.string("app_readiness.recovery.install_validation_failed") )) return } @@ -272,7 +291,7 @@ final class AppReadinessStore: ObservableObject { code: code, severity: .error, message: event.message ?? event.summary, - recovery: BackendErrorViewModel(event: event).recovery?.message ?? "Open Diagnostics and retry app readiness." + recovery: BackendErrorViewModel(event: event).recovery?.message ?? L10n.string("app_readiness.recovery.retry_diagnostics") ) } @@ -280,8 +299,8 @@ final class AppReadinessStore: ObservableObject { BundleRuntimeIssue( code: .contractDecodeFailed, severity: .error, - message: "\(operation) returned an unexpected payload: \(error.localizedDescription)", - recovery: "Update or reinstall TimeCapsuleSMB so the app and helper use the same API contract." + message: L10n.format("app_readiness.error.unexpected_payload", operation, error.localizedDescription), + recovery: L10n.string("app_readiness.recovery.contract_mismatch") ) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift index 9dca361a..7f6618a5 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendClient.swift @@ -24,6 +24,10 @@ final class BackendClient: ObservableObject { self.helperPath = helperPath } + deinit { + runTask?.cancel() + } + func clear() { guard !isRunning else { return diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift index f6596ac5..3d617ccb 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/BackendPayloads.swift @@ -672,6 +672,7 @@ 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? @@ -680,8 +681,20 @@ struct BackendRecoveryPayload: Decodable, Equatable { case title case message case actions + case actionIDs = "action_ids" case retryable case suggestedOperation = "suggested_operation" case docsAnchor = "docs_anchor" } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.title = try container.decode(String.self, forKey: .title) + self.message = try container.decodeIfPresent(String.self, forKey: .message) + self.actions = try container.decodeIfPresent([String].self, forKey: .actions) ?? [] + self.actionIDs = try container.decodeIfPresent([String].self, forKey: .actionIDs) ?? [] + self.retryable = try container.decode(Bool.self, forKey: .retryable) + self.suggestedOperation = try container.decodeIfPresent(String.self, forKey: .suggestedOperation) + self.docsAnchor = try container.decodeIfPresent(String.self, forKey: .docsAnchor) + } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift index d2bedf19..26cd9376 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ContentView.swift @@ -48,12 +48,12 @@ public struct ContentView: View { Button { appStore.showAddDevice() } label: { - Label("Add", systemImage: "plus") + Label(L10n.string("toolbar.add"), systemImage: "plus") } Button { diagnosticsPresented = true } label: { - Label("Diagnostics", systemImage: "wrench.and.screwdriver") + Label(L10n.string("toolbar.diagnostics"), systemImage: "wrench.and.screwdriver") } Button { if let profile = appStore.selectedProfile { @@ -62,7 +62,7 @@ public struct ContentView: View { appStore.operationCoordinator.clear() } } label: { - Label(appStore.selectedProfile == nil ? L10n.string("toolbar.clear") : "Forget", systemImage: "trash") + Label(appStore.selectedProfile == nil ? L10n.string("toolbar.clear") : L10n.string("toolbar.forget"), systemImage: "trash") } .disabled(appStore.backend.isRunning) Button { @@ -93,11 +93,11 @@ public struct ContentView: View { ) } .confirmationDialog( - "Forget Time Capsule?", + L10n.string("dialog.forget.title"), isPresented: deleteConfirmationPresented, presenting: profilePendingDeletion ) { profile in - Button("Forget \(profile.title)", role: .destructive) { + Button(L10n.format("dialog.forget.action", profile.title), role: .destructive) { do { try appStore.forget(profile) profilePendingDeletion = nil @@ -109,10 +109,10 @@ public struct ContentView: View { profilePendingDeletion = nil } } message: { profile in - Text("Remove \(profile.title) from this Mac. This does not uninstall SMB from the Time Capsule.") + Text(L10n.format("dialog.forget.message", profile.title)) } - .alert("Could Not Forget Time Capsule", isPresented: deleteErrorPresented) { - Button("OK", role: .cancel) { + .alert(L10n.string("dialog.forget.error_title"), isPresented: deleteErrorPresented) { + Button(L10n.string("action.ok"), role: .cancel) { deleteErrorMessage = nil } } message: { @@ -197,10 +197,10 @@ public struct ContentView: View { private var sidebar: some View { List(selection: sidebarSelection) { - Label("All Time Capsules", systemImage: "externaldrive.connected.to.line.below") + Label(L10n.string("sidebar.all_time_capsules"), systemImage: "externaldrive.connected.to.line.below") .tag("all") - Section("Devices") { + Section(L10n.string("sidebar.devices")) { ForEach(appStore.deviceRegistry.profiles) { profile in DeviceSidebarRow( profile: profile, @@ -211,7 +211,7 @@ public struct ContentView: View { } Section { - Label("Add Time Capsule", systemImage: "plus.circle") + Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") .tag("add") } } @@ -244,15 +244,15 @@ private struct DeviceListOverviewView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - Text(appStore.deviceRegistry.profiles.isEmpty ? "No Time Capsules Saved" : "All Time Capsules") + Text(appStore.deviceRegistry.profiles.isEmpty ? L10n.string("overview.empty.title") : L10n.string("sidebar.all_time_capsules")) .font(.title2.weight(.semibold)) if appStore.deviceRegistry.profiles.isEmpty { - Text("Add a Time Capsule to configure SMB, run checkups, and manage maintenance tasks.") + Text(L10n.string("overview.empty.message")) .foregroundStyle(.secondary) Button { appStore.showAddDevice() } label: { - Label("Add Time Capsule", systemImage: "plus.circle") + Label(L10n.string("sidebar.add_time_capsule"), systemImage: "plus.circle") } } else { ForEach(appStore.deviceRegistry.profiles) { profile in @@ -290,10 +290,10 @@ private struct AddDeviceView: View { var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { - Text("Add Time Capsule") + Text(L10n.string("add_device.title")) .font(.title2.weight(.semibold)) Spacer() - Picker("Connection Method", selection: Binding( + Picker(L10n.string("add_device.connection_method"), selection: Binding( get: { store.entryMode }, set: { store.setEntryMode($0) } )) { @@ -307,7 +307,7 @@ private struct AddDeviceView: View { HStack { if store.entryMode == .discover { - Text(store.currentStage?.description ?? "Browse for AirPort Bonjour services") + Text(store.currentStage?.description ?? L10n.string("add_device.discover.placeholder")) .foregroundStyle(.secondary) Button { store.runDiscover() @@ -323,7 +323,7 @@ private struct AddDeviceView: View { if store.entryMode == .discover && !store.devices.isEmpty { VStack(alignment: .leading, spacing: 6) { - Text("Discovered Devices") + Text(L10n.string("add_device.discovered_devices")) .font(.headline) ForEach(store.devices) { device in Button { @@ -337,32 +337,32 @@ private struct AddDeviceView: View { } HStack { - TextField("Host or IP", text: Binding( + TextField(L10n.string("add_device.host_or_ip"), text: Binding( get: { store.hostFieldText }, set: { store.manualHost = $0 } )) .disabled(!store.isHostFieldEditable) - SecureField("Time Capsule password", text: $store.password) + SecureField(L10n.string("add_device.password"), text: $store.password) } HStack { Button { store.runConfigure() } label: { - Label("Save Device", systemImage: "checkmark.circle") + Label(L10n.string("add_device.save_device"), systemImage: "checkmark.circle") } .disabled(!store.canConfigure) Button { store.reset() } label: { - Label("Reset", systemImage: "arrow.counterclockwise") + Label(L10n.string("add_device.reset"), systemImage: "arrow.counterclockwise") } .disabled(store.isRunning) } if let profile = store.savedProfile { - Label("Saved \(profile.title)", systemImage: "checkmark.circle") + Label(L10n.format("add_device.saved", profile.title), systemImage: "checkmark.circle") .foregroundStyle(.green) } @@ -482,14 +482,14 @@ private struct OverviewTab: View { .font(.title2.weight(.semibold)) Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { - GridRow { Text("Status").foregroundStyle(.secondary); Text(summary.displayStatus.title) } - GridRow { Text("Host").foregroundStyle(.secondary); Text(profile.host) } - GridRow { Text("Model").foregroundStyle(.secondary); Text(profile.model ?? "Unknown") } - GridRow { Text("Generation").foregroundStyle(.secondary); Text(profile.deviceGeneration ?? "Unknown") } - GridRow { Text("Payload").foregroundStyle(.secondary); Text(profile.payloadFamily ?? "Unknown") } - GridRow { Text("Password").foregroundStyle(.secondary); Text(summary.passwordState.rawValue) } - GridRow { Text("Last Checkup").foregroundStyle(.secondary); Text(profile.lastCheckup?.summary ?? "Never") } - GridRow { Text("Last Install").foregroundStyle(.secondary); Text(profile.lastDeploy?.summary ?? "Never") } + GridRow { Text(L10n.string("dashboard.overview.status")).foregroundStyle(.secondary); Text(summary.displayStatus.title) } + GridRow { Text(L10n.string("dashboard.overview.host")).foregroundStyle(.secondary); Text(profile.host) } + GridRow { Text(L10n.string("dashboard.overview.model")).foregroundStyle(.secondary); Text(profile.model ?? L10n.string("value.unknown")) } + GridRow { Text(L10n.string("dashboard.overview.generation")).foregroundStyle(.secondary); Text(profile.deviceGeneration ?? L10n.string("value.unknown")) } + GridRow { Text(L10n.string("dashboard.overview.payload")).foregroundStyle(.secondary); Text(profile.payloadFamily ?? L10n.string("value.unknown")) } + GridRow { Text(L10n.string("dashboard.overview.password")).foregroundStyle(.secondary); Text(summary.passwordState.title) } + GridRow { Text(L10n.string("dashboard.overview.last_checkup")).foregroundStyle(.secondary); Text(profile.lastCheckup?.summary ?? L10n.string("value.never")) } + GridRow { Text(L10n.string("dashboard.overview.last_install")).foregroundStyle(.secondary); Text(profile.lastDeploy?.summary ?? L10n.string("value.never")) } } HStack { @@ -501,17 +501,17 @@ private struct OverviewTab: View { Button { dashboardStore.runCheckup(profile: profile) } label: { - Label("Run Checkup", systemImage: "stethoscope") + Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") } } HStack { - SecureField("Replacement password", text: $replacementPassword) + SecureField(L10n.string("dashboard.replacement_password"), text: $replacementPassword) Button { try? appStore.savePassword(replacementPassword, for: profile) replacementPassword = "" } label: { - Label("Save Password", systemImage: "key") + Label(L10n.string("dashboard.action.save_password"), systemImage: "key") } .disabled(replacementPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } @@ -526,17 +526,17 @@ private struct OverviewTab: View { private func primaryActionTitle(_ action: DashboardPrimaryAction) -> String { switch action { case .addDevice: - return "Add Time Capsule" + return L10n.string("sidebar.add_time_capsule") case .replacePassword: - return "Replace Password" + return L10n.string("dashboard.action.replace_password") case .runCheckup: - return "Run Checkup" + return L10n.string("dashboard.action.run_checkup") case .installSMB: - return "Install SMB" + return L10n.string("dashboard.action.install_smb") case .viewCheckup: - return "View Checkup" + return L10n.string("dashboard.action.view_checkup") case .openSMB: - return "Open SMB Address" + return L10n.string("dashboard.action.open_smb") } } @@ -576,7 +576,7 @@ private struct InstallTab: View { var body: some View { let store = dashboardStore.deployStore VStack(alignment: .leading, spacing: 12) { - Text("Install / Update") + Text(L10n.string("dashboard.tab.install")) .font(.title2.weight(.semibold)) HStack { Toggle(L10n.string("toggle.enable_nbns"), isOn: $dashboardStore.deployStore.nbnsEnabled) @@ -590,13 +590,13 @@ private struct InstallTab: View { Button { dashboardStore.runInstallPlan(profile: profile) } label: { - Label("Plan Install", systemImage: "doc.text.magnifyingglass") + Label(L10n.string("deploy.action.plan_install"), systemImage: "doc.text.magnifyingglass") } .disabled(store.isRunning || store.mountWaitValue == nil) Button { dashboardStore.runInstall(profile: profile) } label: { - Label("Install SMB", systemImage: "square.and.arrow.up") + Label(L10n.string("dashboard.action.install_smb"), systemImage: "square.and.arrow.up") } .disabled(!store.canDeploy) Label(store.state.title, systemImage: "circle") @@ -618,16 +618,16 @@ private struct InstallTab: View { .font(.caption) .foregroundStyle(.yellow) } - DisclosureGroup("Advanced Plan Details") { + DisclosureGroup(L10n.string("deploy.advanced_plan_details")) { SummaryGrid(rows: presentation.advancedRows.map { ($0.label, $0.value) }) .padding(.top, 6) } } if let result = store.result { SummaryGrid(rows: [ - ("Verified", result.verified == true ? "yes" : "no"), - ("Reboot Requested", result.rebootRequested == true ? "yes" : "no"), - ("Message", result.message ?? "Install completed.") + (L10n.string("deploy.result.verified"), result.verified == true ? L10n.string("value.yes") : L10n.string("value.no")), + (L10n.string("deploy.result.reboot_requested"), result.rebootRequested == true ? L10n.string("value.yes") : L10n.string("value.no")), + (L10n.string("deploy.result.message"), result.message ?? L10n.string("deploy.result.default_message")) ]) } if let error = store.error { @@ -655,7 +655,7 @@ private struct CheckupTab: View { var body: some View { let store = dashboardStore.doctorStore VStack(alignment: .leading, spacing: 12) { - Text("Checkup") + Text(L10n.string("dashboard.tab.checkup")) .font(.title2.weight(.semibold)) HStack { TextField(L10n.string("field.bonjour_timeout"), text: $dashboardStore.doctorStore.bonjourTimeout) @@ -663,7 +663,7 @@ private struct CheckupTab: View { Button { dashboardStore.runCheckup(profile: profile) } label: { - Label("Run Checkup", systemImage: "stethoscope") + Label(L10n.string("dashboard.action.run_checkup"), systemImage: "stethoscope") } .disabled(store.isRunning || store.bonjourTimeoutValue == nil) Label(store.state.title, systemImage: "circle") @@ -717,13 +717,13 @@ private struct MaintenanceTab: View { let store = dashboardStore.maintenanceStore let presentation = MaintenanceWorkflowPresentation.presentation(for: store.selectedWorkflow) VStack(alignment: .leading, spacing: 12) { - Text("Maintenance") + Text(L10n.string("dashboard.tab.maintenance")) .font(.title2.weight(.semibold)) - Picker("Maintenance", selection: $dashboardStore.maintenanceStore.selectedWorkflow) { - Text("NetBSD4 Activation").tag(MaintenanceWorkflow.activate) - Text("Uninstall").tag(MaintenanceWorkflow.uninstall) - Text("Disk Repair").tag(MaintenanceWorkflow.fsck) - Text("File Metadata Repair").tag(MaintenanceWorkflow.repairXattrs) + Picker(L10n.string("dashboard.tab.maintenance"), selection: $dashboardStore.maintenanceStore.selectedWorkflow) { + Text(L10n.string("maintenance.workflow.activate")).tag(MaintenanceWorkflow.activate) + Text(L10n.string("maintenance.workflow.uninstall")).tag(MaintenanceWorkflow.uninstall) + Text(L10n.string("maintenance.workflow.fsck")).tag(MaintenanceWorkflow.fsck) + Text(L10n.string("maintenance.workflow.repair_xattrs")).tag(MaintenanceWorkflow.repairXattrs) } .pickerStyle(.segmented) @@ -772,12 +772,12 @@ private struct MaintenanceTab: View { switch store.selectedWorkflow { case .activate: HStack { - Button("Plan Start SMB") { + Button(L10n.string("maintenance.action.plan_start_smb")) { if let password = dashboardStore.maintenancePassword(for: profile) { store.planActivation(password: password, profile: profile) } } - Button("Start SMB") { + Button(L10n.string("maintenance.action.start_smb")) { if let password = dashboardStore.maintenancePassword(for: profile) { store.runActivation(password: password, profile: profile) } @@ -787,12 +787,12 @@ private struct MaintenanceTab: View { } case .uninstall: HStack { - Button("Plan Uninstall") { + Button(L10n.string("maintenance.action.plan_uninstall")) { if let password = dashboardStore.maintenancePassword(for: profile) { store.planUninstall(password: password, profile: profile) } } - Button("Uninstall") { + Button(L10n.string("maintenance.action.uninstall")) { if let password = dashboardStore.maintenancePassword(for: profile) { store.runUninstall(password: password, profile: profile) } @@ -803,18 +803,18 @@ private struct MaintenanceTab: View { case .fsck: VStack(alignment: .leading, spacing: 8) { HStack { - Button("Find Volumes") { + Button(L10n.string("maintenance.action.find_volumes")) { if let password = dashboardStore.maintenancePassword(for: profile) { store.refreshFsckTargets(password: password, profile: profile) } } - Button("Plan Disk Repair") { + Button(L10n.string("maintenance.action.plan_disk_repair")) { if let password = dashboardStore.maintenancePassword(for: profile) { store.planFsck(password: password, profile: profile) } } .disabled(!store.canPlanFsck) - Button("Run Disk Repair") { + Button(L10n.string("maintenance.action.run_disk_repair")) { if let password = dashboardStore.maintenancePassword(for: profile) { store.runFsck(password: password, profile: profile) } @@ -842,21 +842,21 @@ private struct MaintenanceTab: View { Button { chooseRepairPath(store: store) } label: { - Label("Choose Folder", systemImage: "folder") + Label(L10n.string("maintenance.action.choose_folder"), systemImage: "folder") } } HStack { - Button("Scan Metadata") { + Button(L10n.string("maintenance.action.scan_metadata")) { store.scanRepairXattrs() } - Button("Repair Metadata") { + Button(L10n.string("maintenance.action.repair_metadata")) { store.runRepairXattrs() } .disabled(!store.canRepairXattrs) Label(store.repairState.title, systemImage: "circle") } if let scan = store.repairScan { - Text("\(scan.repairableCount) repairable item(s)") + Text(L10n.format("maintenance.repairable_count", scan.repairableCount)) .foregroundStyle(.secondary) } } @@ -868,7 +868,7 @@ private struct MaintenanceTab: View { panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false - panel.prompt = "Choose" + panel.prompt = L10n.string("maintenance.action.choose") if panel.runModal() == .OK, let url = panel.url { store.repairPath = url.path } @@ -881,12 +881,12 @@ private struct AdvancedTab: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("Advanced") + Text(L10n.string("dashboard.tab.advanced")) .font(.title2.weight(.semibold)) SummaryGrid(rows: [ - ("Profile ID", profile.id), - ("Config", profile.configPath), - ("Helper", appStore.backend.helperPath.isEmpty ? "Auto" : appStore.backend.helperPath) + (L10n.string("advanced.profile_id"), profile.id), + (L10n.string("advanced.config"), profile.configPath), + (L10n.string("advanced.helper"), appStore.backend.helperPath.isEmpty ? L10n.string("value.auto") : appStore.backend.helperPath) ]) EventList(events: appStore.backend.events) } @@ -921,10 +921,10 @@ private struct AppReadinessBannerView: View { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.yellow) - Text(issues.first?.message ?? "TimeCapsuleSMB is running with warnings.") + Text(issues.first?.message ?? L10n.string("readiness.warning.default")) .font(.caption) Spacer() - Button("Diagnostics", action: showDiagnostics) + Button(L10n.string("toolbar.diagnostics"), action: showDiagnostics) } .padding(.horizontal) .padding(.vertical, 8) @@ -937,11 +937,11 @@ private struct AppReadinessBannerView: View { private var title: String { switch store.state.kind { case .resolvingBundle: - return "Preparing app runtime" + return L10n.string("readiness.state.resolving_bundle") case .checkingCapabilities: - return "Checking helper" + return L10n.string("readiness.state.checking_capabilities") case .validatingInstall: - return "Validating bundled files" + return L10n.string("readiness.state.validating_install") default: return "" } @@ -954,7 +954,7 @@ private struct AppReadinessBlockedView: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - Label("TimeCapsuleSMB cannot start", systemImage: "exclamationmark.octagon") + Label(L10n.string("readiness.blocked.title"), systemImage: "exclamationmark.octagon") .font(.title2.weight(.semibold)) .foregroundStyle(.red) if case .blocked(let issue) = store.state { @@ -966,14 +966,14 @@ private struct AppReadinessBlockedView: View { Button { store.start() } label: { - Label("Retry", systemImage: "arrow.clockwise") + Label(L10n.string("recovery.action.retry"), systemImage: "arrow.clockwise") } .disabled(!store.canRetry) Button { showDiagnostics() } label: { - Label("Diagnostics", systemImage: "wrench.and.screwdriver") + Label(L10n.string("toolbar.diagnostics"), systemImage: "wrench.and.screwdriver") } } } @@ -991,10 +991,10 @@ private struct AppDiagnosticsView: View { var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { - Text("Diagnostics") + Text(L10n.string("diagnostics.title")) .font(.title2.weight(.semibold)) Spacer() - Button("Done") { + Button(L10n.string("action.done")) { dismiss() } .keyboardShortcut(.defaultAction) @@ -1004,16 +1004,16 @@ private struct AppDiagnosticsView: View { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { GridRow { - Text("State").foregroundStyle(.secondary) - Text(store.state.kind.rawValue) + Text(L10n.string("diagnostics.state")).foregroundStyle(.secondary) + Text(store.state.kind.title) } if let capabilities = store.capabilities { GridRow { - Text("Helper").foregroundStyle(.secondary) + Text(L10n.string("diagnostics.helper")).foregroundStyle(.secondary) Text(capabilities.helperVersion) } GridRow { - Text("Distribution").foregroundStyle(.secondary) + Text(L10n.string("diagnostics.distribution")).foregroundStyle(.secondary) Text(capabilities.distributionRoot) .lineLimit(1) .truncationMode(.middle) @@ -1021,7 +1021,7 @@ private struct AppDiagnosticsView: View { } if let validation = store.validation { GridRow { - Text("Validation").foregroundStyle(.secondary) + Text(L10n.string("diagnostics.validation")).foregroundStyle(.secondary) Text(validation.summary) } } @@ -1030,7 +1030,7 @@ private struct AppDiagnosticsView: View { if !store.issues.isEmpty { VStack(alignment: .leading, spacing: 6) { - Text("Runtime Issues") + Text(L10n.string("diagnostics.runtime_issues")) .font(.headline) ForEach(store.issues) { issue in VStack(alignment: .leading, spacing: 2) { @@ -1043,7 +1043,7 @@ private struct AppDiagnosticsView: View { } } - Text("Backend Events") + Text(L10n.string("diagnostics.backend_events")) .font(.headline) EventList(events: events) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift index da941e35..5fc44861 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardPresentation.swift @@ -16,25 +16,30 @@ struct DeployPlanPresentation: Equatable { let warnings: [String] init(plan: DeployPlanPayload, profile: DeviceProfile, hostWarning: HostCompatibilityWarning? = nil) { - self.title = plan.netbsd4 ? "Install SMB and Start Runtime" : "Install SMB" + self.title = plan.netbsd4 + ? L10n.string("deploy.presentation.title.netbsd4") + : L10n.string("deploy.presentation.title.standard") self.summaryRows = [ - PresentationRow(label: "Target", value: profile.title), - PresentationRow(label: "Host", value: plan.host), - PresentationRow(label: "Payload", value: plan.payloadFamily ?? profile.payloadFamily ?? "Unknown"), - PresentationRow(label: "Disk Location", value: plan.volumeRoot ?? plan.payloadDir), - PresentationRow(label: "Reboot", value: plan.requiresReboot ? "Required" : "Not required"), - PresentationRow(label: "Expected Changes", value: "\(plan.uploads.count) file upload(s), \(plan.postUploadActions.count) install action(s)") + PresentationRow(label: L10n.string("deploy.presentation.row.target"), value: profile.title), + PresentationRow(label: L10n.string("deploy.presentation.row.host"), value: plan.host), + PresentationRow(label: L10n.string("deploy.presentation.row.payload"), value: plan.payloadFamily ?? profile.payloadFamily ?? L10n.string("value.unknown")), + PresentationRow(label: L10n.string("deploy.presentation.row.disk_location"), value: plan.volumeRoot ?? plan.payloadDir), + PresentationRow(label: L10n.string("deploy.presentation.row.reboot"), value: plan.requiresReboot ? L10n.string("value.required") : L10n.string("value.not_required")), + PresentationRow( + label: L10n.string("deploy.presentation.row.expected_changes"), + value: L10n.format("deploy.presentation.expected_changes", plan.uploads.count, plan.postUploadActions.count) + ) ] self.advancedRows = [ - PresentationRow(label: "Payload Directory", value: plan.payloadDir), - PresentationRow(label: "Pre-upload Actions", value: "\(plan.preUploadActions.count)"), - PresentationRow(label: "Post-upload Actions", value: "\(plan.postUploadActions.count)"), - PresentationRow(label: "Activation Actions", value: "\(plan.activationActions.count)"), - PresentationRow(label: "Post-install Checks", value: plan.postDeployChecks.map(\.description).joined(separator: ", ")) + PresentationRow(label: L10n.string("deploy.presentation.row.payload_directory"), value: plan.payloadDir), + PresentationRow(label: L10n.string("deploy.presentation.row.pre_upload_actions"), value: "\(plan.preUploadActions.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.post_upload_actions"), value: "\(plan.postUploadActions.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.activation_actions"), value: "\(plan.activationActions.count)"), + PresentationRow(label: L10n.string("deploy.presentation.row.post_install_checks"), value: plan.postDeployChecks.map(\.description).joined(separator: ", ")) ] var warnings: [String] = [] if plan.netbsd4 { - warnings.append("This NetBSD4 device may need Start SMB after future reboots unless the boot hook is patched.") + warnings.append(L10n.string("deploy.presentation.warning.netbsd4_activation")) } if let hostWarning { warnings.append(hostWarning.message) @@ -51,23 +56,23 @@ struct CheckupPresentation: Equatable { init(summary: DoctorSummary, state: DoctorWorkflowState) { switch state { case .passed: - self.headline = "SMB looks healthy." + self.headline = L10n.string("checkup.presentation.headline.passed") case .warning: - self.headline = "Checkup found warnings." + self.headline = L10n.string("checkup.presentation.headline.warning") case .failed: - self.headline = "Checkup found failures." + self.headline = L10n.string("checkup.presentation.headline.failed") case .runFailed: - self.headline = "Checkup could not finish." + self.headline = L10n.string("checkup.presentation.headline.run_failed") case .idle: - self.headline = "Run a checkup to verify this Time Capsule." + self.headline = L10n.string("checkup.presentation.headline.idle") case .running: - self.headline = "Running checkup..." + self.headline = L10n.string("checkup.presentation.headline.running") } self.summaryRows = [ - PresentationRow(label: "Pass", value: "\(summary.passCount)"), - PresentationRow(label: "Warning", value: "\(summary.warnCount)"), - PresentationRow(label: "Fail", value: "\(summary.failCount)"), - PresentationRow(label: "Info", value: "\(summary.infoCount)") + PresentationRow(label: L10n.string("checkup.presentation.row.pass"), value: "\(summary.passCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.warning"), value: "\(summary.warnCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.fail"), value: "\(summary.failCount)"), + PresentationRow(label: L10n.string("checkup.presentation.row.info"), value: "\(summary.infoCount)") ] self.groups = summary.groups } @@ -83,31 +88,31 @@ struct MaintenanceWorkflowPresentation: Equatable { switch workflow { case .activate: return MaintenanceWorkflowPresentation( - title: "NetBSD4 Activation", - subtitle: "Start the deployed SMB runtime on a NetBSD4 Time Capsule.", - primaryAction: "Start SMB", - risk: "Remote write" + title: L10n.string("maintenance.presentation.activate.title"), + subtitle: L10n.string("maintenance.presentation.activate.subtitle"), + primaryAction: L10n.string("maintenance.presentation.activate.primary_action"), + risk: L10n.string("maintenance.presentation.risk.remote_write") ) case .uninstall: return MaintenanceWorkflowPresentation( - title: "Uninstall", - subtitle: "Remove managed SMB files from the selected Time Capsule.", - primaryAction: "Uninstall", - risk: "Destructive" + title: L10n.string("maintenance.presentation.uninstall.title"), + subtitle: L10n.string("maintenance.presentation.uninstall.subtitle"), + primaryAction: L10n.string("maintenance.presentation.uninstall.primary_action"), + risk: L10n.string("maintenance.presentation.risk.destructive") ) case .fsck: return MaintenanceWorkflowPresentation( - title: "Disk Repair", - subtitle: "Unmount a selected HFS volume and run fsck_hfs on the device.", - primaryAction: "Run Disk Repair", - risk: "Destructive" + title: L10n.string("maintenance.presentation.fsck.title"), + subtitle: L10n.string("maintenance.presentation.fsck.subtitle"), + primaryAction: L10n.string("maintenance.presentation.fsck.primary_action"), + risk: L10n.string("maintenance.presentation.risk.destructive") ) case .repairXattrs: return MaintenanceWorkflowPresentation( - title: "File Metadata Repair", - subtitle: "Scan and repair macOS metadata on a mounted SMB share.", - primaryAction: "Repair Metadata", - risk: "Local destructive" + title: L10n.string("maintenance.presentation.repair_xattrs.title"), + subtitle: L10n.string("maintenance.presentation.repair_xattrs.subtitle"), + primaryAction: L10n.string("maintenance.presentation.repair_xattrs.primary_action"), + risk: L10n.string("maintenance.presentation.risk.local_destructive") ) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift index 249d5556..333b9f1a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DashboardStore.swift @@ -16,15 +16,15 @@ enum DeviceDashboardTab: String, CaseIterable, Equatable, Identifiable { var title: String { switch self { case .overview: - return "Overview" + return L10n.string("dashboard.tab.overview") case .install: - return "Install / Update" + return L10n.string("dashboard.tab.install") case .checkup: - return "Checkup" + return L10n.string("dashboard.tab.checkup") case .maintenance: - return "Maintenance" + return L10n.string("dashboard.tab.maintenance") case .advanced: - return "Advanced" + return L10n.string("dashboard.tab.advanced") } } } @@ -58,7 +58,7 @@ final class DashboardStore: ObservableObject { func runCheckup(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { - passwordError = "Password is required." + passwordError = L10n.string("password.error.required") return } passwordError = nil @@ -70,7 +70,7 @@ final class DashboardStore: ObservableObject { func runInstallPlan(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { - passwordError = "Password is required." + passwordError = L10n.string("password.error.required") return } passwordError = nil @@ -83,7 +83,7 @@ final class DashboardStore: ObservableObject { func runInstall(profile: DeviceProfile) { guard let password = appStore.password(for: profile) else { - passwordError = "Password is required." + passwordError = L10n.string("password.error.required") return } passwordError = nil @@ -95,7 +95,7 @@ final class DashboardStore: ObservableObject { func maintenancePassword(for profile: DeviceProfile) -> String? { guard let password = appStore.password(for: profile) else { - passwordError = "Password is required." + passwordError = L10n.string("password.error.required") return nil } passwordError = nil @@ -118,6 +118,10 @@ final class DashboardStore: ObservableObject { selectedTab = .maintenance maintenanceStore.selectedWorkflow = .activate return true + case .uninstall: + selectedTab = .maintenance + maintenanceStore.selectedWorkflow = .uninstall + return true case .diskRepair: selectedTab = .maintenance maintenanceStore.selectedWorkflow = .fsck @@ -255,7 +259,7 @@ final class DashboardStore: ObservableObject { passCount: summary.passCount, warnCount: summary.warnCount, failCount: summary.failCount, - summary: "PASS \(summary.passCount), WARN \(summary.warnCount), FAIL \(summary.failCount)" + summary: L10n.format("summary.checkup_counts", summary.passCount, summary.warnCount, summary.failCount) ), for: profileID) } @@ -278,7 +282,7 @@ final class DashboardStore: ObservableObject { payloadFamily: deployStore.plan?.payloadFamily ?? profile.payloadFamily, rebootRequested: result.rebootRequested, verified: result.verified, - summary: result.message ?? "Install completed." + summary: result.message ?? L10n.string("deploy.result.default_message") ), for: profile.id) } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift index 062f80b0..34f63ed7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceProfile.swift @@ -16,6 +16,21 @@ enum DevicePasswordState: String, Codable, CaseIterable, Equatable { 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 { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift index e90bb284..65000438 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceStatusPolicy.swift @@ -22,35 +22,35 @@ enum DeviceDisplayStatus: String, CaseIterable, Equatable, Identifiable { var title: String { switch self { case .unchecked: - return "Unchecked" + return L10n.string("status.unchecked") case .passwordNeeded: - return "Password Needed" + return L10n.string("status.password_needed") case .passwordInvalid: - return "Password Invalid" + return L10n.string("status.password_invalid") case .keychainUnavailable: - return "Keychain Unavailable" + return L10n.string("status.keychain_unavailable") case .checking: - return "Checking" + return L10n.string("status.checking") case .installing: - return "Installing" + return L10n.string("status.installing") case .maintaining: - return "Maintenance" + return L10n.string("status.maintenance") case .readyToInstall: - return "Ready to Install" + return L10n.string("status.ready_to_install") case .healthy: - return "Healthy" + return L10n.string("status.healthy") case .warning: - return "Warning" + return L10n.string("status.warning") case .failed: - return "Failed" + return L10n.string("status.failed") case .activationNeeded: - return "Activation Needed" + return L10n.string("status.activation_needed") case .removed: - return "Removed" + return L10n.string("status.removed") case .offline: - return "Offline" + return L10n.string("status.offline") case .unsupported: - return "Unsupported" + return L10n.string("status.unsupported") } } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift index 8a6aa52d..60db485e 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ErrorRecoveryView.swift @@ -62,6 +62,8 @@ struct ErrorRecoveryView: View { return "square.and.arrow.up" case .startSMB: return "play.circle" + case .uninstall: + return "trash" case .diskRepair: return "externaldrive.badge.exclamationmark" case .metadataRepair: diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift index de27a56e..5c96dea1 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HostCompatibilityPolicy.swift @@ -5,24 +5,45 @@ struct HostCompatibilityWarning: Equatable { let message: String } +private struct KnownHostCompatibilityIssue { + let majorVersion: Int + let minorVersion: Int + let patchVersions: Set? + + func matches(_ version: OperatingSystemVersion) -> Bool { + guard version.majorVersion == majorVersion, version.minorVersion == minorVersion else { + return false + } + guard let patchVersions else { + return true + } + return patchVersions.contains(version.patchVersion) + } +} + enum HostCompatibilityPolicy { + // Product guidance tracks macOS 26.4.x separately from the 15.7 patch band. + private static let knownTimeMachineIssues = [ + KnownHostCompatibilityIssue(majorVersion: 15, minorVersion: 7, patchVersions: [5, 6, 7]), + KnownHostCompatibilityIssue(majorVersion: 26, minorVersion: 4, patchVersions: nil) + ] + static func warning(for version: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion) -> HostCompatibilityWarning? { - guard version.majorVersion == 15 || version.majorVersion == 26 else { + guard knownTimeMachineIssues.contains(where: { $0.matches(version) }) else { return nil } - if version.majorVersion == 15 && version.minorVersion == 7 && [5, 6, 7].contains(version.patchVersion) { - return timeMachineWarning(version: version) - } - if version.majorVersion == 26 && version.minorVersion == 4 { - return timeMachineWarning(version: version) - } - return nil + return timeMachineWarning(version: version) } private static func timeMachineWarning(version: OperatingSystemVersion) -> HostCompatibilityWarning { HostCompatibilityWarning( - title: "macOS Time Machine Warning", - message: "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion) has known Time Machine network backup issues. SMB may work, but backup reliability can be affected by the host OS." + 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/OperationTimeline.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift index e57d03f3..75de9a6d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationTimeline.swift @@ -36,7 +36,7 @@ enum OperationTimelineBuilder { return OperationTimelineItem( id: "\(index):\(event.operation):result", operation: event.operation, - title: event.ok == true ? "Done" : "Failed", + title: event.ok == true ? L10n.string("timeline.result.done") : L10n.string("timeline.result.failed"), detail: event.payloadSummaryText ?? event.summary, state: event.ok == true ? .succeeded : .failed, risk: nil, @@ -46,7 +46,9 @@ enum OperationTimelineBuilder { return OperationTimelineItem( id: "\(index):\(event.operation):error", operation: event.operation, - title: event.code == "confirmation_required" ? "Needs Confirmation" : "Needs Attention", + 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, @@ -61,25 +63,25 @@ enum OperationTimelineBuilder { static func operationTitle(_ operation: String) -> String { switch operation { case "discover": - return "Discovery" + return L10n.string("timeline.operation.discovery") case "configure": - return "Add Time Capsule" + return L10n.string("timeline.operation.configure") case "deploy": - return "Install / Update" + return L10n.string("timeline.operation.deploy") case "doctor": - return "Checkup" + return L10n.string("timeline.operation.doctor") case "activate": - return "Start SMB" + return L10n.string("timeline.operation.activate") case "fsck": - return "Disk Repair" + return L10n.string("timeline.operation.fsck") case "repair-xattrs": - return "File Metadata Repair" + return L10n.string("timeline.operation.repair_xattrs") case "uninstall": - return "Uninstall" + return L10n.string("timeline.operation.uninstall") case "capabilities", "validate-install", "paths": - return "App Readiness" + return L10n.string("timeline.operation.readiness") case "flash": - return "Persistent NetBSD4 Boot Hook" + return L10n.string("timeline.operation.flash") default: return operation } @@ -91,51 +93,51 @@ enum OperationTimelineBuilder { } switch (operation, stage) { case ("discover", "bonjour_discovery"): - return "Finding Time Capsules" + return L10n.string("timeline.stage.finding_time_capsules") case ("configure", "ssh_probe"), ("configure", "ssh_probe_after_acp"): - return "Checking SSH" + return L10n.string("timeline.stage.checking_ssh") case ("configure", "acp_enable_ssh"): - return "Enabling SSH" + return L10n.string("timeline.stage.enabling_ssh") case ("configure", "wait_for_ssh_after_acp"): - return "Waiting for Device" + return L10n.string("timeline.stage.waiting_for_device") case ("configure", "write_env"): - return "Saving Device" + return L10n.string("timeline.stage.saving_device") case ("deploy", "build_deployment_plan"): - return "Planning Install" + return L10n.string("timeline.stage.planning_install") case ("deploy", "validate_artifacts"): - return "Checking Bundled Files" + return L10n.string("timeline.stage.checking_bundled_files") case ("deploy", "read_mast"), ("deploy", "select_payload_home"): - return "Finding Disk" + return L10n.string("timeline.stage.finding_disk") case ("deploy", "upload_payload"): - return "Uploading" + return L10n.string("timeline.stage.uploading") case ("deploy", "flush_payload_upload"): - return "Syncing to Disk" + return L10n.string("timeline.stage.syncing_to_disk") case ("deploy", "reboot"), ("deploy", "wait_for_reboot_down"), ("deploy", "wait_for_reboot_up"): - return "Rebooting" + return L10n.string("timeline.stage.rebooting") case ("deploy", "netbsd4_activation"): - return "Starting SMB" + return L10n.string("timeline.stage.starting_smb") case ("deploy", "verify_runtime_activation"), ("deploy", "verify_runtime_reboot"): - return "Verifying SMB" + return L10n.string("timeline.stage.verifying_smb") case ("doctor", "run_checks"): - return "Running Checkup" + return L10n.string("timeline.stage.running_checkup") case ("activate", "build_activation_plan"): - return "Planning Start SMB" + return L10n.string("timeline.stage.planning_start_smb") case ("activate", "run_activation"): - return "Starting SMB" + return L10n.string("timeline.stage.starting_smb") case ("uninstall", "build_uninstall_plan"): - return "Planning Uninstall" + return L10n.string("timeline.stage.planning_uninstall") case ("uninstall", "uninstall_payload"): - return "Removing Managed Files" + return L10n.string("timeline.stage.removing_managed_files") case ("fsck", "read_mast"), ("fsck", "select_fsck_volume"): - return "Finding Volumes" + return L10n.string("timeline.stage.finding_volumes") case ("fsck", "run_fsck"): - return "Repairing Disk" + return L10n.string("timeline.stage.repairing_disk") case ("repair-xattrs", "scan_findings"): - return "Scanning Metadata" + return L10n.string("timeline.stage.scanning_metadata") case ("repair-xattrs", "repair_findings"): - return "Repairing Metadata" + return L10n.string("timeline.stage.repairing_metadata") case ("validate-install", "validate_install"): - return "Validating App Bundle" + return L10n.string("timeline.stage.validating_app_bundle") default: return stage .split(separator: "_") diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift index 54dbf56a..e637b56a 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/RecoveryActionMapper.swift @@ -2,15 +2,16 @@ import Foundation enum RecoveryActionKind: String, Equatable { case retry - case runCheckup - case installSMB - case startSMB - case diskRepair - case metadataRepair - case openFinder - case replacePassword - case copyDiagnostics - case diagnostics + 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 } @@ -27,73 +28,79 @@ enum RecoveryActionMapper { static func actions(for error: BackendErrorViewModel) -> [RecoveryAction] { var actions: [RecoveryAction] = [] if error.code == "auth_failed" { - actions.append(RecoveryAction(title: "Replace Password", kind: .replacePassword)) + actions.append(action(for: .replacePassword)) } - if let suggested = error.recovery?.suggestedOperation { - actions.append(action(forSuggestedOperation: suggested)) + for actionID in error.recovery?.actionIDs ?? [] { + guard let kind = RecoveryActionKind(rawValue: actionID), kind != .generic else { + continue + } + actions.append(action(for: kind)) } - for title in error.recovery?.actions ?? [] { - actions.append(RecoveryAction(title: title, kind: inferKind(from: title))) + if let suggested = error.recovery?.suggestedOperation { + actions.append(action(forSuggestedOperation: suggested)) } if error.recovery?.retryable == true || error.code == "operation_failed" { - actions.append(RecoveryAction(title: "Retry", kind: .retry)) + actions.append(action(for: .retry)) } - actions.append(RecoveryAction(title: "Copy Diagnostics", kind: .copyDiagnostics)) + actions.append(action(for: .copyDiagnostics)) return deduplicated(actions) } private static func action(forSuggestedOperation operation: String) -> RecoveryAction { switch operation { case "doctor": - return RecoveryAction(title: "Run Checkup", kind: .runCheckup) + return action(for: .runCheckup) case "deploy": - return RecoveryAction(title: "Install SMB", kind: .installSMB) + return action(for: .installSMB) case "activate": - return RecoveryAction(title: "Start SMB", kind: .startSMB) + return action(for: .startSMB) + case "uninstall": + return action(for: .uninstall) case "fsck": - return RecoveryAction(title: "Run Disk Repair", kind: .diskRepair) + return action(for: .diskRepair) case "repair-xattrs": - return RecoveryAction(title: "Repair File Metadata", kind: .metadataRepair) + return action(for: .metadataRepair) case "validate-install": - return RecoveryAction(title: "Open Diagnostics", kind: .diagnostics) + return action(for: .diagnostics) default: return RecoveryAction(title: operation, kind: .generic) } } - private static func inferKind(from title: String) -> RecoveryActionKind { - let lower = title.lowercased() - if lower.contains("password") { - return .replacePassword - } - if lower.contains("checkup") || lower.contains("doctor") { - return .runCheckup - } - if lower.contains("deploy") || lower.contains("install") { - return .installSMB - } - if lower.contains("activate") || lower.contains("start smb") { - return .startSMB - } - if lower.contains("finder") || lower.contains("smb://") { - return .openFinder - } - if lower.contains("fsck") || lower.contains("disk") { - return .diskRepair - } - if lower.contains("xattr") || lower.contains("metadata") { - return .metadataRepair - } - if lower.contains("diagnostic") { - return .diagnostics - } - if lower.contains("retry") { - return .retry + 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") } - return .generic } private static func deduplicated(_ actions: [RecoveryAction]) -> [RecoveryAction] { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index b1fcd4f0..9efd6d10 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -3,11 +3,37 @@ "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.host_or_ip" = "Host or IP"; +"add_device.password" = "Time Capsule password"; +"add_device.reset" = "Reset"; +"add_device.save_device" = "Save Device"; +"add_device.saved" = "Saved %@"; +"add_device.title" = "Add Time Capsule"; +"advanced.config" = "Config"; "advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; "advanced.flash_help" = "Use `.venv/bin/tcapsule flash --help` for firmware operations."; +"advanced.helper" = "Helper"; +"advanced.profile_id" = "Profile ID"; +"app_readiness.state.blocked" = "Blocked"; +"app_readiness.state.checking_capabilities" = "Checking helper"; +"app_readiness.state.degraded" = "Degraded"; +"app_readiness.state.idle" = "Idle"; +"app_readiness.state.ready" = "Ready"; +"app_readiness.state.resolving_bundle" = "Preparing app runtime"; +"app_readiness.state.validating_install" = "Validating bundled files"; +"app_readiness.error.unexpected_payload" = "%@ returned an unexpected payload: %@"; +"app_readiness.recovery.contract_mismatch" = "Update or reinstall TimeCapsuleSMB so the app and helper use the same API contract."; +"app_readiness.recovery.helper_missing" = "Reinstall TimeCapsuleSMB or choose a valid helper in Diagnostics."; +"app_readiness.recovery.install_validation_failed" = "Reinstall TimeCapsuleSMB or open Diagnostics for the failed checks."; +"app_readiness.recovery.retry_diagnostics" = "Open Diagnostics and retry app readiness."; "button.activate" = "Activate"; "button.capabilities" = "Capabilities"; "button.configure" = "Configure"; @@ -24,6 +50,16 @@ "button.uninstall" = "Uninstall"; "button.uninstall_plan" = "Uninstall Plan"; "button.validate" = "Validate"; +"checkup.presentation.headline.failed" = "Checkup failed."; +"checkup.presentation.headline.idle" = "Run a checkup to inspect this Time Capsule."; +"checkup.presentation.headline.passed" = "Checkup passed."; +"checkup.presentation.headline.run_failed" = "Checkup could not complete."; +"checkup.presentation.headline.running" = "Checkup is running."; +"checkup.presentation.headline.warning" = "Checkup found warnings."; +"checkup.presentation.row.fail" = "Fail"; +"checkup.presentation.row.info" = "Info"; +"checkup.presentation.row.pass" = "Pass"; +"checkup.presentation.row.warning" = "Warning"; "confirm.activate.message" = "This will restart the deployed Samba runtime on an older NetBSD 4 device."; "confirm.activate.title" = "Activate NetBSD 4 Runtime?"; "confirm.backend.message" = "Confirm this operation."; @@ -48,6 +84,58 @@ "confirm.uninstall.no_wait.title" = "Uninstall And Skip Waiting?"; "confirm.uninstall.reboot.message" = "This will remove the managed TimeCapsuleSMB payload and wait for the device to reboot."; "confirm.uninstall.reboot.title" = "Uninstall And Reboot?"; +"dashboard.action.install_smb" = "Install SMB"; +"dashboard.action.open_smb" = "Open SMB Address"; +"dashboard.action.replace_password" = "Replace Password"; +"dashboard.action.run_checkup" = "Run Checkup"; +"dashboard.action.save_password" = "Save Password"; +"dashboard.action.view_checkup" = "View Checkup"; +"dashboard.overview.generation" = "Generation"; +"dashboard.overview.host" = "Host"; +"dashboard.overview.last_checkup" = "Last Checkup"; +"dashboard.overview.last_install" = "Last Install"; +"dashboard.overview.model" = "Model"; +"dashboard.overview.password" = "Password"; +"dashboard.overview.payload" = "Payload"; +"dashboard.overview.status" = "Status"; +"dashboard.replacement_password" = "Replacement password"; +"dashboard.tab.advanced" = "Advanced"; +"dashboard.tab.checkup" = "Checkup"; +"dashboard.tab.install" = "Install / Update"; +"dashboard.tab.maintenance" = "Maintenance"; +"dashboard.tab.overview" = "Overview"; +"deploy.action.plan_install" = "Plan Install"; +"deploy.advanced_plan_details" = "Advanced Plan Details"; +"deploy.presentation.expected_changes" = "%d file upload(s), %d install action(s)"; +"deploy.presentation.row.activation_actions" = "Activation Actions"; +"deploy.presentation.row.disk_location" = "Disk Location"; +"deploy.presentation.row.expected_changes" = "Expected Changes"; +"deploy.presentation.row.host" = "Host"; +"deploy.presentation.row.payload" = "Payload"; +"deploy.presentation.row.payload_directory" = "Payload Directory"; +"deploy.presentation.row.post_install_checks" = "Post-install Checks"; +"deploy.presentation.row.post_upload_actions" = "Post-upload Actions"; +"deploy.presentation.row.pre_upload_actions" = "Pre-upload Actions"; +"deploy.presentation.row.reboot" = "Reboot"; +"deploy.presentation.row.target" = "Target"; +"deploy.presentation.title.netbsd4" = "Install SMB and Start Runtime"; +"deploy.presentation.title.standard" = "Install SMB"; +"deploy.presentation.warning.netbsd4_activation" = "This NetBSD4 device may need Start SMB after future reboots unless the boot hook is patched."; +"deploy.result.default_message" = "Install completed."; +"deploy.result.message" = "Message"; +"deploy.result.reboot_requested" = "Reboot Requested"; +"deploy.result.verified" = "Verified"; +"diagnostics.backend_events" = "Backend Events"; +"diagnostics.distribution" = "Distribution"; +"diagnostics.helper" = "Helper"; +"diagnostics.runtime_issues" = "Runtime Issues"; +"diagnostics.state" = "State"; +"diagnostics.title" = "Diagnostics"; +"diagnostics.validation" = "Validation"; +"dialog.forget.action" = "Forget %@"; +"dialog.forget.error_title" = "Could Not Forget Time Capsule"; +"dialog.forget.message" = "Remove %@ from this Mac. This does not uninstall SMB from the Time Capsule."; +"dialog.forget.title" = "Forget Time Capsule?"; "event.summary.check" = "%@ %@"; "event.summary.check.default_status" = "INFO"; "event.summary.error" = "%@: %@"; @@ -65,13 +153,128 @@ "field.repair_xattrs_path" = "Repair xattrs path"; "helper.error.cancelled" = "Operation cancelled."; "helper.error.missing_terminal_event" = "Helper exited without a result or error event."; +"host_warning.time_machine.message" = "macOS %d.%d.%d has known Time Machine network backup issues. SMB may work, but backup reliability can be affected by the host OS."; +"host_warning.time_machine.title" = "macOS Time Machine Warning"; +"activity.last_operation" = "Last operation"; +"activity.no_active_operation" = "No active operation"; +"maintenance.action.choose" = "Choose"; +"maintenance.action.choose_folder" = "Choose Folder"; +"maintenance.action.find_volumes" = "Find Volumes"; +"maintenance.action.plan_disk_repair" = "Plan Disk Repair"; +"maintenance.action.plan_start_smb" = "Plan Start SMB"; +"maintenance.action.plan_uninstall" = "Plan Uninstall"; +"maintenance.action.repair_metadata" = "Repair Metadata"; +"maintenance.action.run_disk_repair" = "Run Disk Repair"; +"maintenance.action.scan_metadata" = "Scan Metadata"; +"maintenance.action.start_smb" = "Start SMB"; +"maintenance.action.uninstall" = "Uninstall"; +"maintenance.presentation.activate.primary_action" = "Start SMB"; +"maintenance.presentation.activate.subtitle" = "Start the deployed SMB runtime on a NetBSD4 Time Capsule."; +"maintenance.presentation.activate.title" = "NetBSD4 Activation"; +"maintenance.presentation.fsck.primary_action" = "Run Disk Repair"; +"maintenance.presentation.fsck.subtitle" = "Unmount a selected HFS volume and run fsck_hfs on the device."; +"maintenance.presentation.fsck.title" = "Disk Repair"; +"maintenance.presentation.repair_xattrs.primary_action" = "Repair Metadata"; +"maintenance.presentation.repair_xattrs.subtitle" = "Scan and repair macOS metadata on a mounted SMB share."; +"maintenance.presentation.repair_xattrs.title" = "File Metadata Repair"; +"maintenance.presentation.risk.destructive" = "Destructive"; +"maintenance.presentation.risk.local_destructive" = "Local destructive"; +"maintenance.presentation.risk.remote_write" = "Remote write"; +"maintenance.presentation.uninstall.primary_action" = "Uninstall"; +"maintenance.presentation.uninstall.subtitle" = "Remove managed SMB files from the selected Time Capsule."; +"maintenance.presentation.uninstall.title" = "Uninstall"; +"maintenance.repairable_count" = "%d repairable item(s)"; +"maintenance.workflow.activate" = "NetBSD4 Activation"; +"maintenance.workflow.fsck" = "Disk Repair"; +"maintenance.workflow.repair_xattrs" = "File Metadata Repair"; +"maintenance.workflow.uninstall" = "Uninstall"; +"overview.empty.message" = "Add a Time Capsule to configure SMB, run checkups, and manage maintenance tasks."; +"overview.empty.title" = "No Time Capsules Saved"; "panel.connect" = "Discover And Connect"; +"password_state.available" = "Available"; +"password_state.invalid" = "Invalid"; +"password_state.keychain_unavailable" = "Keychain unavailable"; +"password_state.missing" = "Missing"; +"password_state.unknown" = "Unknown"; +"password.error.required" = "Password is required."; +"readiness.blocked.title" = "TimeCapsuleSMB cannot start"; +"readiness.state.checking_capabilities" = "Checking helper"; +"readiness.state.resolving_bundle" = "Preparing app runtime"; +"readiness.state.validating_install" = "Validating bundled files"; +"readiness.warning.default" = "TimeCapsuleSMB is running with warnings."; +"recovery.action.copy_diagnostics" = "Copy Diagnostics"; +"recovery.action.disk_repair" = "Run Disk Repair"; +"recovery.action.install_smb" = "Install SMB"; +"recovery.action.metadata_repair" = "Repair File Metadata"; +"recovery.action.open" = "Open"; +"recovery.action.open_diagnostics" = "Open Diagnostics"; +"recovery.action.open_finder" = "Open Finder"; +"recovery.action.replace_password" = "Replace Password"; +"recovery.action.retry" = "Retry"; +"recovery.action.run_checkup" = "Run Checkup"; +"recovery.action.start_smb" = "Start SMB"; +"recovery.action.uninstall" = "Uninstall"; "screen.advanced" = "Advanced"; "screen.connect" = "Connect"; "screen.deploy" = "Deploy"; "screen.doctor" = "Doctor"; "screen.maintenance" = "Maintenance"; "screen.readiness" = "Readiness"; +"sidebar.add_time_capsule" = "Add Time Capsule"; +"sidebar.all_time_capsules" = "All Time Capsules"; +"sidebar.devices" = "Devices"; +"status.activation_needed" = "Activation Needed"; +"status.checking" = "Checking"; +"status.failed" = "Failed"; +"status.healthy" = "Healthy"; +"status.installing" = "Installing"; +"status.keychain_unavailable" = "Keychain Unavailable"; +"status.maintenance" = "Maintenance"; +"status.offline" = "Offline"; +"status.password_invalid" = "Password Invalid"; +"status.password_needed" = "Password Needed"; +"status.ready_to_install" = "Ready to Install"; +"status.removed" = "Removed"; +"status.unchecked" = "Unchecked"; +"status.unsupported" = "Unsupported"; +"status.warning" = "Warning"; +"summary.checkup_counts" = "PASS %d, WARN %d, FAIL %d"; +"timeline.error.needs_attention" = "Needs Attention"; +"timeline.error.needs_confirmation" = "Needs Confirmation"; +"timeline.operation.activate" = "Start SMB"; +"timeline.operation.configure" = "Add Time Capsule"; +"timeline.operation.deploy" = "Install / Update"; +"timeline.operation.discovery" = "Discovery"; +"timeline.operation.doctor" = "Checkup"; +"timeline.operation.flash" = "Persistent NetBSD4 Boot Hook"; +"timeline.operation.fsck" = "Disk Repair"; +"timeline.operation.readiness" = "App Readiness"; +"timeline.operation.repair_xattrs" = "File Metadata Repair"; +"timeline.operation.uninstall" = "Uninstall"; +"timeline.result.done" = "Done"; +"timeline.result.failed" = "Failed"; +"timeline.stage.checking_bundled_files" = "Checking Bundled Files"; +"timeline.stage.checking_ssh" = "Checking SSH"; +"timeline.stage.enabling_ssh" = "Enabling SSH"; +"timeline.stage.finding_disk" = "Finding Disk"; +"timeline.stage.finding_time_capsules" = "Finding Time Capsules"; +"timeline.stage.finding_volumes" = "Finding Volumes"; +"timeline.stage.planning_install" = "Planning Install"; +"timeline.stage.planning_start_smb" = "Planning Start SMB"; +"timeline.stage.planning_uninstall" = "Planning Uninstall"; +"timeline.stage.rebooting" = "Rebooting"; +"timeline.stage.removing_managed_files" = "Removing Managed Files"; +"timeline.stage.repairing_disk" = "Repairing Disk"; +"timeline.stage.repairing_metadata" = "Repairing Metadata"; +"timeline.stage.running_checkup" = "Running Checkup"; +"timeline.stage.saving_device" = "Saving Device"; +"timeline.stage.scanning_metadata" = "Scanning Metadata"; +"timeline.stage.starting_smb" = "Starting SMB"; +"timeline.stage.syncing_to_disk" = "Syncing to Disk"; +"timeline.stage.uploading" = "Uploading"; +"timeline.stage.validating_app_bundle" = "Validating App Bundle"; +"timeline.stage.verifying_smb" = "Verifying SMB"; +"timeline.stage.waiting_for_device" = "Waiting for Device"; "toggle.dry_run" = "Dry Run"; "toggle.enable_debug_logging" = "Enable Debug Logging"; "toggle.enable_nbns" = "Enable NBNS"; @@ -79,4 +282,14 @@ "toggle.no_reboot" = "No Reboot"; "toggle.no_wait" = "No Wait"; "toolbar.cancel" = "Cancel"; +"toolbar.add" = "Add"; "toolbar.clear" = "Clear"; +"toolbar.diagnostics" = "Diagnostics"; +"toolbar.forget" = "Forget"; +"value.auto" = "Auto"; +"value.never" = "Never"; +"value.no" = "no"; +"value.not_required" = "Not required"; +"value.required" = "Required"; +"value.unknown" = "Unknown"; +"value.yes" = "yes"; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift index 567262ac..044848eb 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ActivityStoreTests.swift @@ -24,6 +24,8 @@ final class ActivityStoreTests: XCTestCase { 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 } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift index 83c70e24..04d2ed22 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AppReadinessStoreTests.swift @@ -10,6 +10,18 @@ final class AppReadinessStoreTests: XCTestCase { ) } + func testStateTitlesAreLocalized() { + XCTAssertEqual(AppReadinessStateKind.allCases.map(\.title), [ + "Idle", + "Preparing app runtime", + "Checking helper", + "Validating bundled files", + "Ready", + "Degraded", + "Blocked" + ]) + } + func testSuccessfulReadinessRunsCapabilitiesThenValidation() async throws { let runner = StoreTestRunner(responses: [ .init(events: [ diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift index 0de75197..9106817b 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/BackendClientTests.swift @@ -56,6 +56,23 @@ final class BackendClientTests: XCTestCase { 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: [ @@ -215,6 +232,64 @@ final class BackendClientTests: XCTestCase { try await Task.sleep(nanoseconds: 10_000_000) } } + + private func waitUntilAsync( + timeoutNanoseconds: UInt64 = 2_000_000_000, + _ condition: @escaping () async -> Bool + ) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !(await condition()) { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + XCTFail("Timed out waiting for async BackendClient state change.") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } +} + +private actor CancellationRecorder { + private var didStart = false + private var didCancel = false + + var started: Bool { + didStart + } + + var cancelled: Bool { + didCancel + } + + func markStarted() { + didStart = true + } + + func markCancelled() { + didCancel = true + } +} + +private final class CancellationObservingRunner: HelperRunning, @unchecked Sendable { + private let recorder: CancellationRecorder + + init(recorder: CancellationRecorder) { + self.recorder = recorder + } + + func run( + helperPath: String?, + operation: String, + params: [String: JSONValue], + context: DeviceRuntimeContext?, + onEvent: @escaping @Sendable (BackendEvent) async -> Void + ) async -> HelperRunResult { + await recorder.markStarted() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 10_000_000) + } + await recorder.markCancelled() + return HelperRunResult(exitCode: 130, sawTerminalEvent: false, stderr: "") + } } private final class RecordingHelperRunner: HelperRunning, @unchecked Sendable { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift index 165596d3..1eb9408c 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/DeviceStatusPolicyTests.swift @@ -22,6 +22,46 @@ final class DeviceStatusPolicyTests: XCTestCase { ]) } + func testDisplayStatusTitlesAreLocalized() { + XCTAssertEqual(DeviceDisplayStatus.allCases.map(\.title), [ + "Unchecked", + "Password Needed", + "Password Invalid", + "Keychain Unavailable", + "Checking", + "Installing", + "Maintenance", + "Ready to Install", + "Healthy", + "Warning", + "Failed", + "Activation Needed", + "Removed", + "Offline", + "Unsupported" + ]) + } + + func testPasswordStateTitlesAreLocalized() { + XCTAssertEqual(DevicePasswordState.allCases.map(\.title), [ + "Unknown", + "Available", + "Missing", + "Invalid", + "Keychain unavailable" + ]) + } + + func testDashboardTabTitlesAreLocalized() { + XCTAssertEqual(DeviceDashboardTab.allCases.map(\.title), [ + "Overview", + "Install / Update", + "Checkup", + "Maintenance", + "Advanced" + ]) + } + func testPasswordStatesTakePriority() throws { let profile = try makeProfile() diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift index c8e9af92..cd802bcf 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/RecoveryActionMapperTests.swift @@ -15,7 +15,8 @@ final class RecoveryActionMapperTests: XCTestCase { let recovery = try recoveryValue( title: "Disk issue", actions: ["Wake the disk by opening it in Finder.", "Retry deploy."], - suggestedOperation: "fsck" + suggestedOperation: "fsck", + actionIDs: ["open_finder", "install_smb"] ).decode(BackendRecoveryPayload.self) let error = BackendErrorViewModel( operation: "deploy", @@ -27,7 +28,27 @@ final class RecoveryActionMapperTests: XCTestCase { let actions = RecoveryActionMapper.actions(for: error) XCTAssertTrue(actions.contains(RecoveryAction(title: "Run Disk Repair", kind: .diskRepair))) - XCTAssertTrue(actions.contains(where: { $0.kind == .openFinder })) - XCTAssertTrue(actions.contains(where: { $0.kind == .installSMB })) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Open Finder", kind: .openFinder))) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Install SMB", kind: .installSMB))) + } + + func testHumanRecoveryTextDoesNotCreateActionButtons() throws { + let recovery = try recoveryValue( + title: "Disk issue", + actions: ["Wake the disk by opening it in Finder.", "Retry deploy."], + suggestedOperation: "unknown" + ).decode(BackendRecoveryPayload.self) + let error = BackendErrorViewModel( + operation: "deploy", + code: "remote_error", + message: "Disk did not mount.", + recovery: recovery + ) + + let actions = RecoveryActionMapper.actions(for: error) + + XCTAssertFalse(actions.contains(where: { $0.kind == .openFinder })) + XCTAssertFalse(actions.contains(where: { $0.kind == .installSMB })) + XCTAssertTrue(actions.contains(RecoveryAction(title: "Retry", kind: .retry))) } } diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift index 45cdd6df..16dec9c5 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/StoreTestSupport.swift @@ -85,11 +85,17 @@ func waitUntilStoreState( } } -func recoveryValue(title: String, actions: [String], suggestedOperation: String = "doctor") -> JSONValue { +func recoveryValue( + title: String, + actions: [String], + suggestedOperation: String = "doctor", + actionIDs: [String] = [] +) -> JSONValue { return .object([ "title": .string(title), "message": .string(title), "actions": .array(actions.map(JSONValue.string)), + "action_ids": .array(actionIDs.map(JSONValue.string)), "retryable": .bool(true), "suggested_operation": .string(suggestedOperation) ]) diff --git a/src/timecapsulesmb/app/ops/maintenance.py b/src/timecapsulesmb/app/ops/maintenance.py index d0e638d1..5c1d6e59 100644 --- a/src/timecapsulesmb/app/ops/maintenance.py +++ b/src/timecapsulesmb/app/ops/maintenance.py @@ -18,7 +18,6 @@ from timecapsulesmb.app.confirmations import build_confirmation, require_confirmation from timecapsulesmb.app.events import EventSink from timecapsulesmb.app.ops.deploy import ( - load_config_and_target, request_reboot, request_reboot_and_wait, require_supported_payload, @@ -70,28 +69,26 @@ select_fsck_target, ) from timecapsulesmb.services import repair_xattrs as repair_xattrs_service -from timecapsulesmb.services.runtime import load_env_config, load_optional_env_config, resolve_env_connection +from timecapsulesmb.services.runtime import ( + load_env_config, + load_optional_env_config, + resolve_env_connection, + resolve_validated_managed_target, +) from timecapsulesmb.transport.ssh import SshConnection, run_ssh def activate_operation(params: dict[str, object], sink: EventSink) -> OperationResult: operation = "activate" dry_run = bool_param(params, "dry_run") - _, target = load_config_and_target(operation, params, sink, 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", - ) sink.stage(operation, "build_activation_plan") plan = build_netbsd4_activation_plan() if dry_run: return OperationResult(True, activation_plan_payload(activation_plan_to_jsonable(plan))) - connection = target.connection - sink.stage(operation, "probe_runtime") - if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: - return OperationResult(True, activation_result_payload(already_active=True)) + + sink.stage(operation, "load_config") + config = overlay_request_credentials(load_env_config(env_path=config_path(params)), params) + confirmation_connection = resolve_env_connection(config, allow_empty_password=True) require_confirmation( params, build_confirmation( @@ -103,13 +100,31 @@ def activate_operation(params: dict[str, object], sink: EventSink) -> OperationR risk="destructive", summary="NetBSD4 service activation", context={ - "host": connection.host, - "payload_family": compatibility.payload_family, + "host": confirmation_connection.host, "netbsd4": True, }, ), legacy_names=("confirm_netbsd4_activation",), ) + + sink.stage(operation, "resolve_managed_target") + target = resolve_validated_managed_target( + config, + command_name=operation, + profile="activate", + include_probe=True, + ) + compatibility = require_supported_payload(target, allow_unsupported=False) + if not is_netbsd4_payload_family(compatibility.payload_family): + raise AppOperationError( + "activate is only supported for NetBSD4 AirPort storage devices; use deploy for persistent NetBSD6 installs.", + code="unsupported_device", + ) + connection = target.connection + sink.stage(operation, "probe_runtime") + if probe_managed_runtime_conn(connection, timeout_seconds=20).ready: + return OperationResult(True, activation_result_payload(already_active=True)) + sink.stage(operation, "run_activation") run_remote_actions(connection, plan.actions) verify_runtime(operation, sink, connection, stage="verify_runtime_activation", timeout_seconds=180) diff --git a/src/timecapsulesmb/app/recovery.py b/src/timecapsulesmb/app/recovery.py index e32e7240..481f1f55 100644 --- a/src/timecapsulesmb/app/recovery.py +++ b/src/timecapsulesmb/app/recovery.py @@ -10,6 +10,7 @@ class RecoveryInfo: actions: tuple[str, ...] retryable: bool suggested_operation: str | None = None + action_ids: tuple[str, ...] = () docs_anchor: str | None = None def to_jsonable(self) -> dict[str, object]: @@ -17,6 +18,7 @@ def to_jsonable(self) -> 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, } @@ -50,6 +52,7 @@ def to_jsonable(self) -> dict[str, object]: ("Open the configuration step.", "Verify host, password, and SSH options."), retryable=True, suggested_operation="configure", + action_ids=("replace_password",), ), "auth_failed": RecoveryInfo( "Authentication failed", @@ -57,6 +60,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -76,6 +80,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -93,6 +98,7 @@ def to_jsonable(self) -> dict[str, object]: ("Re-enter the AirPort admin password.", "Confirm the selected device is the intended Time Capsule."), retryable=True, suggested_operation="configure", + action_ids=("replace_password",), ), ("configure", "unsupported_device"): RecoveryInfo( "Unsupported Time Capsule", @@ -112,6 +118,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -124,36 +131,42 @@ def to_jsonable(self) -> dict[str, object]: "NetBSD4 activation starts the deployed runtime and must be confirmed.", ("Review the NetBSD4 activation guidance.", "Confirm activation before retrying."), retryable=True, + action_ids=("start_smb",), ), ("uninstall", "confirmation_required"): RecoveryInfo( "Uninstall confirmation required", "Uninstall removes managed files and may reboot the device.", ("Review the uninstall plan.", "Confirm uninstall and reboot before retrying."), retryable=True, + action_ids=("uninstall",), ), ("fsck", "confirmation_required"): RecoveryInfo( "fsck confirmation required", "fsck stops file sharing, unmounts the selected HFS disk, and may reboot the device.", ("Review the selected volume.", "Confirm fsck before retrying."), retryable=True, + action_ids=("disk_repair",), ), ("fsck", "validation_failed"): RecoveryInfo( "Volume selection failed", "The helper could not choose a mounted HFS volume for fsck.", ("Select a specific HFS volume.", "Refresh mounted volumes and retry."), retryable=True, + action_ids=("disk_repair",), ), ("repair-xattrs", "confirmation_required"): RecoveryInfo( "Repair confirmation required", "repair-xattrs needs dry-run mode or explicit confirmation before changing local file metadata.", ("Run a dry run first.", "Confirm repair before retrying."), retryable=True, + action_ids=("repair_metadata",), ), ("repair-xattrs", "validation_failed"): RecoveryInfo( "repair-xattrs cannot run", "repair-xattrs must run on macOS against a valid mounted SMB share path.", ("Choose a mounted share path.", "Run this from macOS."), retryable=True, + action_ids=("repair_metadata",), ), } @@ -165,6 +178,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -179,6 +193,7 @@ def to_jsonable(self) -> dict[str, object]: ("Wake the disk by opening it in Finder.", "Check the disk is installed and formatted HFS.", "Retry deploy."), retryable=True, suggested_operation="deploy", + action_ids=("open_finder", "install_smb"), ), ("deploy", "remote_error", "select_payload_home"): RecoveryInfo( "No writable payload volume", @@ -186,6 +201,7 @@ def to_jsonable(self) -> dict[str, object]: ("Wake or remount the disk.", "Check available free space.", "Retry deploy."), retryable=True, suggested_operation="deploy", + action_ids=("open_finder", "install_smb"), ), ("deploy", "remote_error", "verify_payload_upload"): RecoveryInfo( "Payload verification failed", @@ -214,6 +230,7 @@ def to_jsonable(self) -> dict[str, object]: ("Wait a few more minutes.", "Power-cycle the device if needed.", "Run doctor once SSH returns."), retryable=True, suggested_operation="doctor", + action_ids=("run_checkup",), ), ("deploy", "remote_error", "verify_runtime_reboot"): RecoveryInfo( "Runtime not ready", @@ -221,6 +238,7 @@ def to_jsonable(self) -> dict[str, object]: ("Run doctor for details.", "Check boot logs from the CLI if doctor still fails."), retryable=True, suggested_operation="doctor", + action_ids=("run_checkup",), ), ("deploy", "remote_error", "verify_runtime_activation"): RecoveryInfo( "Activated runtime not ready", @@ -228,6 +246,7 @@ def to_jsonable(self) -> dict[str, object]: ("Retry activation.", "Run doctor for detailed runtime checks."), retryable=True, suggested_operation="doctor", + action_ids=("start_smb", "run_checkup"), ), ("uninstall", "remote_error", "verify_post_uninstall"): RecoveryInfo( "Post-uninstall verification failed", @@ -235,6 +254,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -242,6 +262,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -249,6 +270,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -256,6 +278,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -263,6 +286,7 @@ def to_jsonable(self) -> dict[str, object]: ("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", @@ -270,6 +294,7 @@ def to_jsonable(self) -> dict[str, object]: ("Choose a mounted SMB share path.", "Confirm the share is accessible in Finder."), retryable=True, suggested_operation="repair-xattrs", + action_ids=("repair_metadata",), ), } diff --git a/tests/test_app_api.py b/tests/test_app_api.py index ed8769f6..2418f5ad 100644 --- a/tests/test_app_api.py +++ b/tests/test_app_api.py @@ -591,6 +591,7 @@ def test_configure_reports_acp_auth_failure_without_writing_env(self) -> None: self.assertFalse(config_path.exists()) self.assertEqual(collector.events_of_type("error")[0]["code"], "auth_failed") self.assertEqual(collector.events_of_type("error")[0]["recovery"]["suggested_operation"], "configure") + self.assertEqual(collector.events_of_type("error")[0]["recovery"]["action_ids"], ["replace_password"]) self.assertNotIn("badpw", json.dumps(collector.events)) def test_configure_reports_unsupported_device(self) -> None: @@ -1060,25 +1061,21 @@ def test_deploy_reports_no_mast_volumes_as_remote_error(self) -> None: error = collector.events_of_type("error")[0] self.assertEqual(error["code"], "remote_error") self.assertEqual(error["recovery"]["title"], "No HFS volumes found") + self.assertEqual(error["recovery"]["action_ids"], ["open_finder", "install_smb"]) def test_activate_requires_explicit_confirmation(self) -> None: collector = CollectingSink() - connection = SshConnection("root@10.0.0.2", "pw", "-o foo") - target = SimpleNamespace( - connection=connection, - probe_state=ProbedDeviceState( - probe_result=probed_state().probe_result, - compatibility=supported_compatibility("netbsd4le_samba4"), - ), - ) - with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): - with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: - rc = service.run_api_request({"operation": "activate", "params": {}}, collector.sink) + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_validated_managed_target") as resolve_target: + with mock.patch("timecapsulesmb.app.ops.maintenance.probe_managed_runtime_conn") as runtime_probe: + with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: + rc = service.run_api_request({"operation": "activate", "params": {}}, collector.sink) self.assertEqual(rc, 1) self.assertEqual(collector.events_of_type("error")[0]["code"], "confirmation_required") + resolve_target.assert_not_called() + runtime_probe.assert_not_called() remote_actions.assert_not_called() def test_activate_accepts_yes_alias_for_confirmation(self) -> None: @@ -1086,8 +1083,8 @@ def test_activate_accepts_yes_alias_for_confirmation(self) -> None: connection = SshConnection("root@10.0.0.2", "pw", "-o foo") target = SimpleNamespace(connection=connection, probe_state=netbsd4_probed_state()) - with mock.patch("timecapsulesmb.app.ops.deploy.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): - with mock.patch("timecapsulesmb.app.ops.deploy.resolve_validated_managed_target", return_value=target): + with mock.patch("timecapsulesmb.app.ops.maintenance.load_env_config", return_value=AppConfig.from_values({"TC_HOST": "root@10.0.0.2", "TC_PASSWORD": "pw"})): + with mock.patch("timecapsulesmb.app.ops.maintenance.resolve_validated_managed_target", return_value=target): with mock.patch("timecapsulesmb.app.ops.maintenance.probe_managed_runtime_conn", return_value=SimpleNamespace(ready=True)): with mock.patch("timecapsulesmb.app.ops.maintenance.run_remote_actions") as remote_actions: rc = service.run_api_request( From 3fcb0e87127213b21be53cc6b601bf1b35fc439b Mon Sep 17 00:00:00 2001 From: James Chang Date: Thu, 21 May 2026 01:33:00 -0700 Subject: [PATCH 20/20] Harden GUI helper and profile persistence --- .../AddDeviceFlowStore.swift | 81 +++++++------ .../ConfiguredDeviceProfileSaver.swift | 81 +++++++++++++ .../DeviceRegistryStore.swift | 76 +++++++++---- .../HelperRequestWriter.swift | 107 ++++++++++++++++++ .../TimeCapsuleSMBApp/HelperRunner.swift | 49 +++++++- .../OperationCoordinator.swift | 2 +- .../TimeCapsuleSMBApp/PasswordStore.swift | 71 +++++++++--- .../Resources/en.lproj/Localizable.strings | 24 ++++ .../AddDeviceFlowStoreTests.swift | 11 +- .../ConfiguredDeviceProfileSaverTests.swift | 70 ++++++++++++ .../HelperRunnerTests.swift | 27 +++++ .../PasswordStoreTests.swift | 66 +++++++++++ 12 files changed, 583 insertions(+), 82 deletions(-) create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift create mode 100644 macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift create mode 100644 macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift index 529c3854..fdb0818d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/AddDeviceFlowStore.swift @@ -18,29 +18,29 @@ enum AddDeviceFlowState: String, CaseIterable, Equatable { var title: String { switch self { case .idle: - return "Idle" + return L10n.string("add_device.state.idle") case .discovering: - return "Discovering" + return L10n.string("add_device.state.discovering") case .discoveryEmpty: - return "No Devices Found" + return L10n.string("add_device.state.discovery_empty") case .discoveryReady: - return "Devices Found" + return L10n.string("add_device.state.discovery_ready") case .manualEntry: - return "Manual Address" + return L10n.string("add_device.state.manual_entry") case .passwordEntry: - return "Password Required" + return L10n.string("add_device.state.password_entry") case .configuring: - return "Configuring" + return L10n.string("add_device.state.configuring") case .savingProfile: - return "Saving" + return L10n.string("add_device.state.saving_profile") case .saved: - return "Saved" + return L10n.string("add_device.state.saved") case .authFailed: - return "Password Rejected" + return L10n.string("add_device.state.auth_failed") case .unsupported: - return "Unsupported" + return L10n.string("add_device.state.unsupported") case .failed: - return "Failed" + return L10n.string("add_device.state.failed") } } } @@ -54,9 +54,9 @@ enum AddDeviceEntryMode: String, CaseIterable, Equatable, Identifiable { var title: String { switch self { case .discover: - return "Discover" + return L10n.string("add_device.entry.discover") case .manual: - return "Manual Address" + return L10n.string("add_device.entry.manual") } } } @@ -78,6 +78,7 @@ final class AddDeviceFlowStore: ObservableObject { let coordinator: OperationCoordinator let registry: DeviceRegistryStore let passwordStore: PasswordStore + let profileSaver: ConfiguredDeviceProfileSaving private var pendingProfileID: DeviceProfile.ID? private var pendingDiscoveredDevice: DiscoveredDevice? @@ -88,11 +89,13 @@ final class AddDeviceFlowStore: ObservableObject { init( coordinator: OperationCoordinator, registry: DeviceRegistryStore, - passwordStore: PasswordStore + passwordStore: PasswordStore, + profileSaver: ConfiguredDeviceProfileSaving? = nil ) { self.coordinator = coordinator self.registry = registry self.passwordStore = passwordStore + self.profileSaver = profileSaver ?? ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) coordinator.backend.$events .sink { [weak self] events in Task { @MainActor in @@ -177,7 +180,7 @@ final class AddDeviceFlowStore: ObservableObject { func promptForPassword() { guard hasSelectedTarget else { - failLocally("Choose a discovered device or enter a host.") + failLocally(L10n.string("add_device.error.choose_target")) return } state = .passwordEntry @@ -186,11 +189,11 @@ final class AddDeviceFlowStore: ObservableObject { func runDiscover() { guard let timeout = bonjourTimeoutValue else { - failLocally("Bonjour timeout must be a non-negative number.") + failLocally(L10n.string("add_device.error.invalid_bonjour_timeout")) return } guard !coordinator.backend.isRunning else { - rejectRun("Another operation is already running.") + rejectRun(L10n.string("operation.error.already_running")) return } resetRunState(clearDevices: true) @@ -209,13 +212,13 @@ final class AddDeviceFlowStore: ObservableObject { let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedPassword.isEmpty else { state = .passwordEntry - failLocally("Time Capsule password is required.") + failLocally(L10n.string("add_device.error.password_required")) return } let selectedDevice = entryMode == .discover ? selectedDevice : nil let trimmedHost = manualHost.trimmingCharacters(in: .whitespacesAndNewlines) guard selectedDevice != nil || (entryMode == .manual && !trimmedHost.isEmpty) else { - failLocally("Choose a discovered device or enter a host.") + failLocally(L10n.string("add_device.error.choose_target")) return } @@ -233,7 +236,7 @@ final class AddDeviceFlowStore: ObservableObject { guard !coordinator.backend.isRunning else { pendingProfileID = nil pendingDiscoveredDevice = nil - rejectRun("Another operation is already running.") + rejectRun(L10n.string("operation.error.already_running")) return } resetRunState(clearDevices: false) @@ -370,32 +373,28 @@ final class AddDeviceFlowStore: ObservableObject { } private func applyConfigureResult(_ event: BackendEvent) { + let configured: ConfiguredDeviceState + do { + configured = ConfiguredDeviceState(payload: try event.decodePayload(ConfigurePayload.self)) + } catch { + failContract(error) + return + } + do { state = .savingProfile - let payload = try event.decodePayload(ConfigurePayload.self) - let configured = ConfiguredDeviceState(payload: payload) let profileID = pendingProfileID ?? UUID().uuidString.lowercased() - let profile = try registry.saveConfiguredDevice( + savedProfile = try profileSaver.saveConfiguredDevice( configuredDevice: configured, discoveredDevice: pendingDiscoveredDevice, - passwordState: .missing, + password: password, preferredID: profileID ) - do { - try passwordStore.save(password, for: profile.keychainAccount) - var saved = profile - saved.passwordState = .available - saved = try registry.updateProfile(saved) - savedProfile = saved - } catch { - registry.updatePasswordState(.missing, for: profile.id) - savedProfile = registry.profile(id: profile.id) ?? profile - } error = nil state = .saved activeOperation = nil } catch { - failContract(error) + failProfileSave(error) } } @@ -432,6 +431,16 @@ final class AddDeviceFlowStore: ObservableObject { activeOperation = nil } + private func failProfileSave(_ error: Error) { + self.error = BackendErrorViewModel( + operation: "add-device", + code: "profile_save_failed", + message: error.localizedDescription + ) + state = .failed + activeOperation = nil + } + private func failLocally(_ message: String) { error = BackendErrorViewModel( operation: "add-device", diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift new file mode 100644 index 00000000..647e0bdd --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/ConfiguredDeviceProfileSaver.swift @@ -0,0 +1,81 @@ +import Foundation + +@MainActor +protocol ConfiguredDeviceProfileSaving: AnyObject { + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + password: String, + preferredID: DeviceProfile.ID + ) throws -> DeviceProfile +} + +@MainActor +final class ConfiguredDeviceProfileSaver: ConfiguredDeviceProfileSaving { + private enum PasswordRollback { + case delete + case restore(String) + } + + private let registry: DeviceRegistryStore + private let passwordStore: PasswordStore + + init(registry: DeviceRegistryStore, passwordStore: PasswordStore) { + self.registry = registry + self.passwordStore = passwordStore + } + + func saveConfiguredDevice( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + password: String, + preferredID: DeviceProfile.ID + ) throws -> DeviceProfile { + let profile = registry.makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: .available, + preferredID: preferredID + ) + let wasSavedProfile = registry.profile(id: profile.id) != nil + let rollback = try passwordRollback(for: profile.keychainAccount) + + do { + try passwordStore.save(password, for: profile.keychainAccount) + } catch { + if !wasSavedProfile { + registry.discardArtifacts(for: profile) + } + throw error + } + + do { + return try registry.saveProfileMergingDuplicates(profile) + } catch { + rollbackPassword(rollback, account: profile.keychainAccount) + if !wasSavedProfile { + registry.discardArtifacts(for: profile) + } + throw error + } + } + + private func passwordRollback(for account: String) throws -> PasswordRollback { + do { + return .restore(try passwordStore.password(for: account)) + } catch PasswordStoreError.missing { + return .delete + } catch { + throw error + } + } + + private func rollbackPassword(_ rollback: PasswordRollback, account: String) { + switch rollback { + case .delete: + try? passwordStore.deletePassword(for: account) + case .restore(let password): + try? passwordStore.save(password, for: account) + } + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift index 99588b2c..7dc28c67 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/DeviceRegistryStore.swift @@ -110,6 +110,21 @@ final class DeviceRegistryStore: ObservableObject { passwordState: DevicePasswordState, preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() ) throws -> DeviceProfile { + let profile = makeConfiguredDeviceProfile( + configuredDevice: configuredDevice, + discoveredDevice: discoveredDevice, + passwordState: passwordState, + preferredID: preferredID + ) + return try saveProfileMergingDuplicates(profile) + } + + func makeConfiguredDeviceProfile( + configuredDevice: ConfiguredDeviceState, + discoveredDevice: DiscoveredDevice?, + passwordState: DevicePasswordState, + preferredID: DeviceProfile.ID = UUID().uuidString.lowercased() + ) -> DeviceProfile { let existing = matchingProfile(host: configuredDevice.host, bonjourFullname: discoveredDevice?.fullname) var profile = DeviceProfile.make( id: preferredID, @@ -120,11 +135,11 @@ final class DeviceRegistryStore: ObservableObject { date: now() ) profile.passwordState = passwordState - return try saveMergingDuplicates(profile) + return profile } @discardableResult - private func saveMergingDuplicates(_ profile: DeviceProfile) throws -> DeviceProfile { + func saveProfileMergingDuplicates(_ profile: DeviceProfile) throws -> DeviceProfile { state = .saving error = nil do { @@ -135,8 +150,9 @@ final class DeviceRegistryStore: ObservableObject { ) var updated = profiles.filter { !DeviceProfile.matches($0, profile) && $0.id != profile.id } updated.append(profile) - profiles = updated.sorted { $0.updatedAt > $1.updatedAt } - try persist() + updated = updated.sorted { $0.updatedAt > $1.updatedAt } + try persist(updated) + profiles = updated state = profiles.isEmpty ? .empty : .loaded return profile } catch { @@ -146,6 +162,16 @@ final class DeviceRegistryStore: ObservableObject { } } + func discardArtifacts(for profile: DeviceProfile) { + let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() + let configDirectoryPath = configDirectory.standardizedFileURL.path + let devicesDirectoryPath = devicesDirectoryURL.standardizedFileURL.path + guard configDirectoryPath.hasPrefix(devicesDirectoryPath + "/") else { + return + } + try? fileManager.removeItem(at: configDirectory) + } + @discardableResult func updateProfile(_ profile: DeviceProfile) throws -> DeviceProfile { guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { @@ -167,9 +193,11 @@ final class DeviceRegistryStore: ObservableObject { at: URL(fileURLWithPath: updated.configPath).deletingLastPathComponent(), withIntermediateDirectories: true ) - profiles[index] = updated - profiles = profiles.sorted { $0.updatedAt > $1.updatedAt } - try persist() + var updatedProfiles = profiles + updatedProfiles[index] = updated + updatedProfiles = updatedProfiles.sorted { $0.updatedAt > $1.updatedAt } + try persist(updatedProfiles) + profiles = updatedProfiles state = profiles.isEmpty ? .empty : .loaded return updated } catch { @@ -183,12 +211,13 @@ final class DeviceRegistryStore: ObservableObject { state = .saving error = nil do { - profiles.removeAll { $0.id == profile.id } + let updatedProfiles = profiles.filter { $0.id != profile.id } let configDirectory = URL(fileURLWithPath: profile.configPath).deletingLastPathComponent() + try persist(updatedProfiles) + profiles = updatedProfiles if fileManager.fileExists(atPath: configDirectory.path) { try fileManager.removeItem(at: configDirectory) } - try persist() state = profiles.isEmpty ? .empty : .loaded } catch { self.error = .io(error.localizedDescription) @@ -204,27 +233,36 @@ final class DeviceRegistryStore: ObservableObject { guard profiles[index].passwordState != state else { return } - profiles[index].passwordState = state - profiles[index].updatedAt = now() - try? persist() + var updatedProfiles = profiles + updatedProfiles[index].passwordState = state + updatedProfiles[index].updatedAt = now() + if (try? persist(updatedProfiles)) != nil { + profiles = updatedProfiles + } } func updateCheckup(_ snapshot: DeviceCheckupSnapshot, for profileID: DeviceProfile.ID) { guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { return } - profiles[index].lastCheckup = snapshot - profiles[index].updatedAt = now() - try? persist() + var updatedProfiles = profiles + updatedProfiles[index].lastCheckup = snapshot + updatedProfiles[index].updatedAt = now() + if (try? persist(updatedProfiles)) != nil { + profiles = updatedProfiles + } } func updateDeploy(_ snapshot: DeviceDeploySnapshot, for profileID: DeviceProfile.ID) { guard let index = profiles.firstIndex(where: { $0.id == profileID }) else { return } - profiles[index].lastDeploy = snapshot - profiles[index].updatedAt = now() - try? persist() + var updatedProfiles = profiles + updatedProfiles[index].lastDeploy = snapshot + updatedProfiles[index].updatedAt = now() + if (try? persist(updatedProfiles)) != nil { + profiles = updatedProfiles + } } func profile(id: DeviceProfile.ID?) -> DeviceProfile? { @@ -279,7 +317,7 @@ final class DeviceRegistryStore: ObservableObject { return normalized } - private func persist() throws { + private func persist(_ profiles: [DeviceProfile]) throws { try fileManager.createDirectory(at: applicationSupportURL, withIntermediateDirectories: true) let data = try encoder.encode(profiles) try data.write(to: registryURL, options: [.atomic]) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift new file mode 100644 index 00000000..4d800db2 --- /dev/null +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRequestWriter.swift @@ -0,0 +1,107 @@ +import Foundation + +public protocol HelperRequestWriting: Sendable { + func write(_ data: Data, to handle: FileHandle) async throws +} + +public final class PipeRequestWriter: HelperRequestWriting, @unchecked Sendable { + private let chunkSize: Int + + public init(chunkSize: Int = 4096) { + self.chunkSize = chunkSize + } + + public func write(_ data: Data, to handle: FileHandle) async throws { + try Task.checkCancellation() + guard !data.isEmpty else { + return + } + + let state = PipeRequestWriteState(data: data, handle: handle, chunkSize: chunkSize) + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + state.start(continuation: continuation) + } + } onCancel: { + state.cancel() + } + } +} + +private final class PipeRequestWriteState: @unchecked Sendable { + private let data: Data + private let handle: FileHandle + private let chunkSize: Int + private let lock = NSLock() + private var offset = 0 + private var continuation: CheckedContinuation? + private var completed = false + + init(data: Data, handle: FileHandle, chunkSize: Int) { + self.data = data + self.handle = handle + self.chunkSize = max(1, chunkSize) + } + + func start(continuation: CheckedContinuation) { + lock.lock() + if completed { + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + lock.unlock() + + handle.writeabilityHandler = { [weak self] writableHandle in + self?.writeNextChunk(to: writableHandle) + } + writeNextChunk(to: handle) + } + + func cancel() { + complete(.failure(CancellationError())) + } + + private func writeNextChunk(to handle: FileHandle) { + let chunk: Data + lock.lock() + guard !completed else { + lock.unlock() + return + } + let end = min(offset + chunkSize, data.count) + chunk = data.subdata(in: offset..= data.count + lock.unlock() + if finished { + complete(.success(())) + } + } + + private func complete(_ result: Result) { + lock.lock() + guard !completed else { + lock.unlock() + return + } + completed = true + let continuation = self.continuation + self.continuation = nil + lock.unlock() + + handle.writeabilityHandler = nil + continuation?.resume(with: result) + } +} diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift index 76934b41..14d56bc7 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/HelperRunner.swift @@ -22,10 +22,16 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { private let locator: HelperLocator private let stderrLimit: Int + private let requestWriter: any HelperRequestWriting - public init(locator: HelperLocator = HelperLocator(), stderrLimit: Int = 64 * 1024) { + public init( + locator: HelperLocator = HelperLocator(), + stderrLimit: Int = 64 * 1024, + requestWriter: any HelperRequestWriting = PipeRequestWriter() + ) { self.locator = locator self.stderrLimit = stderrLimit + self.requestWriter = requestWriter } public func run( @@ -76,17 +82,15 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { Self.readCapped(error.fileHandleForReading, limit: stderrLimit) } + let requestData: Data do { var requestParams = params if let context, requestParams["config"] == nil { requestParams["config"] = .string(context.configURL.path) } let request = ["operation": JSONValue.string(operation), "params": JSONValue.object(requestParams)] - let requestData = try JSONEncoder().encode(JSONValue.object(request)) - try input.fileHandleForWriting.write(contentsOf: requestData) - try input.fileHandleForWriting.close() + requestData = try JSONEncoder().encode(JSONValue.object(request)) } catch { - try? input.fileHandleForWriting.close() await Self.terminate(process) await eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) await stdoutTask.value @@ -94,6 +98,41 @@ public final class HelperRunner: @unchecked Sendable, HelperRunning { return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) } + let requestWriter = self.requestWriter + let writeResult: Result = await withTaskCancellationHandler { + do { + try await requestWriter.write(requestData, to: input.fileHandleForWriting) + try input.fileHandleForWriting.close() + return .success(()) + } catch { + return .failure(error) + } + } onCancel: { + try? input.fileHandleForWriting.close() + Task { + await Self.terminate(process) + } + } + + if case .failure(let error) = writeResult { + try? input.fileHandleForWriting.close() + await Self.terminate(process) + await stdoutTask.value + let stderr = await stderrTask.value + if Task.isCancelled || error is CancellationError { + await eventSink(BackendEvent.error( + operation: operation, + code: "cancelled", + message: L10n.string("helper.error.cancelled"), + debug: stderr.isEmpty ? nil : .object(["stderr": .string(stderr)]) + )) + let sawTerminalEvent = await terminalTracker.sawTerminalEvent + return HelperRunResult(exitCode: 130, sawTerminalEvent: sawTerminalEvent, stderr: stderr) + } + await eventSink(BackendEvent.error(operation: operation, code: "helper_write_failed", message: error.localizedDescription)) + return HelperRunResult(exitCode: 1, sawTerminalEvent: true, stderr: stderr) + } + await withTaskCancellationHandler { await Self.waitForExit(process) } onCancel: { diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift index bc632642..505c7533 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/OperationCoordinator.swift @@ -89,7 +89,7 @@ final class OperationCoordinator: ObservableObject { password: String? = nil ) -> OperationStartResult { guard !backend.isRunning else { - let message = "Another operation is already running." + let message = L10n.string("operation.error.already_running") rejectedOperationMessage = message return .rejected(message) } diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift index 5ad0994b..da2e834b 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/PasswordStore.swift @@ -1,6 +1,36 @@ 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) @@ -8,7 +38,7 @@ enum PasswordStoreError: Error, Equatable, LocalizedError { var errorDescription: String? { switch self { case .missing: - return "Password is missing." + return L10n.string("password.error.missing") case .unavailable(let message): return message } @@ -26,9 +56,17 @@ final class KeychainPasswordStore: PasswordStore { static let service = "TimeCapsuleSMB.DevicePassword" private let service: String - - init(service: String = KeychainPasswordStore.service) { + 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 { @@ -37,7 +75,7 @@ final class KeychainPasswordStore: PasswordStore { query[kSecReturnData as String] = true var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) + let status = keychainClient.copyMatching(query, result: &result) if status == errSecItemNotFound { throw PasswordStoreError.missing } @@ -46,7 +84,7 @@ final class KeychainPasswordStore: PasswordStore { } guard let data = result as? Data, let password = String(data: data, encoding: .utf8) else { - throw PasswordStoreError.unavailable("Keychain returned an unreadable password.") + throw PasswordStoreError.unavailable(L10n.string("password.error.unreadable_keychain_item")) } return password } @@ -54,8 +92,11 @@ final class KeychainPasswordStore: PasswordStore { func save(_ password: String, for account: String) throws { let data = Data(password.utf8) var query = baseQuery(account: account) - let attributes = [kSecValueData as String: data] - let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: accessibility + ] + let status = keychainClient.update(query, attributes: attributes) if status == errSecSuccess { return } @@ -63,15 +104,15 @@ final class KeychainPasswordStore: PasswordStore { throw PasswordStoreError.unavailable(message(for: status)) } query[kSecValueData as String] = data - query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - let addStatus = SecItemAdd(query as CFDictionary, nil) + 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 = SecItemDelete(baseQuery(account: account) as CFDictionary) + let status = keychainClient.delete(baseQuery(account: account)) if status == errSecSuccess || status == errSecItemNotFound { return } @@ -98,10 +139,10 @@ final class KeychainPasswordStore: PasswordStore { } private func message(for status: OSStatus) -> String { - if let message = SecCopyErrorMessageString(status, nil) as String? { + if let message = keychainClient.message(for: status) { return message } - return "Keychain error \(status)." + return L10n.format("password.error.keychain_status", status) } } @@ -126,7 +167,7 @@ final class InMemoryPasswordStore: PasswordStore { func password(for account: String) throws -> String { if readFailure != nil { - throw PasswordStoreError.unavailable("In-memory password store read failed.") + throw PasswordStoreError.unavailable(L10n.string("password.error.memory_read_failed")) } guard let password = passwords[account] else { throw PasswordStoreError.missing @@ -136,7 +177,7 @@ final class InMemoryPasswordStore: PasswordStore { func save(_ password: String, for account: String) throws { if saveFailure != nil { - throw PasswordStoreError.unavailable("In-memory password store save failed.") + throw PasswordStoreError.unavailable(L10n.string("password.error.memory_save_failed")) } passwords[account] = password invalidAccounts.remove(account) @@ -144,7 +185,7 @@ final class InMemoryPasswordStore: PasswordStore { func deletePassword(for account: String) throws { if deleteFailure != nil { - throw PasswordStoreError.unavailable("In-memory password store delete failed.") + throw PasswordStoreError.unavailable(L10n.string("password.error.memory_delete_failed")) } passwords.removeValue(forKey: account) invalidAccounts.remove(account) diff --git a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings index 9efd6d10..5d1c9b5d 100644 --- a/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings +++ b/macos/TimeCapsuleSMB/Sources/TimeCapsuleSMBApp/Resources/en.lproj/Localizable.strings @@ -11,11 +11,28 @@ "add_device.connection_method" = "Connection Method"; "add_device.discover.placeholder" = "Browse for AirPort Bonjour services"; "add_device.discovered_devices" = "Discovered Devices"; +"add_device.entry.discover" = "Discover"; +"add_device.entry.manual" = "Manual Address"; +"add_device.error.choose_target" = "Choose a discovered device or enter a host."; +"add_device.error.invalid_bonjour_timeout" = "Bonjour timeout must be a non-negative number."; +"add_device.error.password_required" = "Time Capsule password is required."; "add_device.host_or_ip" = "Host or IP"; "add_device.password" = "Time Capsule password"; "add_device.reset" = "Reset"; "add_device.save_device" = "Save Device"; "add_device.saved" = "Saved %@"; +"add_device.state.auth_failed" = "Password Rejected"; +"add_device.state.configuring" = "Configuring"; +"add_device.state.discovering" = "Discovering"; +"add_device.state.discovery_empty" = "No Devices Found"; +"add_device.state.discovery_ready" = "Devices Found"; +"add_device.state.failed" = "Failed"; +"add_device.state.idle" = "Idle"; +"add_device.state.manual_entry" = "Manual Address"; +"add_device.state.password_entry" = "Password Required"; +"add_device.state.saved" = "Saved"; +"add_device.state.saving_profile" = "Saving"; +"add_device.state.unsupported" = "Unsupported"; "add_device.title" = "Add Time Capsule"; "advanced.config" = "Config"; "advanced.flash_cli_only" = "Flash backup, patch, and restore remain CLI-only in this version."; @@ -190,13 +207,20 @@ "maintenance.workflow.uninstall" = "Uninstall"; "overview.empty.message" = "Add a Time Capsule to configure SMB, run checkups, and manage maintenance tasks."; "overview.empty.title" = "No Time Capsules Saved"; +"operation.error.already_running" = "Another operation is already running."; "panel.connect" = "Discover And Connect"; "password_state.available" = "Available"; "password_state.invalid" = "Invalid"; "password_state.keychain_unavailable" = "Keychain unavailable"; "password_state.missing" = "Missing"; "password_state.unknown" = "Unknown"; +"password.error.keychain_status" = "Keychain error %d."; +"password.error.memory_delete_failed" = "In-memory password store delete failed."; +"password.error.memory_read_failed" = "In-memory password store read failed."; +"password.error.memory_save_failed" = "In-memory password store save failed."; +"password.error.missing" = "Password is missing."; "password.error.required" = "Password is required."; +"password.error.unreadable_keychain_item" = "Keychain returned an unreadable password."; "readiness.blocked.title" = "TimeCapsuleSMB cannot start"; "readiness.state.checking_capabilities" = "Checking helper"; "readiness.state.resolving_bundle" = "Preparing app runtime"; diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift index b0c82bc7..372aba14 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/AddDeviceFlowStoreTests.swift @@ -325,7 +325,7 @@ final class AddDeviceFlowStoreTests: XCTestCase { XCTAssertEqual(fixture.runner.calls[0].context?.profileID, existing.id) } - func testKeychainSaveFailureLeavesProfilePasswordMissing() async throws { + func testKeychainSaveFailureDoesNotSaveProfile() async throws { let fixture = try makeStore(responses: [ .init(events: [ BackendEvent(type: "result", operation: "configure", ok: true, payload: testConfigurePayload(host: "10.0.0.2")) @@ -338,11 +338,10 @@ final class AddDeviceFlowStoreTests: XCTestCase { fixture.store.runConfigure() - try await waitUntilStoreState { fixture.store.state == .saved } - let profile = try XCTUnwrap(fixture.store.savedProfile) - XCTAssertEqual(profile.passwordState, .missing) - XCTAssertEqual(fixture.registry.profile(id: profile.id)?.passwordState, .missing) - XCTAssertEqual(fixture.passwordStore.state(for: profile.keychainAccount), .missing) + 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 { diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift new file mode 100644 index 00000000..0185a11a --- /dev/null +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/ConfiguredDeviceProfileSaverTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import TimeCapsuleSMBApp + +@MainActor +final class ConfiguredDeviceProfileSaverTests: XCTestCase { + func testKeychainFailureDoesNotPersistProfile() throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let passwordStore = InMemoryPasswordStore() + passwordStore.saveFailure = .save + let saver = ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) + + XCTAssertThrowsError(try saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + password: "secret", + preferredID: "device-one" + )) + + XCTAssertEqual(registry.profiles, []) + XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) + } + + func testRegistryFailureRollsBackNewKeychainPassword() throws { + let temp = try TemporaryDirectory() + let blockedApplicationSupport = temp.url.appendingPathComponent("not-a-directory") + try "file".write(to: blockedApplicationSupport, atomically: true, encoding: .utf8) + let registry = DeviceRegistryStore(applicationSupportURL: blockedApplicationSupport) + let passwordStore = InMemoryPasswordStore() + let saver = ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) + + XCTAssertThrowsError(try saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + password: "secret", + preferredID: "device-one" + )) + + XCTAssertEqual(registry.profiles, []) + XCTAssertEqual(passwordStore.state(for: "device-one"), .missing) + } + + func testRegistryFailureRestoresExistingKeychainPassword() throws { + let temp = try TemporaryDirectory() + let registry = DeviceRegistryStore(applicationSupportURL: temp.url) + registry.load() + let existing = try registry.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2"), + discoveredDevice: nil, + passwordState: .available, + preferredID: "device-one" + ) + let passwordStore = InMemoryPasswordStore(passwords: [existing.keychainAccount: "old-secret"]) + let saver = ConfiguredDeviceProfileSaver(registry: registry, passwordStore: passwordStore) + let blockedRegistryPath = registry.registryURL + try FileManager.default.removeItem(at: blockedRegistryPath) + try FileManager.default.createDirectory(at: blockedRegistryPath, withIntermediateDirectories: false) + + XCTAssertThrowsError(try saver.saveConfiguredDevice( + configuredDevice: testConfiguredDevice(host: "10.0.0.2", model: "Updated Capsule"), + discoveredDevice: nil, + password: "new-secret", + preferredID: "device-one" + )) + + XCTAssertEqual(try passwordStore.password(for: existing.keychainAccount), "old-secret") + XCTAssertEqual(registry.profile(id: existing.id)?.model, existing.model) + } +} diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift index f12fd120..d6e3057c 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/HelperRunnerTests.swift @@ -171,6 +171,33 @@ final class HelperRunnerTests: XCTestCase { XCTAssertEqual(events.last?.message, L10n.string("helper.error.cancelled")) } + func testRunnerCancelsBlockedRequestWrite() async throws { + let temp = try TemporaryDirectory() + let helper = try makeHelper( + in: temp.url, + body: """ + sleep 10 + """ + ) + let runner = HelperRunner(locator: HelperLocator(environment: [:], currentDirectory: temp.url, bundle: .main, fileManager: .default)) + let recorder = EventRecorder() + let largePayload = String(repeating: "x", count: 8 * 1024 * 1024) + + let task = Task { + await runner.run(helperPath: helper.path, operation: "doctor", params: ["payload": .string(largePayload)]) { + await recorder.append($0) + } + } + try await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + let result = await task.value + + let events = await recorder.events + XCTAssertEqual(result.exitCode, 130) + XCTAssertEqual(events.last?.type, "error") + XCTAssertEqual(events.last?.code, "cancelled") + } + private func makeHelper(in directory: URL, body: String) throws -> URL { let helper = directory.appendingPathComponent("tcapsule") try "#!/bin/sh\n\(body)\n".write(to: helper, atomically: true, encoding: .utf8) diff --git a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift index 6b649bb3..62987937 100644 --- a/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift +++ b/macos/TimeCapsuleSMB/Tests/TimeCapsuleSMBAppTests/PasswordStoreTests.swift @@ -1,3 +1,4 @@ +import Security import XCTest @testable import TimeCapsuleSMBApp @@ -52,4 +53,69 @@ final class PasswordStoreTests: XCTestCase { } } } + + 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)" + } }